fix(sqlserver): preserve double-dot completion targets

This commit is contained in:
guoyongchang 2026-07-31 19:33:18 +08:00 committed by GitHub
parent e1052333c1
commit 00927ac435
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 12 deletions

View File

@ -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<string, SqlCompletionColumn[]>([
["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<string, SqlCompletionColumn[]>([
["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"],

View File

@ -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);

View File

@ -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",

View File

@ -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 {

View File

@ -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);