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
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user