feat(completion): fix SQL alias completion, star expansion, and formatter layout
This commit is contained in:
1 parent
0402fea37b
commit
868f7961e3
30 files changed
+758
-76
No files matched your search
@@ -23,10 +23,38 @@ function selectStarToken(model: SqlSemanticModel): SqlSemanticToken | undefined
|
||||
return model.tokens.find((token) => token.text === "*" && token.span.start === range.start && token.span.end === range.end);
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarTableSources(model: SqlSemanticModel): SqlSemanticRowSource[] {
|
||||
const star = selectStarToken(model);
|
||||
if (!star) return [];
|
||||
|
||||
if (model.cursorIntent.targetSourceId) {
|
||||
const target = model.rowSources.find((source) => source.id === model.cursorIntent.targetSourceId);
|
||||
return target?.kind === "table" ? [target] : [];
|
||||
}
|
||||
if (model.cursorIntent.qualifierParts.length > 0) return [];
|
||||
|
||||
let selectStart = -1;
|
||||
for (let index = model.tokens.length - 1; index >= 0; index -= 1) {
|
||||
const token = model.tokens[index];
|
||||
if (!token || token.span.end > star.span.start || token.depth !== star.depth) continue;
|
||||
if (token.kind === "word" && token.normalized === "select") {
|
||||
selectStart = token.span.start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selectStart < 0) return [];
|
||||
|
||||
const blockSources = model.rowSources.filter((source) => {
|
||||
if (source.sourceSpan.start < selectStart) return false;
|
||||
const sourceToken = model.tokens.find((token) => token.span.start === source.sourceSpan.start);
|
||||
return sourceToken?.depth === star.depth;
|
||||
});
|
||||
return blockSources.every((source) => source.kind === "table") ? blockSources : [];
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarTableSource(model: SqlSemanticModel): SqlSemanticRowSource | undefined {
|
||||
if (model.cursorIntent.kind !== "star") return undefined;
|
||||
const source = model.cursorIntent.targetSourceId ? model.rowSources.find((candidate) => candidate.id === model.cursorIntent.targetSourceId) : model.rowSources.length === 1 ? model.rowSources[0] : undefined;
|
||||
return source?.kind === "table" ? source : undefined;
|
||||
const sources = sqlSemanticSelectStarTableSources(model);
|
||||
return sources.length === 1 ? sources[0] : undefined;
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarQualifierSql(model: SqlSemanticModel): string | undefined {
|
||||
@@ -80,6 +108,7 @@ export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlComplet
|
||||
schema: source.qualifierParts[source.qualifierParts.length - 1],
|
||||
schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,
|
||||
alias: source.alias,
|
||||
aliasSql: source.aliasSpan ? model.sql.slice(source.aliasSpan.start, source.aliasSpan.end) : source.alias,
|
||||
columns: source.columns,
|
||||
columnAliases: source.columnAliases,
|
||||
};
|
||||
|
||||
@@ -1184,6 +1184,7 @@ export interface SqlCompletionColumn {
|
||||
name: string;
|
||||
table: string;
|
||||
sourceAlias?: string;
|
||||
sourceQualifierSql?: string;
|
||||
schema?: string;
|
||||
dataType?: string;
|
||||
isNullable?: boolean;
|
||||
@@ -1201,7 +1202,7 @@ export interface SqlCompletionForeignKey {
|
||||
export interface SqlCompletionItem {
|
||||
label: string;
|
||||
filterText?: string;
|
||||
type: "keyword" | "table" | "column" | "snippet" | "function" | "schema";
|
||||
type: "keyword" | "table" | "column" | "snippet" | "function" | "schema" | "variable" | "text";
|
||||
detail?: string;
|
||||
info?: string;
|
||||
apply?: string;
|
||||
@@ -1223,6 +1224,7 @@ export interface SqlCompletionReferencedTable {
|
||||
schema?: string;
|
||||
schemaQuoted?: boolean;
|
||||
alias?: string;
|
||||
aliasSql?: string;
|
||||
columns?: string[];
|
||||
columnAliases?: string[];
|
||||
}
|
||||
@@ -1292,6 +1294,7 @@ export interface SqlCompletionTranslations {
|
||||
numericLiteral: string;
|
||||
booleanValue: string;
|
||||
starExpansionColumns: string;
|
||||
tableAlias: string;
|
||||
functionDescriptions: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -1393,6 +1396,10 @@ class SqlCompletionProvider {
|
||||
this.items.push(...buildPreferredKeywordItems(context.prefix, context.preferredKeywords, this.input.keywordCase));
|
||||
}
|
||||
|
||||
if (!pendingJoinKeyword && context.suggestColumns && !context.qualifier && !context.insertTable && context.prefix) {
|
||||
this.items.push(...buildReferencedAliasItems(context, this.t));
|
||||
}
|
||||
|
||||
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.prioritizeSelectAliases) {
|
||||
this.items.push(...buildSelectAliasItems(context));
|
||||
}
|
||||
@@ -1456,8 +1463,8 @@ class SqlCompletionProvider {
|
||||
// Alias snippets reuse the prefix as a label while applying alias SQL, so they are not exact name matches.
|
||||
const isAliasSnippet = item.type === "snippet" && item.apply === formatAliasCompletionApply(item.label, this.databaseType, this.input.keywordCase);
|
||||
const isExactLabelMatch = !isAliasSnippet && item.label.toLowerCase() === context.prefix.toLowerCase();
|
||||
const isExactSnippetPrefixMatch = item.type === "snippet" && item.filterText?.toLowerCase() === context.prefix.toLowerCase();
|
||||
if (isExactLabelMatch || isExactSnippetPrefixMatch) {
|
||||
const isExactFilterTextMatch = item.filterText?.toLowerCase() === context.prefix.toLowerCase();
|
||||
if (isExactLabelMatch || isExactFilterTextMatch) {
|
||||
item.exactMatch = true;
|
||||
item.boost += EXACT_LABEL_MATCH_BOOST;
|
||||
}
|
||||
@@ -3239,8 +3246,11 @@ function buildPreferredKeywordItems(prefix: string, keywords: string[], keywordC
|
||||
}
|
||||
|
||||
function selectStarExpansionColumns(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>): SqlCompletionColumn[] {
|
||||
const columns = context.qualifier ? referencedTablesForSelectAllColumns(context).flatMap((ref) => columnsForSelectAllReferencedTable(ref, columnsByTable)) : [...columnsByTable.values()].flat();
|
||||
return uniqueColumnsByName(columns);
|
||||
const references = referencedTablesForSelectAllColumns(context);
|
||||
if (context.qualifier || references.length > 1) {
|
||||
return references.flatMap((reference) => uniqueColumnsByName(columnsForSelectAllReferencedTable(reference, columnsByTable)));
|
||||
}
|
||||
return uniqueColumnsByName([...columnsByTable.values()].flat());
|
||||
}
|
||||
|
||||
export function selectStarResultColumnsMatch(options: { currentSql: string; targetFrom: number; targetTo: number; statementSql: string; sourceStatement?: string; sourceFrom?: number; sourceTo?: number }): boolean {
|
||||
@@ -3259,7 +3269,17 @@ export function buildSelectStarExpansion(context: SqlCompletionContext, columnsB
|
||||
const columns = selectStarExpansionColumns(context, columnsByTable);
|
||||
if (columns.length === 0) return null;
|
||||
// `alias.*` replaces only the `*`, so the first column must continue the already typed `alias.`.
|
||||
return qualifierSql ? buildSelectAllColumnExpansion(columns, qualifierSql, true, dialect, databaseType) : columns.map((column) => quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)).join(", ");
|
||||
if (qualifierSql) return buildSelectAllColumnExpansion(columns, qualifierSql, true, dialect, databaseType);
|
||||
|
||||
const references = referencedTablesForSelectAllColumns(context);
|
||||
if (references.length <= 1) return columns.map((column) => quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)).join(", ");
|
||||
|
||||
return references
|
||||
.flatMap((reference) => {
|
||||
const qualifier = reference.aliasSql ?? (reference.alias ? quoteSqlIdentifier(reference.alias, dialect) : quoteSqlIdentifier(reference.name, dialect));
|
||||
return uniqueColumnsByName(columnsForSelectAllReferencedTable(reference, columnsByTable)).map((column) => `${qualifier}.${quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)}`);
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function buildStarExpansionItem(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem | null {
|
||||
@@ -3480,6 +3500,27 @@ function buildComparisonValueItems(context: SqlCompletionContext, columnsByTable
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildReferencedAliasItems(context: SqlCompletionContext, t?: SqlCompletionTranslations): SqlCompletionItem[] {
|
||||
const seen = new Set<string>();
|
||||
const items: SqlCompletionItem[] = [];
|
||||
for (const reference of context.referencedTables) {
|
||||
const alias = reference.alias?.trim();
|
||||
if (!alias || !matchesIdentifierSearch(alias, context.prefix)) continue;
|
||||
const key = normalizeIdentifierPart(alias);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const tableName = reference.schema ? `${reference.schema}.${reference.name}` : reference.name;
|
||||
items.push({
|
||||
label: alias,
|
||||
type: "text",
|
||||
detail: `${t?.tableAlias ?? "Table alias"} · ${tableName}`,
|
||||
apply: reference.aliasSql ?? alias,
|
||||
boost: 20_000 + identifierMatchScore(alias, context.prefix) - items.length,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildAliasItems(context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
|
||||
const items: SqlCompletionItem[] = [];
|
||||
const existingAliases = new Set(context.referencedTables.map((ref) => ref.alias?.toLowerCase()).filter((alias): alias is string => !!alias));
|
||||
@@ -3673,23 +3714,28 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map<str
|
||||
// Count name frequencies to detect duplicates across tables
|
||||
const nameCount = new Map<string, number>();
|
||||
for (const c of relevantCols) {
|
||||
nameCount.set(c.name, (nameCount.get(c.name) || 0) + 1);
|
||||
const nameKey = normalizeIdentifierPart(c.name);
|
||||
nameCount.set(nameKey, (nameCount.get(nameKey) || 0) + 1);
|
||||
}
|
||||
|
||||
// Deduplicate — for dupes, qualify with table name
|
||||
// Multi-source queries always insert a qualified column. Duplicate names remain
|
||||
// separate choices so the user can select the intended row source.
|
||||
const qualifyAllColumns = !context.qualifier && !context.insertTable && context.referencedTables.length > 1;
|
||||
const seen = new Set<string>();
|
||||
const uniqueColumns: Array<SqlCompletionColumn & { key: string; displayLabel: string }> = [];
|
||||
for (const c of relevantCols) {
|
||||
const count = nameCount.get(c.name) || 0;
|
||||
if (count > 1) {
|
||||
const qualifier = c.sourceAlias ?? c.table;
|
||||
const count = nameCount.get(normalizeIdentifierPart(c.name)) || 0;
|
||||
if (count > 1 || qualifyAllColumns) {
|
||||
const qualifier = c.sourceQualifierSql ?? c.sourceAlias ?? c.table;
|
||||
const qualifiedKey = `${qualifier}.${c.name}`;
|
||||
if (seen.has(qualifiedKey)) continue;
|
||||
seen.add(qualifiedKey);
|
||||
const normalizedQualifiedKey = normalizeCompletionKey(qualifiedKey);
|
||||
if (seen.has(normalizedQualifiedKey)) continue;
|
||||
seen.add(normalizedQualifiedKey);
|
||||
uniqueColumns.push({ ...c, key: c.key, displayLabel: `${qualifier}.${c.name}` });
|
||||
} else {
|
||||
if (seen.has(c.name)) continue;
|
||||
seen.add(c.name);
|
||||
const nameKey = normalizeIdentifierPart(c.name);
|
||||
if (seen.has(nameKey)) continue;
|
||||
seen.add(nameKey);
|
||||
uniqueColumns.push({ ...c, key: c.key, displayLabel: c.name });
|
||||
}
|
||||
}
|
||||
@@ -3707,6 +3753,7 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map<str
|
||||
const matchScore = Math.max(identifierMatchScore(column.name, context.prefix), identifierMatchScore(column.displayLabel, context.prefix));
|
||||
return {
|
||||
label: column.displayLabel,
|
||||
filterText: column.displayLabel === column.name ? undefined : column.name,
|
||||
type: "column" as const,
|
||||
detail: buildColumnDetail(column),
|
||||
info: buildColumnInfo(column),
|
||||
@@ -3721,7 +3768,7 @@ function completionColumnsForReferencedTable<T extends SqlCompletionColumn & { k
|
||||
const matched = columns.filter((column) => columnMatchesReferencedTable(column, table));
|
||||
const aliasedColumns = applyReferencedColumnAliases(table, matched);
|
||||
if (!table.alias) return aliasedColumns;
|
||||
return aliasedColumns.map((column) => ({ ...column, sourceAlias: table.alias }));
|
||||
return aliasedColumns.map((column) => ({ ...column, sourceAlias: table.alias, sourceQualifierSql: table.aliasSql }));
|
||||
}
|
||||
|
||||
function applyReferencedColumnAliases<T extends SqlCompletionColumn>(table: SqlCompletionReferencedTable, columns: readonly T[]): T[] {
|
||||
@@ -3781,7 +3828,8 @@ function buildColumnApply(column: SqlCompletionColumn & { displayLabel: string }
|
||||
if (context.qualifier || column.displayLabel === column.name || !column.displayLabel.includes(".")) {
|
||||
return quoteSqlIdentifier(column.name, dialect);
|
||||
}
|
||||
return `${quoteSqlIdentifier(column.sourceAlias ?? column.table, dialect)}.${quoteSqlIdentifier(column.name, dialect)}`;
|
||||
const qualifier = column.sourceQualifierSql ?? quoteSqlIdentifier(column.sourceAlias ?? column.table, dialect);
|
||||
return `${qualifier}.${quoteSqlIdentifier(column.name, dialect)}`;
|
||||
}
|
||||
|
||||
function isKeyColumn(name: string): boolean {
|
||||
@@ -4515,6 +4563,7 @@ function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] {
|
||||
}
|
||||
|
||||
function compareCompletionItems(left: SqlCompletionItem, right: SqlCompletionItem): number {
|
||||
if ((left.type === "variable") !== (right.type === "variable")) return left.type === "variable" ? -1 : 1;
|
||||
if (!left.exactMatch !== !right.exactMatch) return left.exactMatch ? -1 : 1;
|
||||
const leftBonus = getHistoryBoost(left.label, left.type);
|
||||
const rightBonus = getHistoryBoost(right.label, right.type);
|
||||
@@ -4529,6 +4578,10 @@ function getTypePriorityBoost(type: SqlCompletionItem["type"]): number {
|
||||
return 160;
|
||||
case "schema":
|
||||
return 120;
|
||||
case "variable":
|
||||
return 220;
|
||||
case "text":
|
||||
return 220;
|
||||
case "function":
|
||||
return 90;
|
||||
case "snippet":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_SQL_FORMATTER_SETTINGS, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { looksLikeXml } from "@/lib/sql/autoFormat";
|
||||
|
||||
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "clickhouse" | "generic";
|
||||
@@ -86,10 +86,12 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
||||
}
|
||||
|
||||
const { format } = await import("sql-formatter");
|
||||
const options = sqlFormatterOptions(settings);
|
||||
const normalizedSettings = normalizeSqlFormatterSettings(settings);
|
||||
const options = sqlFormatterOptions(normalizedSettings);
|
||||
const language = formatterLanguage(dialect);
|
||||
try {
|
||||
return format(sql, { language, ...options });
|
||||
const formatted = format(sql, { language, ...options });
|
||||
return applySqlFormatterLayout(formatted, normalizedSettings, dialect);
|
||||
} catch (err) {
|
||||
// The generic "sql" dialect can't parse many real-world constructs (PostgreSQL
|
||||
// `::` casts, GaussDB/openGauss materialized-view DDL, T-SQL specifics, ...).
|
||||
@@ -97,7 +99,8 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
||||
// that tolerates most of these, before surfacing the failure.
|
||||
if (language !== "postgresql") {
|
||||
try {
|
||||
return format(sql, { language: "postgresql", ...options });
|
||||
const formatted = format(sql, { language: "postgresql", ...options });
|
||||
return applySqlFormatterLayout(formatted, normalizedSettings, dialect);
|
||||
} catch {
|
||||
// fall through to the original error below
|
||||
}
|
||||
@@ -106,6 +109,236 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces block comments, line comments and string/identifier literals in
|
||||
* `sql` with opaque placeholders, returning the masked text plus the captured
|
||||
* spans in source order. The two regexes in {@link keepLogicalOperatorsOnSameLine}
|
||||
* operate on plain text and cannot tell a logical operator inside a comment or
|
||||
* string literal from one in a real SQL clause, so a multi-line `/* ... AND ...
|
||||
* OR ... *\/` comment would get its internal line breaks collapsed. Masking
|
||||
* those spans first keeps the regexes away from them; {@link restoreSpans} puts
|
||||
* the original text back afterwards.
|
||||
*
|
||||
* The scanner reuses the same dialect-aware token recognition as
|
||||
* {@link compressSqlText}: block comments (with nesting for Postgres/SQL
|
||||
* Server/ClickHouse), `--`/`#` line comments, Postgres dollar-quoted strings,
|
||||
* single-quoted strings (`''` and MySQL/`E'...'` backslash escapes), double-
|
||||
* quoted identifiers (`""` escapes), MySQL backtick identifiers and SQL Server
|
||||
* `[...]` identifiers. sql-formatter normalizes string literals to a single
|
||||
* line, but masking them is cheap and defends against callers that ever pass
|
||||
* raw (non-formatter) SQL through this path.
|
||||
*/
|
||||
function maskStringAndCommentSpans(sql: string, dialect: SqlFormatDialect): { masked: string; spans: string[] } {
|
||||
const len = sql.length;
|
||||
const spans: string[] = [];
|
||||
const placeholder = (index: number) => `\x00${index}\x00`;
|
||||
let out = "";
|
||||
let i = 0;
|
||||
|
||||
const isIdentifierPart = (c: string | undefined) => c !== undefined && /[A-Za-z0-9_$]/.test(c);
|
||||
const supportsNestedBlockComments = dialect === "postgres" || dialect === "sqlserver" || dialect === "clickhouse";
|
||||
const isMysqlDashComment = (c: string | undefined) => c === undefined || c.charCodeAt(0) <= 32 || c.charCodeAt(0) === 127;
|
||||
|
||||
const dollarQuoteTagAt = (position: number): string | null => {
|
||||
if (sql[position] !== "$" || isIdentifierPart(sql[position - 1])) return null;
|
||||
if (sql[position + 1] === "$") return "$$";
|
||||
if (!/[A-Za-z_]/.test(sql[position + 1] ?? "")) return null;
|
||||
let end = position + 2;
|
||||
while (/[A-Za-z0-9_]/.test(sql[end] ?? "")) end++;
|
||||
return sql[end] === "$" ? sql.slice(position, end + 1) : null;
|
||||
};
|
||||
|
||||
// Captures a full span starting at `start` (already consumed into `i`) and
|
||||
// emits a placeholder. `end` is the index just past the span terminator.
|
||||
const emit = (start: number, end: number) => {
|
||||
spans.push(sql.slice(start, end));
|
||||
out += placeholder(spans.length - 1);
|
||||
};
|
||||
|
||||
while (i < len) {
|
||||
const ch = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
// 块注释 /* ... */(含嵌套)
|
||||
if (ch === "/" && next === "*") {
|
||||
const start = i;
|
||||
i += 2;
|
||||
let depth = 1;
|
||||
while (i < len && depth > 0) {
|
||||
if (supportsNestedBlockComments && sql[i] === "/" && sql[i + 1] === "*") {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (sql[i] === "*" && sql[i + 1] === "/") {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
// 未闭合的块注释:原样保留剩余文本(不遮罩),避免破坏用户输入。
|
||||
if (depth > 0) {
|
||||
out += sql.slice(start);
|
||||
i = len;
|
||||
} else {
|
||||
emit(start, i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 行注释 -- ... / # ...(不跨行,但遮罩可防御未来变更)
|
||||
const startsDashComment = ch === "-" && next === "-" && (dialect !== "mysql" || isMysqlDashComment(sql[i + 2]));
|
||||
if (startsDashComment || (dialect === "mysql" && ch === "#")) {
|
||||
const start = i;
|
||||
i += startsDashComment ? 2 : 1;
|
||||
while (i < len && sql[i] !== "\n" && sql[i] !== "\r") i++;
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// PostgreSQL dollar-quoted 字符串
|
||||
if (dialect === "postgres" && ch === "$") {
|
||||
const tag = dollarQuoteTagAt(i);
|
||||
if (tag) {
|
||||
const start = i;
|
||||
i += tag.length;
|
||||
const end = sql.indexOf(tag, i);
|
||||
if (end < 0) {
|
||||
out += sql.slice(start);
|
||||
i = len;
|
||||
} else {
|
||||
emit(start, end + tag.length);
|
||||
i = end + tag.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 单引号字符串('' 转义;MySQL/PG E'...' 反斜杠转义)
|
||||
if (ch === "'") {
|
||||
const start = i;
|
||||
i++;
|
||||
const postgresEscapeString = dialect === "postgres" && (sql[i - 2] === "E" || sql[i - 2] === "e") && !isIdentifierPart(sql[i - 3]);
|
||||
while (i < len) {
|
||||
const c = sql[i];
|
||||
if ((dialect === "mysql" || postgresEscapeString) && c === "\\" && i + 1 < len) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
if (sql[i + 1] === "'") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 双引号标识符("" 转义)
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (dialect === "mysql" && sql[i] === "\\" && i + 1 < len) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql[i] === '"') {
|
||||
if (sql[i + 1] === '"') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 反引号标识符(MySQL)
|
||||
if (ch === "`") {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < len && sql[i] !== "`") i++;
|
||||
if (i < len) i++;
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// SQL Server 方括号标识符 [...](]] 为转义 ])
|
||||
if (dialect === "sqlserver" && ch === "[") {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (sql[i] === "]") {
|
||||
if (sql[i + 1] === "]") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
return { masked: out, spans };
|
||||
}
|
||||
|
||||
function restoreSpans(masked: string, spans: string[]): string {
|
||||
return masked.replace(/\x00(\d+)\x00/g, (_, index) => spans[Number(index)] ?? "");
|
||||
}
|
||||
|
||||
function keepLogicalOperatorsOnSameLine(sql: string, dialect: SqlFormatDialect = "generic"): string {
|
||||
// 先遮罩块注释/字符串/引号标识符,避免正则命中注释或字面量内部的 AND/OR/XOR
|
||||
// (块注释内部跨行的 AND/OR 会被误折叠成空格,破坏用户多行注释格式)。
|
||||
const { masked, spans } = maskStringAndCommentSpans(sql, dialect);
|
||||
const collapsed = masked.replace(/\n[ \t]*(AND|OR|XOR)\b/gi, " $1").replace(/\b(AND|OR|XOR)[ \t]*\n[ \t]*/gi, "$1 ");
|
||||
return restoreSpans(collapsed, spans);
|
||||
}
|
||||
|
||||
function keepFromClauseAndFirstSourceOnSameLine(sql: string): string {
|
||||
const lines = sql.split("\n");
|
||||
for (let index = 0; index < lines.length - 1; index += 1) {
|
||||
const clauseMatch = lines[index].match(/^(\s*)FROM\s*$/i);
|
||||
if (!clauseMatch) continue;
|
||||
|
||||
const sourceLine = lines[index + 1];
|
||||
const sourceMatch = sourceLine.match(/^(\s+)(\S.*)$/);
|
||||
if (!sourceMatch) continue;
|
||||
const source = sourceMatch[2];
|
||||
// Keep derived tables and leading comments multiline; merging these would
|
||||
// make nested SQL and comment boundaries substantially harder to read.
|
||||
if (source.startsWith("(") || source.startsWith("/*") || source.startsWith("--")) continue;
|
||||
|
||||
const clauseIndent = clauseMatch[1];
|
||||
const sourceIndent = sourceMatch[1];
|
||||
const separator = sourceIndent.startsWith(clauseIndent) ? sourceIndent.slice(clauseIndent.length) : " ";
|
||||
lines[index] = `${lines[index]}${separator || " "}${source}`;
|
||||
lines.splice(index + 1, 1);
|
||||
index -= 1;
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function applySqlFormatterLayout(sql: string, settings: SqlFormatterSettings, dialect: SqlFormatDialect): string {
|
||||
let formatted = settings.logicalOperatorNewline === "none" ? keepLogicalOperatorsOnSameLine(sql, dialect) : sql;
|
||||
if (settings.fromClauseLayout === "sameLine") formatted = keepFromClauseAndFirstSourceOnSameLine(formatted);
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function isSqlFormatterParseError(error: unknown): boolean {
|
||||
if (!(error instanceof Error) || !error.message.startsWith("Parse error at token:")) return false;
|
||||
const candidate = error as Error & { offset?: unknown; token?: unknown };
|
||||
|
||||
@@ -3,7 +3,8 @@ export const SQL_FORMATTER_CONFIG_FORMATTER = "sql-formatter";
|
||||
|
||||
const CASE_VALUES = ["preserve", "upper", "lower"] as const;
|
||||
const INDENT_STYLE_VALUES = ["standard", "tabularLeft", "tabularRight"] as const;
|
||||
const LOGICAL_OPERATOR_NEWLINE_VALUES = ["before", "after"] as const;
|
||||
const LOGICAL_OPERATOR_NEWLINE_VALUES = ["before", "after", "none"] as const;
|
||||
const FROM_CLAUSE_LAYOUT_VALUES = ["newLine", "sameLine"] as const;
|
||||
const TAB_WIDTH_VALUES = [2, 4] as const;
|
||||
const EXPRESSION_WIDTH_VALUES = [50, 80, 120] as const;
|
||||
const LINES_BETWEEN_QUERIES_VALUES = [0, 1, 2] as const;
|
||||
@@ -14,6 +15,7 @@ const SQL_FORMATTER_LEGACY_OPTION_KEYS = new Set(["params"]);
|
||||
export type SqlFormatterCase = (typeof CASE_VALUES)[number];
|
||||
export type SqlFormatterIndentStyle = (typeof INDENT_STYLE_VALUES)[number];
|
||||
export type SqlFormatterLogicalOperatorNewline = (typeof LOGICAL_OPERATOR_NEWLINE_VALUES)[number];
|
||||
export type SqlFormatterFromClauseLayout = (typeof FROM_CLAUSE_LAYOUT_VALUES)[number];
|
||||
export type SqlFormatterTabWidth = (typeof TAB_WIDTH_VALUES)[number];
|
||||
export type SqlFormatterExpressionWidth = (typeof EXPRESSION_WIDTH_VALUES)[number];
|
||||
export type SqlFormatterLinesBetweenQueries = (typeof LINES_BETWEEN_QUERIES_VALUES)[number];
|
||||
@@ -44,6 +46,7 @@ export interface SqlFormatterOptionSettings {
|
||||
useTabs: boolean;
|
||||
tabWidth: SqlFormatterTabWidth;
|
||||
logicalOperatorNewline: SqlFormatterLogicalOperatorNewline;
|
||||
fromClauseLayout: SqlFormatterFromClauseLayout;
|
||||
expressionWidth: SqlFormatterExpressionWidth;
|
||||
linesBetweenQueries: SqlFormatterLinesBetweenQueries;
|
||||
denseOperators: boolean;
|
||||
@@ -70,6 +73,7 @@ export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = {
|
||||
useTabs: false,
|
||||
tabWidth: 2,
|
||||
logicalOperatorNewline: "before",
|
||||
fromClauseLayout: "newLine",
|
||||
expressionWidth: 50,
|
||||
linesBetweenQueries: 1,
|
||||
denseOperators: false,
|
||||
@@ -86,6 +90,7 @@ const SQL_FORMATTER_OPTION_KEYS = new Set<keyof SqlFormatterOptionSettings>([
|
||||
"useTabs",
|
||||
"tabWidth",
|
||||
"logicalOperatorNewline",
|
||||
"fromClauseLayout",
|
||||
"expressionWidth",
|
||||
"linesBetweenQueries",
|
||||
"denseOperators",
|
||||
@@ -102,6 +107,7 @@ const SQL_FORMATTER_OPTION_VALIDATORS: Record<keyof SqlFormatterOptionSettings,
|
||||
useTabs: (value) => typeof value === "boolean",
|
||||
tabWidth: (value) => isNumberChoice(value, TAB_WIDTH_VALUES),
|
||||
logicalOperatorNewline: (value) => isStringChoice(value, LOGICAL_OPERATOR_NEWLINE_VALUES),
|
||||
fromClauseLayout: (value) => isStringChoice(value, FROM_CLAUSE_LAYOUT_VALUES),
|
||||
expressionWidth: (value) => isNumberChoice(value, EXPRESSION_WIDTH_VALUES),
|
||||
linesBetweenQueries: (value) => isNumberChoice(value, LINES_BETWEEN_QUERIES_VALUES),
|
||||
denseOperators: (value) => typeof value === "boolean",
|
||||
@@ -182,6 +188,7 @@ export function sqlFormatterOptionSettings(settings: unknown): SqlFormatterOptio
|
||||
useTabs: normalizeBoolean(input.useTabs, DEFAULT_SQL_FORMATTER_SETTINGS.useTabs),
|
||||
tabWidth: normalizeNumberChoice(input.tabWidth, TAB_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.tabWidth),
|
||||
logicalOperatorNewline: normalizeChoice(input.logicalOperatorNewline, LOGICAL_OPERATOR_NEWLINE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.logicalOperatorNewline),
|
||||
fromClauseLayout: normalizeChoice(input.fromClauseLayout, FROM_CLAUSE_LAYOUT_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.fromClauseLayout),
|
||||
expressionWidth: normalizeNumberChoice(input.expressionWidth, EXPRESSION_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.expressionWidth),
|
||||
linesBetweenQueries: normalizeNumberChoice(input.linesBetweenQueries, LINES_BETWEEN_QUERIES_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.linesBetweenQueries),
|
||||
denseOperators: normalizeBoolean(input.denseOperators, DEFAULT_SQL_FORMATTER_SETTINGS.denseOperators),
|
||||
@@ -253,7 +260,9 @@ export function sqlFormatterOptions(settings: unknown) {
|
||||
indentStyle: normalized.indentStyle,
|
||||
useTabs: normalized.useTabs,
|
||||
tabWidth: normalized.tabWidth,
|
||||
logicalOperatorNewline: normalized.logicalOperatorNewline,
|
||||
// sql-formatter itself only supports before/after. The custom "none"
|
||||
// mode is applied as a post-processing pass by formatSqlText.
|
||||
logicalOperatorNewline: normalized.logicalOperatorNewline === "none" ? "before" : normalized.logicalOperatorNewline,
|
||||
expressionWidth: normalized.expressionWidth,
|
||||
linesBetweenQueries: normalized.linesBetweenQueries,
|
||||
denseOperators: normalized.denseOperators,
|
||||
|
||||
Reference in new issue
Block a user