From 00927ac435ceede2828ef2e5ad512fa02c1e2f98 Mon Sep 17 00:00:00 2001 From: guoyongchang Date: Fri, 31 Jul 2026 19:33:18 +0800 Subject: [PATCH] fix(sqlserver): preserve double-dot completion targets --- .../__tests__/sql/semantic/completion.spec.ts | 50 +++++++++++++++++++ .../sql/sqlCompletion.context.spec.ts | 7 +++ .../sql/sqlCompletionLookupTarget.spec.ts | 32 ++++++++++++ apps/desktop/src/lib/sql/semantic/model.ts | 7 ++- apps/desktop/src/lib/sql/sqlCompletion.ts | 31 ++++++++---- 5 files changed, 115 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index dc84dc267..b068b7539 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -134,6 +134,56 @@ describe("semantic SQL completion candidates", () => { expect(context.qualifierParts).toEqual(["DatabaseB", "OUT", "orders"]); expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["target_marker"]); }); + + it("completes SQL Server tables from the database dbo schema after a double dot", () => { + const { context, items } = semanticCompletion( + "SELECT * FROM BarDB..|", + { + tables: [{ name: "orders", database: "BarDB", schema: "dbo", type: "table" }], + }, + { databaseType: "sqlserver", dialect: "sqlserver" }, + ); + + expect(context).toMatchObject({ + prefix: "", + qualifier: "BarDB.dbo", + qualifierParts: ["BarDB", "dbo"], + }); + expect(items.filter((item) => item.type === "table")).toEqual([expect.objectContaining({ label: "orders", apply: "orders" })]); + }); + + it("completes SQL Server alias columns from the exact double-dot metadata target", () => { + const columnsByTable = new Map([ + ["FooDB.dbo.orders", [{ name: "wrong_database", table: "orders", schema: "dbo" }]], + ["BarDB.sales.orders", [{ name: "wrong_schema", table: "orders", schema: "sales" }]], + ["BarDB.dbo.orders", [{ name: "target_marker", table: "orders", schema: "dbo" }]], + ]); + const { model, context, items } = semanticCompletion("SELECT * FROM BarDB..orders AS o WHERE o.|", { columnsByTable }, { databaseType: "sqlserver", dialect: "sqlserver" }); + + expect(model.rowSources).toEqual([ + expect.objectContaining({ + name: "orders", + qualifierParts: ["BarDB", "dbo"], + alias: "o", + metadataTarget: { database: "BarDB", schema: "dbo", table: "orders" }, + }), + ]); + expect(context.referencedTables).toEqual([expect.objectContaining({ name: "orders", database: "BarDB", schema: "dbo", alias: "o" })]); + expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["target_marker"]); + }); + + it("completes unqualified SQL Server columns from the exact double-dot metadata target", () => { + const columnsByTable = new Map([ + ["FooDB.dbo.orders", [{ name: "wrong_database", table: "orders", schema: "dbo" }]], + ["BarDB.sales.orders", [{ name: "wrong_schema", table: "orders", schema: "sales" }]], + ["BarDB.dbo.orders", [{ name: "target_marker", table: "orders", schema: "dbo" }]], + ]); + const { context, items } = semanticCompletion("SELECT * FROM BarDB..orders WHERE tar|", { columnsByTable }, { databaseType: "sqlserver", dialect: "sqlserver" }); + + expect(context.referencedTables).toEqual([expect.objectContaining({ name: "orders", database: "BarDB", schema: "dbo" })]); + expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["target_marker"]); + }); + it.each([ ["MySQL ORDER BY", "SELECT * FROM t LIMIT 100 or|", "mysql", "mysql", "ORDER BY"], ["PostgreSQL ON CONFLICT", "INSERT INTO t VALUES (1) on|", "postgres", "postgres", "ON CONFLICT"], diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index e6d0e5390..06fcd0de0 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -440,6 +440,13 @@ describe("sqlCompletion scoped context classification", () => { expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ schema: "dbo", name: "Users", alias: "u" }), expect.objectContaining({ name: "Orders", alias: "o" })])); }); + it("preserves SQL Server database and omitted schema in legacy table references", () => { + const sql = "SELECT * FROM BarDB..orders AS o WHERE o."; + const context = getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver" }); + + expect(context.referencedTables).toEqual([expect.objectContaining({ database: "BarDB", schema: "dbo", name: "orders", alias: "o" })]); + }); + it("treats schema-qualified table prefixes in FROM as table completion input", () => { const sql = "SELECT * FROM dws_game_sdk_base.di"; const context = getSqlCompletionContext(sql, sql.length); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts index 77fbf7d29..453693437 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts @@ -82,6 +82,38 @@ describe("sqlCompletionLookupTarget", () => { }); }); + it("routes a SQL Server double-dot qualifier to the database's dbo schema", () => { + const sql = "select * from BarDB..ord"; + const completionContext = getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver" }); + + expect(completionContext).toMatchObject({ + prefix: "ord", + qualifier: "BarDB.dbo", + qualifierParts: ["BarDB", "dbo"], + }); + expect( + resolveSqlCompletionTableLookupTarget({ + currentDatabase: "FooDB", + currentSchema: "sales", + supportsDatabaseQualifier: false, + supportsDatabaseSchemaQualifier: true, + knownDatabases: ["FooDB", "BarDB"], + completionContext, + }), + ).toEqual({ + database: "BarDB", + schema: "dbo", + filter: "ord", + qualifierDatabase: "BarDB", + }); + }); + + it.each(["postgres", "trino", "prestosql"] as const)("does not apply SQL Server double-dot semantics to %s", (databaseType) => { + const sql = "select * from BarDB..ord"; + + expect(getSqlCompletionContext(sql, sql.length, { databaseType }).qualifierParts).toBeUndefined(); + }); + it("keeps PostgreSQL schema completion in the current database", () => { const target = resolveSqlCompletionTableLookupTarget({ currentDatabase: "app", diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index 6f7eca779..a1ed6be99 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -25,6 +25,7 @@ const ALIAS_BLACKLIST = new Set([...FROM_CLAUSE_BOUNDARIES, "on", "join", "strai const TABLE_TARGET_MODIFIERS = new Set(["lateral", "only"]); const TABLE_FUNCTION_INTRODUCERS = new Set(["from", "join", "straight_join", "apply"]); const TOP_LEVEL_STATEMENT_WORDS = new Set(["select", "insert", "delete", "merge", "create", "alter", "drop", "truncate", "call", "exec", "execute", "grant", "revoke"]); +const SQLSERVER_DEFAULT_SCHEMA = "dbo"; const SQLSERVER_UPDATE_STATISTICS_SCOPES = new Set(["all", "index", "table"]); interface ParseState { @@ -91,10 +92,12 @@ function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: numb break; } index += 2; - if (!tokenIsIdentifier(tokens[index]) && !(dialect.id === "sqlserver" && tokens[index]?.text === ".")) return null; - if (dialect.id === "sqlserver") { + if (dialect.id === "sqlserver" && tokens[index]?.text === ".") { + const omittedSchema = tokens[index]; + parts.push({ raw: "", name: SQLSERVER_DEFAULT_SCHEMA, span: omittedSchema.span }); while (tokens[index]?.text === ".") index += 1; } + if (!tokenIsIdentifier(tokens[index])) return null; } if (parts.length === 0) return null; return { diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 6e387b48a..5013c32b1 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -13,6 +13,8 @@ import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; +const SQLSERVER_DEFAULT_SCHEMA = "dbo"; + const SQL_KEYWORDS = [ "SELECT", "FROM", @@ -1786,7 +1788,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq // Content before cursor within the current statement const beforeCursor = sql.slice(statementSpan.start, cursor); - const trailingIdentifier = parseTrailingIdentifierContext(beforeCursor); + const trailingIdentifier = parseTrailingIdentifierContext(beforeCursor, options.databaseType); const prefix = trailingIdentifier?.prefix ?? ""; const qualifier = trailingIdentifier?.qualifier; const qualifierParts = trailingIdentifier?.qualifierParts; @@ -1794,7 +1796,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd(); const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? ""; - let referencedTables = extractReferencedTables(fullStatement); + let referencedTables = extractReferencedTables(fullStatement, options.databaseType); // Merge CTE definitions into referenced tables const cteDefs = extractCteDefinitions(fullStatement); @@ -1956,7 +1958,7 @@ function detectCompletionContextKind(options: { return "keyword"; } -function parseTrailingIdentifierContext(input: string): { start: number; prefix: string; qualifier?: string; qualifierParts?: string[] } | null { +function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseType): { start: number; prefix: string; qualifier?: string; qualifierParts?: string[] } | null { if (/\s$/.test(input)) return null; let i = input.length - 1; while (i >= 0 && /\s/.test(input[i] ?? "")) i--; @@ -1972,7 +1974,13 @@ function parseTrailingIdentifierContext(input: string): { start: number; prefix: while (index > 0) { const parsed = parseTrailingIdentifierPart(tail, index); - if (!parsed) break; + if (!parsed) { + const omittedSqlServerSchema = databaseType === "sqlserver" && tail[index - 1] === "." && parseTrailingIdentifierPart(tail, index - 1); + if (!omittedSqlServerSchema) break; + parts.unshift(SQLSERVER_DEFAULT_SCHEMA); + index -= 1; + continue; + } parts.unshift(unquoteIdentifier(parsed.raw)); index = parsed.start; if (index <= 0 || tail[index - 1] !== ".") break; @@ -2394,7 +2402,7 @@ function lastTopLevelKeywordIndex(sql: string, keyword: string): number { return lastIndex; } -function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { +function extractReferencedTables(sql: string, databaseType?: DatabaseType): SqlCompletionReferencedTable[] { // Keywords that should NOT be treated as table aliases const ALIAS_BLACKLIST = new Set([ "where", @@ -2502,7 +2510,8 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { // STRAIGHT_JOIN is a standalone MySQL table introducer, not a modifier followed by JOIN. const identifier = '(?:"[^"]+"|`[^`]+`|\\[[^\\]]+\\]|[A-Za-z_][\\w$@#]*)'; - const pattern = new RegExp(`\\b(?:from|join|straight_join|update|apply)\\s+(${identifier}(?:\\.${identifier}){0,3})(?:\\s+(?:as\\s+)?([A-Za-z_][\\w$]*))?`, "gi"); + const qualifiedSeparator = databaseType === "sqlserver" ? `\\.(?:${identifier}|\\.${identifier})` : `\\.${identifier}`; + const pattern = new RegExp(`\\b(?:from|join|straight_join|update|apply)\\s+(${identifier}(?:${qualifiedSeparator}){0,3})(?:\\s+(?:as\\s+)?([A-Za-z_][\\w$]*))?`, "gi"); const referenced: SqlCompletionReferencedTable[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(sql)) !== null) { @@ -2520,15 +2529,17 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { continue; } const rawParts = splitQualifiedNameRawParts(rawName); + const omittedSqlServerSchema = databaseType === "sqlserver" && rawParts.length >= 3 && rawParts[rawParts.length - 2] === ""; + const unquotedRawParts = rawParts.map((part) => unquoteIdentifier(part)); const parts = rawParts.map((part) => unquoteIdentifier(part)).filter(Boolean); - const name = parts[parts.length - 1]; + const name = unquotedRawParts[unquotedRawParts.length - 1]; if (!name) continue; const table: SqlCompletionReferencedTable = { name, nameQuoted: isQuotedIdentifier(rawParts[rawParts.length - 1]), - database: parts.length >= 3 ? parts[parts.length - 3] : undefined, - schema: parts.length >= 2 ? parts[parts.length - 2] : undefined, - schemaQuoted: parts.length >= 2 ? isQuotedIdentifier(rawParts[rawParts.length - 2]) : undefined, + database: omittedSqlServerSchema ? unquotedRawParts[unquotedRawParts.length - 3] || undefined : parts.length >= 3 ? parts[parts.length - 3] : undefined, + schema: omittedSqlServerSchema ? SQLSERVER_DEFAULT_SCHEMA : parts.length >= 2 ? parts[parts.length - 2] : undefined, + schemaQuoted: omittedSqlServerSchema ? undefined : parts.length >= 2 ? isQuotedIdentifier(rawParts[rawParts.length - 2]) : undefined, alias: cleanAlias, }; referenced.push(table);