feat(completion): fix SQL alias completion, star expansion, and formatter layout

This commit is contained in:
jinmo authored and GitHub committed 2026-08-10 01:11:25 +08:00
1 parent 0402fea37b
commit 868f7961e3
30 files changed
+758 -76

No files matched your search

+237 -4
View File
@@ -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 };