fix(sqlserver): preserve double-dot completion targets
This commit is contained in:
parent
e1052333c1
commit
00927ac435
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue