From 79873443e411030760fc5ad488535112cd3753cc Mon Sep 17 00:00:00 2001 From: guoyongchang Date: Thu, 30 Jul 2026 15:49:01 +0800 Subject: [PATCH] fix(sqlserver): suggest database names in query completion --- .../src/components/editor/QueryEditor.vue | 125 ++++++++++++------ .../database/databaseFeatureSupport.spec.ts | 10 +- .../queryEditorDatabaseNameCompletion.spec.ts | 37 ++++++ .../sql/sqlCompletionLookupTarget.spec.ts | 68 +++++++++- .../lib/database/databaseFeatureSupport.ts | 4 + .../src/lib/sql/sqlCompletionLookupTarget.ts | 23 ++++ .../connectionStore.completion.spec.ts | 26 ++++ apps/desktop/src/stores/connectionStore.ts | 1 + packages/app-tests/sqlCompletion.test.ts | 35 +++++ 9 files changed, 284 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/queryEditorDatabaseNameCompletion.spec.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 3f420e265..7bac9d41c 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -44,7 +44,7 @@ import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references"; import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion"; import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; -import { resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; +import { mergeSqlCompletionQualifierNames, resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionSchemaLookupDatabase, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession"; import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; import { buildHoverTableSql, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; @@ -77,7 +77,7 @@ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorS import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; -import { isSchemaAware, isSingleDatabase, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; +import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy"; import { loadTableMetadata, type TableMetadataLoadResult } from "@/lib/metadata/tableMetadataCache"; @@ -2496,21 +2496,25 @@ function buildCompletionResult(items: QueryCompletionItem[], from: number, valid }; } -function mergeCompletionQualifierNames(primary: string[], secondary: string[]): string[] { - const seen = new Set(); - const merged: string[] = []; - for (const name of [...primary, ...secondary]) { - const key = name.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - merged.push(name); - } - return merged; +function localCompletionDatabaseNames(completionContext: ReturnType): string[] { + if (!supportsDatabaseNameCompletion(props.databaseType) || !completionContext.suggestTables || completionContext.insertTable || !props.connectionId) return []; + return connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES); } -function localCompletionDatabaseNames(completionContext: ReturnType): string[] { - if (!supportsDatabaseQualifierCompletion() || !completionContext.suggestTables || completionContext.insertTable || !props.connectionId) return []; - return connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES); +function mayCompleteDatabaseSchemaQualifier(completionContext: ReturnType): boolean { + if (!supportsDatabaseNameCompletion(props.databaseType) || !supportsDatabaseSchemaQualifierCompletion() || !completionContext.suggestTables || completionContext.insertTable) return false; + return (completionContext.qualifierParts?.filter(Boolean).length ?? completionContext.qualifier?.split(".").filter(Boolean).length ?? 0) === 1; +} + +function localCompletionSchemasForDatabaseDisambiguation(completionContext: ReturnType, databaseNames: string[]): string[] { + if (!props.connectionId || props.database == null || !mayCompleteDatabaseSchemaQualifier(completionContext)) return []; + const database = resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: true, + completionContext, + knownDatabases: databaseNames, + }); + if (!database) return []; + return mergeSqlCompletionQualifierNames(props.schema ? [props.schema] : [], connectionStore.lookupLocalCompletionSchemas(props.connectionId, props.database, completionContext.qualifier, MAX_COMPLETION_TABLES)); } function shouldInsertSqlCompletionSpace(): boolean { @@ -2836,7 +2840,14 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s function buildLocalSqlCompletionResult(completionContext: ReturnType, fullDoc: string, position: number) { if (!props.connectionId || props.database == null) return null; const databaseNames = localCompletionDatabaseNames(completionContext); - const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)); + const currentDatabaseSchemaNames = localCompletionSchemasForDatabaseDisambiguation(completionContext, databaseNames); + const schemaLookupDatabase = resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(), + completionContext, + knownDatabases: databaseNames, + knownSchemas: currentDatabaseSchemaNames, + }); + const shouldLoadTables = !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext))); const tableLookupTarget = resolveSqlCompletionTableLookupTarget({ currentDatabase: props.database, currentSchema: props.schema, @@ -2846,13 +2857,19 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType(); if (completionContext.insertTable) { @@ -2940,15 +2957,23 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType {}); } - if (!localOnlyMetadata && completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) { - void connectionStore.refreshCompletionSchemas(connectionId, database).catch(() => {}); - if (supportsDatabaseQualifierCompletion()) { - void connectionStore.refreshCompletionDatabases(connectionId).catch(() => {}); + if (!localOnlyMetadata && completionContext.suggestTables && !completionContext.insertTable) { + if (schemaLookupDatabase) { + void connectionStore.refreshCompletionSchemas(connectionId, schemaLookupDatabase).catch(() => {}); + } else if (!completionContext.qualifier) { + void connectionStore.refreshCompletionSchemas(connectionId, database).catch(() => {}); + if (supportsDatabaseNameCompletion(props.databaseType)) { + void connectionStore.refreshCompletionDatabases(connectionId).catch(() => {}); + } } } if (!onDemandOnlyColumns && completionContext.insertTable) { @@ -3129,16 +3158,22 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext } } - const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)); let databaseNames = localCompletionDatabaseNames(completionContext); - if (!localOnlyMetadata && supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable && !completionContext.qualifier) { - try { - databaseNames = await connectionStore.listCompletionDatabases(props.connectionId!); - if (epoch !== completionEpoch) return null; - } catch { - databaseNames = []; - } + let currentDatabaseSchemaNames = localCompletionSchemasForDatabaseDisambiguation(completionContext, databaseNames); + const mayCompleteDatabaseSchema = mayCompleteDatabaseSchemaQualifier(completionContext); + if (!localOnlyMetadata && supportsDatabaseNameCompletion(props.databaseType) && completionContext.suggestTables && !completionContext.insertTable && (!completionContext.qualifier || mayCompleteDatabaseSchema)) { + const [databasesResult, schemasResult] = await Promise.allSettled([connectionStore.listCompletionDatabases(props.connectionId!), mayCompleteDatabaseSchema ? connectionStore.listCompletionSchemas(props.connectionId!, props.database!) : Promise.resolve(currentDatabaseSchemaNames)]); + databaseNames = databasesResult.status === "fulfilled" ? databasesResult.value : []; + if (schemasResult.status === "fulfilled") currentDatabaseSchemaNames = mergeSqlCompletionQualifierNames(props.schema ? [props.schema] : [], schemasResult.value); + if (epoch !== completionEpoch) return null; } + const schemaLookupDatabase = resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(), + completionContext, + knownDatabases: databaseNames, + knownSchemas: currentDatabaseSchemaNames, + }); + const shouldLoadTables = !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext))); const tableLookupTarget = resolveSqlCompletionTableLookupTarget({ currentDatabase: props.database!, currentSchema: props.schema, @@ -3148,11 +3183,13 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext knownDatabases: databaseNames, }); const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier; - let tables = shouldLoadTables - ? localOnlyMetadata - ? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, globalOracleTableSearch ? undefined : tableLookupTarget.schema, props.catalog) - : await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch) - : cachedTables; + let tables = schemaLookupDatabase + ? [] + : shouldLoadTables + ? localOnlyMetadata + ? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, globalOracleTableSearch ? undefined : tableLookupTarget.schema, props.catalog) + : await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch) + : cachedTables; if (localOnlyMetadata && tables.length === 0 && supportsDatabaseSchemaQualifierCompletion() && (completionContext.qualifierParts?.length ?? 0) >= 2 && allowsOnDemandQualifiedTableCompletion(completionContext.prefix)) { tables = await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT, tableLookupTarget.schema); } @@ -3173,23 +3210,25 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext // Fetch schemas for schema completion let schemaNames: string[] = []; - if (completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) { + if (completionContext.suggestTables && !completionContext.insertTable && (schemaLookupDatabase || !completionContext.qualifier)) { + const database = schemaLookupDatabase ?? props.database!; if (localOnlyMetadata) { - schemaNames = mergeCompletionQualifierNames(connectionStore.lookupLocalCompletionSchemas(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES), databaseNames); + const schemas = connectionStore.lookupLocalCompletionSchemas(props.connectionId!, database, completionContext.prefix, MAX_COMPLETION_TABLES); + schemaNames = schemaLookupDatabase ? schemas : mergeSqlCompletionQualifierNames(schemas, databaseNames); } else { try { - const schemas = await connectionStore.listCompletionSchemas(props.connectionId!, props.database!); - schemaNames = mergeCompletionQualifierNames(schemas, databaseNames); + const schemas = await connectionStore.listCompletionSchemas(props.connectionId!, database); + schemaNames = schemaLookupDatabase ? schemas : mergeSqlCompletionQualifierNames(schemas, databaseNames); if (epoch !== completionEpoch) return null; } catch { - schemaNames = databaseNames; + schemaNames = schemaLookupDatabase ? [] : databaseNames; } } } // If qualifier didn't match any table names, try it as a schema name let qualifierIsSchema = false; - if (completionContext.qualifier && !tableLookupTarget.qualifierDatabase && !isReferencedTableQualifier(completionContext) && tables.length === 0 && (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)) { + if (completionContext.qualifier && !schemaLookupDatabase && !tableLookupTarget.qualifierDatabase && !isReferencedTableQualifier(completionContext) && tables.length === 0 && (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)) { let schemaTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier, props.catalog); if (!localOnlyMetadata) { schemaTables = await listCompletionTablesWithLatencyBudget(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier); diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts index 4af23bbee..15f99a2d1 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation"; import { editableDatabasePropertyGroups, editableSchemaPropertyGroups } from "@/lib/database/databasePropertyEditing"; import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql"; -import { isSchemaAware, supportsDatabaseSchemaQualifier, supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; +import { isSchemaAware, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; describe("schema awareness", () => { it("keeps SQLite database aliases separate from schema-capable databases", () => { @@ -18,6 +18,14 @@ describe("database and schema qualifiers", () => { it.each(["mysql", "postgres", "oracle", "snowflake"] as const)("does not widen unverified three-part completion for %s", (databaseType) => { expect(supportsDatabaseSchemaQualifier(databaseType)).toBe(false); }); + + it.each(["mysql", "sqlite", "sqlserver"] as const)("suggests database names for %s", (databaseType) => { + expect(supportsDatabaseNameCompletion(databaseType)).toBe(true); + }); + + it.each(["postgres", "oracle", "snowflake", "trino", "prestosql"] as const)("does not add database name completion for %s", (databaseType) => { + expect(supportsDatabaseNameCompletion(databaseType)).toBe(false); + }); }); describe("supportsTransaction", () => { diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorDatabaseNameCompletion.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorDatabaseNameCompletion.spec.ts new file mode 100644 index 000000000..e407e0819 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorDatabaseNameCompletion.spec.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +function extractFunction(name: string): string { + const start = queryEditorSource.indexOf(`function ${name}(`); + if (start < 0) throw new Error(`Missing QueryEditor function: ${name}`); + const bodyStart = queryEditorSource.indexOf("{", start); + let depth = 0; + for (let index = bodyStart; index < queryEditorSource.length; index++) { + const character = queryEditorSource[index]; + if (character === "{") depth++; + if (character === "}" && --depth === 0) return queryEditorSource.slice(start, index + 1); + } + throw new Error(`Unterminated QueryEditor function: ${name}`); +} + +describe("QueryEditor database name completion wiring", () => { + it("uses the shared capability for local lookup, background refresh, and async loading", () => { + expect(extractFunction("localCompletionDatabaseNames")).toContain("supportsDatabaseNameCompletion(props.databaseType)"); + expect(extractFunction("scheduleCompletionMetadataRefresh")).toMatch(/supportsDatabaseNameCompletion\(props\.databaseType\)[\s\S]*?refreshCompletionDatabases\(connectionId\)/); + expect(extractFunction("performAsyncCompletionWithResult")).toMatch(/supportsDatabaseNameCompletion\(props\.databaseType\)[\s\S]*?listCompletionDatabases\(props\.connectionId!\)/); + }); + + it("does not reuse cached tables while completing schemas inside a database", () => { + expect(extractFunction("buildLocalSqlCompletionResult")).toMatch(/const tables = schemaLookupDatabase\s*\?\s*\[\]/); + expect(extractFunction("performAsyncCompletionWithResult")).toMatch(/let tables = schemaLookupDatabase\s*\?\s*\[\]/); + }); + + it("limits database and schema disambiguation to engines with both capabilities", () => { + const guard = extractFunction("mayCompleteDatabaseSchemaQualifier"); + + expect(guard).toContain("supportsDatabaseNameCompletion(props.databaseType)"); + expect(guard).toContain("supportsDatabaseSchemaQualifierCompletion()"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts index e8b677a50..77fbf7d29 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion"; -import { resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; +import { mergeSqlCompletionQualifierNames, resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionSchemaLookupDatabase, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; describe("sqlCompletionLookupTarget", () => { it("treats qualified table completion as a database lookup for MySQL-compatible engines", () => { @@ -99,6 +99,72 @@ describe("sqlCompletionLookupTarget", () => { expect(target).toEqual({ database: "app", schema: "sales", filter: "ord" }); }); + it("routes a SQL Server database qualifier to schema completion", () => { + const completionContext = getSqlCompletionContext("select * from Reporting.d", "select * from Reporting.d".length); + + expect( + resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: true, + knownDatabases: ["Default_DB", "Reporting"], + completionContext, + }), + ).toBe("Reporting"); + }); + + it("does not case-fold database qualifiers while choosing a schema scope", () => { + const completionContext = getSqlCompletionContext("select * from reporting.d", "select * from reporting.d".length); + + expect( + resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: true, + knownDatabases: ["Reporting"], + completionContext, + }), + ).toBeUndefined(); + }); + + it("does not let a case-distinct schema hide an exact database qualifier", () => { + const completionContext = getSqlCompletionContext("select * from reporting.d", "select * from reporting.d".length); + + expect( + resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: true, + knownDatabases: ["reporting"], + knownSchemas: ["Reporting"], + completionContext, + }), + ).toBe("reporting"); + }); + + it("does not mistake a current-database schema for a database", () => { + const completionContext = getSqlCompletionContext("select * from dbo.", "select * from dbo.".length); + + expect( + resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: true, + knownDatabases: ["Default_DB", "Reporting"], + completionContext, + }), + ).toBeUndefined(); + }); + + it("prefers a current-database schema when a database has the same name", () => { + const completionContext = getSqlCompletionContext("select * from dbo.", "select * from dbo.".length); + + expect( + resolveSqlCompletionSchemaLookupDatabase({ + supportsDatabaseSchemaQualifier: true, + knownDatabases: ["dbo", "Reporting"], + knownSchemas: ["dbo", "sales"], + completionContext, + }), + ).toBeUndefined(); + }); + + it("keeps case-distinct database and schema completion names", () => { + expect(mergeSqlCompletionQualifierNames(["Reporting", "dbo"], ["reporting", "dbo"])).toEqual(["Reporting", "dbo", "reporting"]); + }); + it("uses the current schema for unqualified table completion", () => { const target = resolveSqlCompletionTableLookupTarget({ currentDatabase: "app", diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts index df4811bae..69348f1b5 100644 --- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts +++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts @@ -11,6 +11,10 @@ export function supportsDatabaseSchemaQualifier(dbType?: DatabaseType): boolean return !!dbType && DATABASE_SCHEMA_QUALIFIED_TYPES.has(dbType); } +export function supportsDatabaseNameCompletion(dbType?: DatabaseType): boolean { + return !!dbType && ((!isSchemaAware(dbType) && !isSingleDatabase(dbType)) || dbType === "sqlserver"); +} + /** * Doris-family engines that support multi-catalog federation (`SHOW CATALOGS`): * Doris (incl. SelectDB) and StarRocks. Manticore Search shares the MySQL code diff --git a/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts b/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts index b45ca1fa0..6b6fb5ed8 100644 --- a/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts +++ b/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts @@ -16,6 +16,29 @@ function findExactName(names: readonly string[] | undefined, value: string): str return names?.find((name) => name.toLowerCase() === value.toLowerCase()); } +function findCaseSensitiveName(names: readonly string[] | undefined, value: string): string | undefined { + return names?.find((name) => name === value); +} + +export function mergeSqlCompletionQualifierNames(primary: readonly string[], secondary: readonly string[]): string[] { + return [...new Set([...primary, ...secondary])]; +} + +export function resolveSqlCompletionSchemaLookupDatabase(options: { + supportsDatabaseSchemaQualifier?: boolean; + completionContext: Pick; + knownDatabases?: readonly string[]; + knownSchemas?: readonly string[]; +}): string | undefined { + const { completionContext } = options; + if (!options.supportsDatabaseSchemaQualifier || !completionContext.suggestTables || completionContext.insertTable) return undefined; + const qualifier = completionContext.qualifier?.trim(); + const qualifierParts = completionContext.qualifierParts?.filter(Boolean) ?? qualifier?.split(".").filter(Boolean) ?? []; + if (qualifierParts.length !== 1) return undefined; + if (findCaseSensitiveName(options.knownSchemas, qualifierParts[0]!)) return undefined; + return findCaseSensitiveName(options.knownDatabases, qualifierParts[0]!); +} + export function resolveSqlCompletionTableLookupTarget(options: { currentDatabase: string; currentSchema?: string; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 5027f0fc1..a666f3792 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -725,6 +725,32 @@ describe("connectionStore completion assistant", () => { expect(listDatabases).toHaveBeenCalledTimes(52); }); + it("invalidates cached completion databases for a connection", async () => { + const listDatabases = vi + .fn() + .mockResolvedValueOnce([{ name: "Archive" }]) + .mockResolvedValueOnce([{ name: "Reporting" }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + listDatabases, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [sqlServerConnection()]; + store.connectedIds.add("sqlserver-1"); + + expect(await store.listCompletionDatabases("sqlserver-1")).toEqual(["Archive"]); + expect(await store.listCompletionDatabases("sqlserver-1")).toEqual(["Archive"]); + + store.invalidateCompletionCache("sqlserver-1"); + + expect(await store.listCompletionDatabases("sqlserver-1")).toEqual(["Reporting"]); + expect(listDatabases).toHaveBeenCalledTimes(2); + }); + it("evicts old completion schema entries", async () => { const listSchemas = vi.fn(async (_connectionId: string, database: string) => [`schema_${database}`]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 4cf3e240c..1fc0681ea 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2272,6 +2272,7 @@ export const useConnectionStore = defineStore("connection", () => { function invalidateCompletionCache(connectionId: string, database?: string) { invalidateMetadataCaches({ connectionId, database }); + if (database == null) delete completionDatabasesCache.value[connectionId]; const cachePrefix = database == null ? `${connectionId}:` : `${connectionId}:${database}:`; const exactCacheKey = database == null ? null : `${connectionId}:${database}`; for (const key of Object.keys(completionTablesCache.value)) { diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 6277bc7a9..3335ef313 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -2371,6 +2371,41 @@ test("schema items include apply value with trailing dot", () => { assert.equal(shouldChainSqlCompletionAfterAccept({ type: "table", apply: "users" }), false); }); +test.each([ + ["Foo", "FooDB"], + ["Bar", "BarDB"], +])("suggests SQL Server database name %s as a chained qualifier", (prefix, database) => { + const sql = `select * from ${prefix}`; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + columnsByTable: new Map(), + schemas: ["FooDB", "BarDB"], + databaseType: "sqlserver", + dialect: "sqlserver", + }); + const databaseItem = items.find((item) => item.type === "schema" && item.label === database); + + assert.ok(databaseItem); + assert.equal(databaseItem.apply, `${database}.`); + assert.equal(shouldChainSqlCompletionAfterAccept(databaseItem), true); +}); + +test("suggests SQL Server schemas after a database qualifier", () => { + const sql = "select * from BarDB.d"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + columnsByTable: new Map(), + schemas: ["dbo", "data"], + databaseType: "sqlserver", + dialect: "sqlserver", + }); + const schemaItem = items.find((item) => item.type === "schema" && item.label === "dbo"); + + assert.ok(schemaItem); + assert.equal(schemaItem.apply, "dbo."); + assert.equal(shouldChainSqlCompletionAfterAccept(schemaItem), true); +}); + // --- Quoted identifier fix --- test("handles quoted identifiers with dots in splitQualifiedName", () => {