fix(completion): isolate aliases across SQL statements

This commit is contained in:
zipg 2026-07-28 21:08:24 +08:00 committed by GitHub
parent 88721925f2
commit d24cd98776
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 114 additions and 104 deletions

View File

@ -407,6 +407,13 @@ const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
function sqlCompletionDialectOptions() {
return {
databaseType: props.databaseType,
dialect: props.syntaxDialect ?? props.dialect,
};
}
function usesOracleSessionCompletionColumns(schema?: string | null): boolean {
return shouldUseOracleSessionCompletionColumns({
databaseType: props.databaseType,
@ -1780,12 +1787,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
const parts = splitQualifiedIdentifier(identifier);
const name = parts[parts.length - 1] ?? identifier;
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
? buildSqlSemanticModel(sql, pos, {
databaseType: props.databaseType,
dialect: props.syntaxDialect ?? props.dialect,
})
: null;
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions()) : null;
const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
@ -1814,7 +1816,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
};
}
const legacyContext = getSqlCompletionContext(sql, pos);
const legacyContext = getSqlCompletionContext(sql, pos, sqlCompletionDialectOptions());
const context = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyContext) : legacyContext;
const candidates = qualifier ? context.referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : context.referencedTables;
@ -2509,7 +2511,7 @@ async function provideSqlCompletions(context: CompletionContext) {
return provideMongoCompletions(currentState, position, explicit);
}
if (props.databaseType === "elasticsearch") {
if (!isSqlLikeCompletionStatement(fullDoc, position)) {
if (!isSqlLikeCompletionStatement(fullDoc, position, sqlCompletionDialectOptions())) {
return provideElasticsearchCompletions(currentState, position, explicit);
}
}
@ -2522,15 +2524,10 @@ async function provideSqlCompletions(context: CompletionContext) {
try {
if (isSqlCompletionSuppressedContext(fullDoc, position)) return null;
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions())) return null;
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position);
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
? buildSqlSemanticModel(fullDoc, position, {
databaseType: props.databaseType,
dialect: props.syntaxDialect ?? props.dialect,
})
: null;
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, sqlCompletionDialectOptions()) : null;
const completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
if (!hasDatabase) {
@ -2640,7 +2637,7 @@ function flushImeComposition() {
emit("cursorChange", currentView.state.selection.main.head);
latestSelection = readEditorSelection(currentView);
if (editorIsActive) emitEditorSelection(latestSelection);
if (shouldAutoOpenSqlCompletion(currentView.state.doc.toString(), currentView.state.selection.main.head)) {
if (shouldAutoOpenSqlCompletion(currentView.state.doc.toString(), currentView.state.selection.main.head, sqlCompletionDialectOptions())) {
scheduleSqlCompletionStart(currentView);
}
}
@ -2649,20 +2646,20 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s
const position = currentView.state.selection.main.head;
const fullDoc = currentView.state.doc.toString();
if (!insertedText && removedText) {
const completionContext = getSqlCompletionContext(fullDoc, position);
return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position);
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
if (insertedText.endsWith(".")) return true;
if (/[,(]$/.test(insertedText)) {
const completionContext = getSqlCompletionContext(fullDoc, position);
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
return !!completionContext.insertTable;
}
if (/\s$/.test(insertedText)) {
return shouldAutoOpenSqlCompletion(fullDoc, position);
return shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
if (!/[\w$@]$/.test(insertedText)) return false;
const completionContext = getSqlCompletionContext(fullDoc, position);
return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position);
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number) {

View File

@ -16,7 +16,7 @@ function mergeColumns(...maps: Array<Map<string, SqlCompletionColumn[]> | undefi
function semanticCompletion(markedSql: string, input: Partial<SqlCompletionProviderInput> = {}, options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" } = {}) {
const { sql, cursor } = sqlFixtureCursor(markedSql);
const model = buildSqlSemanticModel(sql, cursor, options);
const context = sqlCompletionContextFromSemantic(model, getSqlCompletionContext(sql, cursor));
const context = sqlCompletionContextFromSemantic(model, getSqlCompletionContext(sql, cursor, options));
const columnsByTable = mergeColumns(sqlSemanticLocalColumnsByTable(model), input.columnsByTable);
const items = buildSqlCompletionItemsFromContext(context, {
tables: input.tables ?? [],
@ -35,6 +35,57 @@ function semanticCompletion(markedSql: string, input: Partial<SqlCompletionProvi
}
describe("semantic SQL completion candidates", () => {
it("does not mix SELECT aliases into a following UPDATE statement", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
["codex_completion_a", [{ name: "id", table: "codex_completion_a", schema: "public" }]],
["codex_completion_b", [{ name: "id", table: "codex_completion_b", schema: "public" }]],
]);
const { context, items } = semanticCompletion("SELECT ph.id FROM codex_completion_a AS ph;\n\nUPDATE codex_completion_b\nSET status = 0\nWHERE id|", { columnsByTable }, { databaseType: "postgres", dialect: "postgres" });
expect(context.referencedTables).toEqual([expect.objectContaining({ name: "codex_completion_b" })]);
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["id"]);
});
it("treats PostgreSQL hash operators as part of the preceding statement", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
["codex_completion_a", [{ name: "legacy_id", table: "codex_completion_a", schema: "public" }]],
["codex_completion_b", [{ name: "current_id", table: "codex_completion_b", schema: "public" }]],
]);
const { context, items } = semanticCompletion("SELECT ph.legacy_id # 1 FROM codex_completion_a AS ph;\nUPDATE codex_completion_b SET current_id = 0 WHERE ph.|", { columnsByTable }, { databaseType: "postgres", dialect: "postgres" });
expect(context.statementKind).toBe("update");
expect(context.referencedTables).toEqual([expect.objectContaining({ name: "codex_completion_b" })]);
expect(items.filter((item) => item.type === "column").map((item) => item.label)).not.toContain("legacy_id");
});
it("ignores line-comment semicolons after a real statement boundary", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
["codex_completion_a", [{ name: "legacy_id", table: "codex_completion_a", schema: "public" }]],
["codex_completion_b", [{ name: "current_id", table: "codex_completion_b", schema: "public" }]],
]);
const { context, items } = semanticCompletion("SELECT ph.legacy_id FROM codex_completion_a AS ph; -- separator ; trailing words\nUPDATE codex_completion_b SET current_id = 0 WHERE current_|", { columnsByTable }, { databaseType: "postgres", dialect: "postgres" });
expect(context.statementKind).toBe("update");
expect(context.referencedTables).toEqual([expect.objectContaining({ name: "codex_completion_b" })]);
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["current_id"]);
});
it("ignores block-comment semicolons after a real statement boundary", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
["codex_completion_a", [{ name: "legacy_id", table: "codex_completion_a", schema: "public" }]],
["codex_completion_b", [{ name: "current_id", table: "codex_completion_b", schema: "public" }]],
]);
const { context, items } = semanticCompletion("SELECT ph.legacy_id FROM codex_completion_a AS ph; /* separator ; trailing words */\nUPDATE codex_completion_b SET current_id = 0 WHERE current_|", { columnsByTable }, { databaseType: "postgres", dialect: "postgres" });
expect(context.statementKind).toBe("update");
expect(context.referencedTables).toEqual([expect.objectContaining({ name: "codex_completion_b" })]);
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["current_id"]);
});
it("loads nested alias columns through the database-qualified metadata key", () => {
const { context, items } = semanticCompletion(
"SELECT * FROM aa.tb t WHERE EXISTS (SELECT 1 FROM aa.tb1 t1, aa.tb2 t2 WHERE t1.|)",

View File

@ -21,12 +21,15 @@ describe("sqlSemanticTokens", () => {
expect(isSuppressedSqlSemanticContext(tokens, "select * from users".length)).toBe(false);
});
it("treats hash prefixes as identifiers for SQL Server but comments for MySQL", () => {
it("handles hash tokens according to the SQL dialect", () => {
const sqlServerSql = "SELECT * FROM #temp; SELECT * FROM ##global_temp; SELECT * FROM tempdb..#temp";
const sqlServerTokens = tokenizeSqlSemantic(sqlServerSql, "sqlserver");
const postgresTokens = tokenizeSqlSemantic("SELECT left_value#right_value", "postgres");
expect(sqlServerTokens.filter((token) => token.kind === "word" && token.text.startsWith("#")).map((token) => token.text)).toEqual(["#temp", "##global_temp", "#temp"]);
expect(sqlServerTokens.some((token) => token.kind === "comment")).toBe(false);
expect(postgresTokens.some((token) => token.kind === "operator" && token.text === "#")).toBe(true);
expect(postgresTokens.some((token) => token.kind === "comment")).toBe(false);
expect(tokenizeSqlSemantic("SELECT 1 # comment", "mysql").some((token) => token.kind === "comment" && token.text === "# comment")).toBe(true);
});

View File

@ -199,7 +199,7 @@ export function areSqlSemanticDiagnosticsEqual(left: readonly SqlSemanticDiagnos
export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
const context = getSqlCompletionContext(sql, cursor);
const context = getSqlCompletionContext(sql, cursor, options);
if (context.exclusiveColumnSuggestions) return false;
if (context.qualifier) return false;
if ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor)) return false;
@ -208,7 +208,7 @@ export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, opt
export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
const context = getSqlCompletionContext(sql, cursor);
const context = getSqlCompletionContext(sql, cursor, options);
return context.exclusiveColumnSuggestions || !!context.qualifier || ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor));
}

View File

@ -58,6 +58,12 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql"): SqlSema
continue;
}
if (ch === "#" && dialectId === "postgres") {
index += 1;
tokens.push(token("operator", ch, start, index, depth));
continue;
}
if (ch === "/" && next === "*") {
index += 2;
while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) index += 1;
@ -116,7 +122,7 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql"): SqlSema
if (WORD_START.test(ch)) {
index += 1;
while (index < input.length && WORD_PART.test(input[index] ?? "")) index += 1;
while (index < input.length && WORD_PART.test(input[index] ?? "") && !(dialectId === "postgres" && input[index] === "#")) index += 1;
tokens.push(token("word", input.slice(start, index), start, index, depth));
continue;
}

