fix(sqlserver): honor USE context in completions

This commit is contained in:
guoyongchang authored and GitHub committed 2026-07-31 21:22:31 +08:00
1 parent f64d363abe
commit 486a73ef09
16 files changed
+974 -114

No files matched your search

@@ -34,4 +34,23 @@ describe("QueryEditor database name completion wiring", () => {
expect(guard).toContain("supportsDatabaseNameCompletion(props.databaseType)");
expect(guard).toContain("supportsDatabaseSchemaQualifierCompletion()");
});
it("uses the resolved database scope for routine completion and isolates the editor cache", () => {
expect(extractFunction("routineCompletionTargetForContext")).toContain("currentDatabase: scope.database");
expect(extractFunction("routineCompletionTargetForContext")).toContain("supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion()");
expect(extractFunction("lookupLocalCompletionObjectsForContext")).toContain("target.database");
expect(extractFunction("listCompletionObjectsForContext")).toContain("target.database");
expect(extractFunction("routineCompletionScopeForContext")).toContain("database: target.database");
expect(extractFunction("completionObjectScopeKey")).toContain("scope.database");
expect(extractFunction("completionObjectScopeKey")).toContain("scope.schema");
expect(queryEditorSource).toContain("cachedCompletionObjectsByScope");
});
it("loads SQL Server capability metadata before offering or applying USE completion", () => {
const provider = extractFunction("provideSqlCompletions");
expect(provider).toContain("getSqlServerCompletionContext");
expect(provider).toContain("supports_session_database_switch");
expect(provider).toContain("useDatabaseDefaultSchema");
});
});
@@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
import { mergeSqlCompletionQualifierNames, resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionSchemaLookupDatabase, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
import {
buildSqlServerUseDatabaseCompletionItems,
mergeSqlCompletionQualifierNames,
resolveSqlCompletionRoutineLookupTarget,
resolveSqlCompletionSchemaLookupDatabase,
resolveSqlCompletionScope,
resolveSqlCompletionTableLookupTarget,
resolveSqlServerUseDatabaseCompletion,
sqlServerUseCompletionDatabaseNames,
} from "@/lib/sql/sqlCompletionLookupTarget";
describe("sqlCompletionLookupTarget", () => {
it("treats qualified table completion as a database lookup for MySQL-compatible engines", () => {
@@ -215,22 +224,252 @@ describe("sqlCompletionLookupTarget", () => {
});
});
it("uses the preceding SQL Server USE database and its server-reported default schema", () => {
const sql = "USE [bardb]\n\nSELECT * FROM T";
const completionContext = getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" });
const scope = resolveSqlCompletionScope({
sql,
cursor: sql.length,
databaseType: "sqlserver",
currentDatabase: "FooDB",
currentSchema: "sales",
knownDatabases: ["FooDB", "BarDB"],
supportsSessionDatabaseSwitch: true,
useDatabaseDefaultSchema: "app_user",
completionContext,
});
expect(scope.database).toBe("BarDB");
expect(scope.schema).toBe("app_user");
expect(
resolveSqlCompletionTableLookupTarget({
currentDatabase: scope.database,
currentSchema: scope.schema,
supportsDatabaseQualifier: false,
supportsDatabaseSchemaQualifier: true,
completionContext: scope.completionContext,
}),
).toEqual({
database: "BarDB",
schema: "app_user",
filter: "T",
});
});
it("uses the last preceding SQL Server USE and unescapes its identifier", () => {
const sql = "USE FooDB;\nUSE [Bar]]DB];\nSELECT * FROM T";
const completionContext = getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" });
const scope = resolveSqlCompletionScope({
sql,
cursor: sql.length,
databaseType: "sqlserver",
currentDatabase: "SelectedDB",
knownDatabases: ["SelectedDB", "FooDB", "Bar]DB"],
supportsSessionDatabaseSwitch: true,
useDatabaseDefaultSchema: "reporting_user",
completionContext,
});
expect(scope.database).toBe("Bar]DB");
});
it("ignores commented, quoted, current, later, and non-SQL Server USE text", () => {
const sql = "-- USE [CommentDB]\nSELECT 'USE [StringDB]';\nSELECT * FROM T;\nUSE [LaterDB];";
const cursor = sql.indexOf("T;") + 1;
const completionContext = getSqlCompletionContext(sql, cursor, { databaseType: "sqlserver", dialect: "sqlserver" });
expect(
resolveSqlCompletionScope({
sql,
cursor,
databaseType: "sqlserver",
currentDatabase: "FooDB",
currentSchema: "dbo",
completionContext,
}).database,
).toBe("FooDB");
const currentUseSql = "USE [BarDB]";
expect(
resolveSqlCompletionScope({
sql: currentUseSql,
cursor: currentUseSql.length,
databaseType: "sqlserver",
currentDatabase: "FooDB",
currentSchema: "dbo",
completionContext,
}).database,
).toBe("FooDB");
expect(
resolveSqlCompletionScope({
sql: "USE [BarDB];\nSELECT * FROM T",
cursor: "USE [BarDB];\nSELECT * FROM T".length,
databaseType: "postgres",
currentDatabase: "FooDB",
currentSchema: "public",
completionContext,
}),
).toMatchObject({ database: "FooDB", schema: "public", completionContext });
});
it("scopes unqualified SQL Server references without overriding explicit databases or schemas", () => {
const sql = "USE [BarDB];\nSELECT * FROM T";
const completionContext = {
...getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" }),
insertTable: "NewRow",
referencedTables: [{ name: "TUser" }, { name: "TOrder", schema: "sales" }, { name: "TArchive", database: "ArchiveDB", schema: "history" }],
};
const scope = resolveSqlCompletionScope({
sql,
cursor: sql.length,
databaseType: "sqlserver",
currentDatabase: "FooDB",
knownDatabases: ["FooDB", "BarDB", "ArchiveDB"],
supportsSessionDatabaseSwitch: true,
useDatabaseDefaultSchema: "app_user",
completionContext,
});
expect(scope.completionContext).toMatchObject({
insertDatabase: "BarDB",
insertSchema: "app_user",
referencedTables: [
{ name: "TUser", database: "BarDB", schema: "app_user" },
{ name: "TOrder", database: "BarDB", schema: "sales" },
{ name: "TArchive", database: "ArchiveDB", schema: "history" },
],
});
});
it("falls back to the selected SQL Server database when USE names an unknown database", () => {
const sql = "USE [MissingDB];\nSELECT * FROM T";
const completionContext = getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" });
const scope = resolveSqlCompletionScope({
sql,
cursor: sql.length,
databaseType: "sqlserver",
currentDatabase: "FooDB",
currentSchema: "sales",
knownDatabases: ["FooDB", "BarDB"],
supportsSessionDatabaseSwitch: true,
useDatabaseDefaultSchema: "dbo",
completionContext,
});
expect(scope).toEqual({
database: "FooDB",
schema: "sales",
completionContext,
});
});
it("falls back to the selected database when the endpoint cannot switch sessions", () => {
const sql = "USE [BarDB];\nSELECT * FROM T";
const completionContext = getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" });
expect(
resolveSqlCompletionScope({
sql,
cursor: sql.length,
databaseType: "sqlserver",
currentDatabase: "AzureDB",
currentSchema: "sales",
knownDatabases: ["AzureDB", "BarDB"],
supportsSessionDatabaseSwitch: false,
useDatabaseDefaultSchema: "dbo",
completionContext,
}),
).toEqual({
database: "AzureDB",
schema: "sales",
completionContext,
});
});
it.each([
["USE ", { from: 4, prefix: "", quoteStyle: "none" }],
["USE Bar", { from: 4, prefix: "Bar", quoteStyle: "none" }],
["USE [Bar", { from: 5, prefix: "Bar", quoteStyle: "bracket" }],
['USE "Bar', { from: 5, prefix: "Bar", quoteStyle: "double" }],
["SELECT 1;\nUSE [Bar", { from: 15, prefix: "Bar", quoteStyle: "bracket" }],
])("resolves SQL Server database completion for %s", (sql, expected) => {
expect(resolveSqlServerUseDatabaseCompletion({ sql, cursor: sql.length, databaseType: "sqlserver" })).toEqual(expected);
});
it("does not offer USE database completion outside an incomplete SQL Server USE target", () => {
expect(resolveSqlServerUseDatabaseCompletion({ sql: "USE", cursor: 3, databaseType: "sqlserver" })).toBeUndefined();
expect(resolveSqlServerUseDatabaseCompletion({ sql: "USE [BarDB]", cursor: 11, databaseType: "sqlserver" })).toBeUndefined();
expect(resolveSqlServerUseDatabaseCompletion({ sql: "SELECT 'USE Bar'", cursor: 15, databaseType: "sqlserver" })).toBeUndefined();
expect(resolveSqlServerUseDatabaseCompletion({ sql: "USE Bar", cursor: 7, databaseType: "postgres" })).toBeUndefined();
});
it("builds quoted SQL Server USE database completion insertions", () => {
const unquoted = resolveSqlServerUseDatabaseCompletion({ sql: "USE Odd", cursor: 7, databaseType: "sqlserver" })!;
const bracketed = resolveSqlServerUseDatabaseCompletion({ sql: "USE [Odd", cursor: 8, databaseType: "sqlserver" })!;
const doubleQuoted = resolveSqlServerUseDatabaseCompletion({ sql: 'USE "Odd', cursor: 8, databaseType: "sqlserver" })!;
expect(buildSqlServerUseDatabaseCompletionItems(["Odd]DB"], unquoted)[0]).toMatchObject({ label: "Odd]DB", detail: "database", apply: "[Odd]]DB]" });
expect(buildSqlServerUseDatabaseCompletionItems(["Odd]DB"], bracketed)[0]).toMatchObject({ label: "Odd]DB", filterText: "Odd]]DB", apply: "Odd]]DB]" });
expect(buildSqlServerUseDatabaseCompletionItems(['Odd"DB'], doubleQuoted)[0]).toMatchObject({ label: 'Odd"DB', filterText: 'Odd""DB', apply: 'Odd""DB"' });
});
it("limits USE database candidates to the current database when session switching is unsupported", () => {
expect(
sqlServerUseCompletionDatabaseNames({
databaseNames: ["master", "AzureDB", "OtherDB"],
currentDatabase: "azuredb",
supportsSessionDatabaseSwitch: false,
}),
).toEqual(["AzureDB"]);
expect(
sqlServerUseCompletionDatabaseNames({
databaseNames: ["FooDB", "BarDB"],
currentDatabase: "FooDB",
supportsSessionDatabaseSwitch: true,
}),
).toEqual(["FooDB", "BarDB"]);
expect(
sqlServerUseCompletionDatabaseNames({
databaseNames: [],
currentDatabase: "",
supportsSessionDatabaseSwitch: false,
}),
).toEqual([]);
});
it.each([
["SELECT dbo.fn_", "dbo", "fn_"],
["SELECT public.st_", "public", "st_"],
])("separates a routine schema from its name mask for %s", (sql, schema, mask) => {
const completionContext = getSqlCompletionContext(sql, sql.length);
expect(resolveSqlCompletionRoutineLookupTarget({ currentSchema: "fallback", completionContext })).toEqual({ schema, mask });
expect(resolveSqlCompletionRoutineLookupTarget({ currentDatabase: "app", currentSchema: "fallback", completionContext })).toEqual({ database: "app", schema, mask });
});
it("uses the current schema for an unqualified routine mask", () => {
const sql = "SELECT st_";
const completionContext = getSqlCompletionContext(sql, sql.length);
expect(resolveSqlCompletionRoutineLookupTarget({ currentSchema: "public", completionContext })).toEqual({
expect(resolveSqlCompletionRoutineLookupTarget({ currentDatabase: "app", currentSchema: "public", completionContext })).toEqual({
database: "app",
schema: "public",
mask: "st_",
});
});
it.each(["SELECT BarDB.sales.fn_", "EXEC BarDB.sales.proc_"])("uses an explicit SQL Server database and schema for %s", (sql) => {
const completionContext = getSqlCompletionContext(sql, sql.length);
expect(
resolveSqlCompletionRoutineLookupTarget({
currentDatabase: "FooDB",
currentSchema: "app_user",
supportsDatabaseSchemaQualifier: true,
completionContext,
}),
).toEqual({
database: "BarDB",
schema: "sales",
mask: sql.endsWith("fn_") ? "fn_" : "proc_",
});
});
});
+1
View File
@@ -138,6 +138,7 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
// Schema
export const listDatabases = forward("listDatabases");
export const listDatabaseStorage = forward("listDatabaseStorage");
export const getSqlServerCompletionContext = forward("getSqlServerCompletionContext");
export const listDorisCatalogs = forward("listDorisCatalogs");
export const listDorisCatalogDatabases = forward("listDorisCatalogDatabases");
export const listSqlServerLinkedServers = forward("listSqlServerLinkedServers");
+5
View File
@@ -4,6 +4,7 @@ import type {
DatabaseConnectionInfo,
DatabaseInfo,
DatabaseStorageInfo,
SqlServerCompletionContext,
SchemaInfo,
LinkedServerInfo,
CatalogInfo,
@@ -634,6 +635,10 @@ export async function listDatabaseStorage(connectionId: string, databases: strin
});
}
export async function getSqlServerCompletionContext(connectionId: string, database: string): Promise<SqlServerCompletionContext> {
return get(`/api/schema/sqlserver/completion-context?${qs({ connection_id: connectionId, database })}`);
}
export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
return get(`/api/schema/doris/catalogs?${qs({ connection_id: connectionId })}`);
}
+5
View File
@@ -8,6 +8,7 @@ import type {
DatabaseConnectionInfo,
DatabaseInfo,
DatabaseStorageInfo,
SqlServerCompletionContext,
SchemaInfo,
LinkedServerInfo,
CatalogInfo,
@@ -869,6 +870,10 @@ export async function listDatabaseStorage(connectionId: string, databases: strin
return invoke("list_database_storage", { connectionId, databases });
}
export async function getSqlServerCompletionContext(connectionId: string, database: string): Promise<SqlServerCompletionContext> {
return invoke("get_sqlserver_completion_context", { connectionId, database });
}
export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
return invoke("list_doris_catalogs", { connectionId });
}
@@ -1,4 +1,6 @@
import type { SqlCompletionContext } from "@/lib/sql/sqlCompletion";
import type { SqlCompletionContext, SqlCompletionItem } from "@/lib/sql/sqlCompletion";
import { currentExecutableStatementRange, executableStatementRanges } from "@/lib/sql/sqlStatementRanges";
import type { DatabaseType } from "@/types/database";
export interface SqlCompletionTableLookupTarget {
database: string;
@@ -8,10 +10,186 @@ export interface SqlCompletionTableLookupTarget {
}
export interface SqlCompletionRoutineLookupTarget {
database: string;
schema?: string;
mask: string;
}
export interface SqlCompletionScope {
database: string;
schema?: string;
completionContext: SqlCompletionContext;
}
export interface SqlServerUseDatabaseCompletion {
from: number;
prefix: string;
quoteStyle: "none" | "bracket" | "double";
}
function sqlStatementWithoutLeadingComments(statement: string): string {
let remaining = statement.trimStart();
while (remaining) {
if (remaining.startsWith("--")) {
const newline = remaining.indexOf("\n");
remaining = newline < 0 ? "" : remaining.slice(newline + 1).trimStart();
continue;
}
if (remaining.startsWith("/*")) {
const end = remaining.indexOf("*/", 2);
if (end < 0) return "";
remaining = remaining.slice(end + 2).trimStart();
continue;
}
break;
}
return remaining;
}
function sqlServerUseDatabase(statement: string): string | undefined {
const match = /^USE\s+(?:\[((?:[^\]]|\]\])*)\]|"((?:[^"]|"")*)"|([\p{L}_@#][\p{L}\p{N}_@$#]*))\s*;?\s*$/iu.exec(sqlStatementWithoutLeadingComments(statement));
if (!match) return undefined;
if (match[1] !== undefined) return match[1].replaceAll("]]", "]");
if (match[2] !== undefined) return match[2].replaceAll('""', '"');
return match[3];
}
export function sqlServerUseDatabaseBeforeCursor(sql: string, cursor: number): string | undefined {
const position = Math.max(0, Math.min(cursor, sql.length));
let database: string | undefined;
for (const statement of executableStatementRanges(sql, "sqlserver")) {
if (statement.from >= position || statement.to >= position) break;
database = sqlServerUseDatabase(statement.sql) ?? database;
}
return database;
}
function unclosedQuotedIdentifierPrefix(value: string, quoteStyle: "bracket" | "double"): string | undefined {
const closingQuote = quoteStyle === "bracket" ? "]" : '"';
let prefix = "";
for (let index = 1; index < value.length; index += 1) {
const character = value[index]!;
if (character !== closingQuote) {
prefix += character;
continue;
}
if (value[index + 1] !== closingQuote) return undefined;
prefix += closingQuote;
index += 1;
}
return prefix;
}
export function resolveSqlServerUseDatabaseCompletion(options: { sql: string; cursor: number; databaseType?: DatabaseType }): SqlServerUseDatabaseCompletion | undefined {
if (options.databaseType !== "sqlserver") return undefined;
const position = Math.max(0, Math.min(options.cursor, options.sql.length));
const statement = currentExecutableStatementRange(options.sql, position, "sqlserver");
if (!statement || (statement.to > position && options.sql.slice(position, statement.to).trim())) return undefined;
const beforeCursor = options.sql.slice(statement.from, position);
const useMatch = /^USE(?=\s)/iu.exec(beforeCursor);
if (!useMatch) return undefined;
let targetOffset = useMatch[0].length;
while (targetOffset < beforeCursor.length && /\s/u.test(beforeCursor[targetOffset]!)) targetOffset += 1;
const target = beforeCursor.slice(targetOffset);
if (!target) {
return {
from: statement.from + targetOffset,
prefix: "",
quoteStyle: "none",
};
}
if (/^[\p{L}_@#][\p{L}\p{N}_@$#]*$/u.test(target)) {
return {
from: statement.from + targetOffset,
prefix: target,
quoteStyle: "none",
};
}
const quoteStyle = target[0] === "[" ? "bracket" : target[0] === '"' ? "double" : undefined;
if (!quoteStyle) return undefined;
const prefix = unclosedQuotedIdentifierPrefix(target, quoteStyle);
if (prefix === undefined) return undefined;
return {
from: statement.from + targetOffset + 1,
prefix,
quoteStyle,
};
}
export function buildSqlServerUseDatabaseCompletionItems(databaseNames: readonly string[], completion: SqlServerUseDatabaseCompletion): SqlCompletionItem[] {
return databaseNames.map((database) => {
const escapedDatabase = completion.quoteStyle === "double" ? database.replaceAll('"', '""') : database.replaceAll("]", "]]");
const apply = completion.quoteStyle === "bracket" ? `${escapedDatabase}]` : completion.quoteStyle === "double" ? `${escapedDatabase}"` : `[${escapedDatabase}]`;
return {
label: database,
filterText: completion.quoteStyle === "none" ? database : escapedDatabase,
type: "schema",
detail: "database",
apply,
boost: 1_500,
};
});
}
export function sqlServerUseCompletionDatabaseNames(options: { databaseNames: readonly string[]; currentDatabase: string; supportsSessionDatabaseSwitch: boolean }): string[] {
if (options.supportsSessionDatabaseSwitch) return [...options.databaseNames];
const currentDatabase = options.currentDatabase.trim();
return currentDatabase ? [findExactName(options.databaseNames, currentDatabase) ?? currentDatabase] : [];
}
export function resolveSqlCompletionScope(options: {
sql: string;
cursor: number;
databaseType?: DatabaseType;
currentDatabase: string;
currentSchema?: string;
knownDatabases?: readonly string[];
supportsSessionDatabaseSwitch?: boolean;
useDatabaseDefaultSchema?: string;
completionContext: SqlCompletionContext;
}): SqlCompletionScope {
if (options.databaseType !== "sqlserver") {
return {
database: options.currentDatabase,
schema: options.currentSchema,
completionContext: options.completionContext,
};
}
const parsedDatabase = sqlServerUseDatabaseBeforeCursor(options.sql, options.cursor);
const database = parsedDatabase ? findExactName(options.knownDatabases, parsedDatabase) : undefined;
const targetsCurrentDatabase = database?.toLowerCase() === options.currentDatabase.toLowerCase();
const schema = options.useDatabaseDefaultSchema?.trim();
if (!database || !schema || (!targetsCurrentDatabase && options.supportsSessionDatabaseSwitch !== true)) {
return {
database: options.currentDatabase,
schema: options.currentSchema,
completionContext: options.completionContext,
};
}
return {
database,
schema,
completionContext: {
...options.completionContext,
insertDatabase: options.completionContext.insertTable && !options.completionContext.insertDatabase ? database : options.completionContext.insertDatabase,
insertSchema: options.completionContext.insertTable && !options.completionContext.insertSchema ? schema : options.completionContext.insertSchema,
referencedTables: options.completionContext.referencedTables.map((table) =>
table.database
? table
: {
...table,
database,
schema: table.schema ?? schema,
},
),
},
};
}
function findExactName(names: readonly string[] | undefined, value: string): string | undefined {
return names?.find((name) => name.toLowerCase() === value.toLowerCase());
}
@@ -82,13 +260,17 @@ export function resolveSqlCompletionTableLookupTarget(options: {
};
}
export function resolveSqlCompletionRoutineLookupTarget(options: { currentSchema?: string; completionContext: Pick<SqlCompletionContext, "qualifier" | "qualifierParts" | "prefix"> }): SqlCompletionRoutineLookupTarget {
const qualifierParts = options.completionContext.qualifierParts?.filter(Boolean);
const schema = qualifierParts?.[qualifierParts.length - 1] ?? options.completionContext.qualifier?.trim() ?? options.currentSchema;
export function resolveSqlCompletionRoutineLookupTarget(options: { currentDatabase: string; currentSchema?: string; supportsDatabaseSchemaQualifier?: boolean; completionContext: Pick<SqlCompletionContext, "qualifier" | "qualifierParts" | "prefix"> }): SqlCompletionRoutineLookupTarget {
const qualifier = options.completionContext.qualifier?.trim();
const qualifierParts = options.completionContext.qualifierParts?.filter(Boolean) ?? qualifier?.split(".").filter(Boolean) ?? [];
const hasDatabaseQualifier = options.supportsDatabaseSchemaQualifier && qualifierParts.length >= 2;
const database = hasDatabaseQualifier ? qualifierParts[qualifierParts.length - 2]! : options.currentDatabase;
const schema = qualifierParts[qualifierParts.length - 1] ?? qualifier ?? options.currentSchema;
// A qualified routine uses the qualifier as metadata scope; only the final
// identifier fragment is the function/procedure name mask.
return {
database,
schema: schema || undefined,
mask: options.completionContext.prefix,
};