View File

@ -3,6 +3,9 @@ import type { DatabaseType, SqlSnippet } from "@/types/database";
import { buildMongoCompletionItemsFromContext, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { CLOUDFLARE_D1_COMMON_FUNCTION_NAMES } from "@/lib/sql/cloudflareD1";
import type { SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types";
import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
@ -1303,7 +1306,7 @@ export function buildSqlCompletionItems(
},
): SqlCompletionItem[] {
if (isSqlCompletionSuppressedContext(sql, cursor)) return [];
const context = getSqlCompletionContext(sql, cursor);
const context = getSqlCompletionContext(sql, cursor, input);
return buildSqlCompletionItemsFromContext(context, input);
}
@ -1433,14 +1436,14 @@ class SqlCompletionProvider {
}
}
export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolean {
export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): boolean {
if (isSqlCompletionSuppressedContext(sql, cursor)) return false;
const previousChar = sql[cursor - 1];
if (!previousChar) return false;
if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true;
if (isAfterJoinModifierContext(sql.slice(0, cursor))) return true;
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(sql.slice(0, cursor))) return true;
const context = getSqlCompletionContext(sql, cursor);
const context = getSqlCompletionContext(sql, cursor, options);
if (previousChar === "(" && context.insertTable) return true;
if (/[,;()[\]]/.test(previousChar)) return false;
if (context.exclusiveTableSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) {
@ -1559,17 +1562,25 @@ function getSqlLexicalContext(sql: string, cursor: number): { inLineComment: boo
};
}
export function isSqlLikeCompletionStatement(sql: string, cursor: number): boolean {
const statement = extractStatementAt(sql, cursor).trimStart();
export function isSqlLikeCompletionStatement(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): boolean {
const activeStatementSpan = activeSqlCompletionStatementSpan(sql, cursor, options);
const lineBlock = currentSqlLikeLineBlockSpan(sql, cursor, activeStatementSpan);
const statementSpan = lineBlock ?? activeStatementSpan;
const statement = sql.slice(statementSpan.start, statementSpan.end).trimStart();
if (/^(select|with)\b/i.test(statement)) return true;
return currentLineBlockStartsSql(sql, cursor);
return lineBlock != null;
}
function currentLineBlockStartsSql(sql: string, cursor: number): boolean {
return currentSqlLikeLineBlockSpan(sql, cursor) != null;
function activeSqlCompletionStatementSpan(sql: string, cursor: number, options: SqlSemanticBuildOptions): SqlSemanticSpan {
const safeCursor = Math.max(0, Math.min(cursor, sql.length));
const dialectId = options.databaseType || options.dialect ? sqlSemanticDialectFor(options).id : "mysql";
const tokens = tokenizeSqlSemantic(sql, dialectId);
const statementSpan = findActiveSqlStatementSpan(sql, tokens, safeCursor);
const firstStatementToken = tokens.find((token) => token.kind !== "comment" && token.span.end > statementSpan.start && token.span.start < statementSpan.end);
return firstStatementToken ? { start: firstStatementToken.span.start, end: statementSpan.end } : statementSpan;
}
function currentSqlLikeLineBlockSpan(sql: string, cursor: number): { start: number; end: number } | null {
function currentSqlLikeLineBlockSpan(sql: string, cursor: number, activeStatementSpan: SqlSemanticSpan): SqlSemanticSpan | null {
const safeCursor = Math.max(0, Math.min(cursor, sql.length));
const beforeCursor = sql.slice(0, safeCursor);
const lines = beforeCursor.split(/\r?\n/);
@ -1587,24 +1598,10 @@ function currentSqlLikeLineBlockSpan(sql: string, cursor: number): { start: numb
}
if (start == null) return null;
let end = sql.length;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let index = start; index < sql.length; index += 1) {
const ch = sql[index];
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
else if (ch === ";" && !inSingleQuote && !inDoubleQuote && index >= safeCursor) {
end = index;
break;
}
}
if (activeStatementSpan.start > start) return null;
const blockEnd = currentLineBlockEnd(sql, safeCursor, start);
if (blockEnd != null) end = Math.min(end, blockEnd);
return { start, end };
return { start, end: blockEnd == null ? activeStatementSpan.end : Math.min(activeStatementSpan.end, blockEnd) };
}
function currentLineBlockEnd(sql: string, cursor: number, start: number): number | null {
@ -1650,53 +1647,9 @@ export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databas
};
}
/**
* Find the start position of the SQL statement containing the cursor.
* Respects semicolons and string literals.
*/
function extractStatementStart(sql: string, cursor: number): number {
const lineBlock = currentSqlLikeLineBlockSpan(sql, cursor);
if (lineBlock) return lineBlock.start;
let start = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let i = 0; i < sql.length; i++) {
const ch = sql[i];
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
else if (ch === ";" && !inSingleQuote && !inDoubleQuote) {
if (i < cursor) {
start = i + 1;
while (start < sql.length && /\s/.test(sql[start])) start++;
}
}
}
return start;
}
/**
* Extract the full SQL statement that contains the cursor position.
* Respects semicolons and string literals.
*/
function extractStatementAt(sql: string, cursor: number): string {
const lineBlock = currentSqlLikeLineBlockSpan(sql, cursor);
if (lineBlock) return sql.slice(lineBlock.start, lineBlock.end).trim();
const start = extractStatementStart(sql, cursor);
let end = sql.length;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let i = start; i < sql.length; i++) {
const ch = sql[i];
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
else if (ch === ";" && !inSingleQuote && !inDoubleQuote && i >= cursor) {
end = i;
break;
}
}
return sql.slice(start, end).trim();
function sqlCompletionStatementSpan(sql: string, cursor: number, options: SqlSemanticBuildOptions): SqlSemanticSpan {
const activeStatementSpan = activeSqlCompletionStatementSpan(sql, cursor, options);
return currentSqlLikeLineBlockSpan(sql, cursor, activeStatementSpan) ?? activeStatementSpan;
}
function detectStatementKind(previousStatements: string): SqlStatementKind {
@ -1767,13 +1720,13 @@ function skipSqlWhitespaceAndComments(sql: string, pos: number): number {
}
}
export function getSqlCompletionContext(sql: string, cursor: number): SqlCompletionContext {
export function getSqlCompletionContext(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): SqlCompletionContext {
const statementSpan = sqlCompletionStatementSpan(sql, cursor, options);
// Extract the full statement at cursor position for referenced tables
const fullStatement = extractStatementAt(sql, cursor);
const fullStatement = sql.slice(statementSpan.start, statementSpan.end).trim();
// Content before cursor within the current statement
const stmtStart = extractStatementStart(sql, cursor);
const beforeCursor = sql.slice(stmtStart, cursor);
const beforeCursor = sql.slice(statementSpan.start, cursor);
const trailingIdentifier = parseTrailingIdentifierContext(beforeCursor);
const prefix = trailingIdentifier?.prefix ?? "";