diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 9444d45d8..c545d45b1 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -36,6 +36,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, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; +import { resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; import { extractIdentifierAt, isSqlKeyword, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics"; import { @@ -1688,10 +1689,6 @@ function buildCompletionResult(items: QueryCompletionItem[], from: number, valid }; } -function findExactName(names: string[], value: string): string | undefined { - return names.find((name) => name.toLowerCase() === value.toLowerCase()); -} - function mergeCompletionQualifierNames(primary: string[], secondary: string[]): string[] { const seen = new Set(); const merged: string[] = []; @@ -1704,6 +1701,11 @@ function mergeCompletionQualifierNames(primary: string[], secondary: string[]): return merged; } +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 completionOptionForItem(item: QueryCompletionItem) { const record = () => { recordCompletionSelection(item.label, item.type); @@ -2009,13 +2011,16 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s function buildLocalSqlCompletionResult(completionContext: ReturnType, fullDoc: string, position: number) { if (!props.connectionId || props.database == null) return null; - const databaseNames = supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable ? connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES) : []; - const qualifierDatabase = completionContext.qualifier ? findExactName(databaseNames, completionContext.qualifier) : undefined; + const databaseNames = localCompletionDatabaseNames(completionContext); const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)); - const tableLookupDatabase = qualifierDatabase ?? props.database; - const tableLookupSchema = qualifierDatabase ? undefined : completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema; - const tableLookupFilter = completionContext.qualifier && completionContext.suggestTables ? completionContext.prefix : completionContext.qualifier || completionContext.prefix; - const tables = shouldLoadTables ? connectionStore.lookupLocalCompletionTables(props.connectionId, tableLookupDatabase, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema) : cachedTables; + const tableLookupTarget = resolveSqlCompletionTableLookupTarget({ + currentDatabase: props.database, + currentSchema: props.schema, + supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(), + completionContext, + knownDatabases: databaseNames, + }); + const tables = shouldLoadTables ? connectionStore.lookupLocalCompletionTables(props.connectionId, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema) : cachedTables; const shouldLoadObjects = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions); const completionObjects = shouldLoadObjects @@ -2104,10 +2109,16 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { cachedTables = mergeCompletionTables(cachedTables, tables); if (completionContext.suggestTables && completionContext.referencedTables.length > 0) { @@ -2220,8 +2231,8 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext } const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)); - let databaseNames: string[] = []; - if (!localOnlyMetadata && supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable) { + 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; @@ -2229,14 +2240,17 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext databaseNames = []; } } - const qualifierDatabase = completionContext.qualifier ? findExactName(databaseNames, completionContext.qualifier) : undefined; - const tableLookupDatabase = qualifierDatabase ?? props.database!; - const tableLookupSchema = qualifierDatabase ? undefined : completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema; - const tableLookupFilter = completionContext.qualifier && completionContext.suggestTables ? completionContext.prefix : completionContext.qualifier && !qualifierDatabase ? completionContext.qualifier : completionContext.prefix; + const tableLookupTarget = resolveSqlCompletionTableLookupTarget({ + currentDatabase: props.database!, + currentSchema: props.schema, + supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(), + completionContext, + knownDatabases: databaseNames, + }); let tables = shouldLoadTables ? localOnlyMetadata - ? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupDatabase, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema) - : await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupDatabase, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema) + ? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema) + : await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema) : cachedTables; if (epoch !== completionEpoch) return null; @@ -2275,7 +2289,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext // If qualifier didn't match any table names, try it as a schema name let qualifierIsSchema = false; - if (completionContext.qualifier && !qualifierDatabase && !isReferencedTableQualifier(completionContext) && tables.length === 0 && (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)) { + if (completionContext.qualifier && !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); if (!localOnlyMetadata) { schemaTables = await listCompletionTablesWithLatencyBudget(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts new file mode 100644 index 000000000..eac09297c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletionLookupTarget.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; + +describe("sqlCompletionLookupTarget", () => { + it("treats qualified table completion as a database lookup for MySQL-compatible engines", () => { + const target = resolveSqlCompletionTableLookupTarget({ + currentDatabase: "default_db", + supportsDatabaseQualifier: true, + completionContext: { + qualifier: "game_data", + prefix: "", + suggestTables: true, + }, + }); + + expect(target).toEqual({ + database: "game_data", + filter: "", + qualifierDatabase: "game_data", + }); + }); + + it("preserves the known database casing when the qualifier matches locally", () => { + const target = resolveSqlCompletionTableLookupTarget({ + currentDatabase: "default_db", + supportsDatabaseQualifier: true, + knownDatabases: ["Game_Data"], + completionContext: { + qualifier: "game_data", + prefix: "ord", + suggestTables: true, + }, + }); + + expect(target).toEqual({ + database: "Game_Data", + filter: "ord", + qualifierDatabase: "Game_Data", + }); + }); + + it("keeps schema-aware qualified table completion scoped to the schema", () => { + const target = resolveSqlCompletionTableLookupTarget({ + currentDatabase: "app", + currentSchema: "public", + supportsDatabaseQualifier: false, + completionContext: { + qualifier: "sales", + prefix: "ord", + suggestTables: true, + }, + }); + + expect(target).toEqual({ + database: "app", + schema: "sales", + filter: "ord", + }); + }); + + it("uses the current schema for unqualified table completion", () => { + const target = resolveSqlCompletionTableLookupTarget({ + currentDatabase: "app", + currentSchema: "public", + supportsDatabaseQualifier: true, + completionContext: { + prefix: "ord", + suggestTables: true, + }, + }); + + expect(target).toEqual({ + database: "app", + schema: "public", + filter: "ord", + }); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts b/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts new file mode 100644 index 000000000..6d7cd4da5 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlCompletionLookupTarget.ts @@ -0,0 +1,42 @@ +import type { SqlCompletionContext } from "@/lib/sql/sqlCompletion"; + +export interface SqlCompletionTableLookupTarget { + database: string; + schema?: string; + filter: string; + qualifierDatabase?: string; +} + +function findExactName(names: readonly string[] | undefined, value: string): string | undefined { + return names?.find((name) => name.toLowerCase() === value.toLowerCase()); +} + +export function resolveSqlCompletionTableLookupTarget(options: { + currentDatabase: string; + currentSchema?: string; + supportsDatabaseQualifier: boolean; + completionContext: Pick; + knownDatabases?: readonly string[]; +}): SqlCompletionTableLookupTarget { + const { completionContext } = options; + const qualifier = completionContext.qualifier?.trim(); + const qualifierIsDatabase = options.supportsDatabaseQualifier && !!qualifier && completionContext.suggestTables && !completionContext.insertTable; + + if (qualifierIsDatabase) { + // MySQL-compatible engines, including OceanBase MySQL mode, use + // database.table. Do not block table completion on a separate database-list + // request when the user already typed the database qualifier. + const database = findExactName(options.knownDatabases, qualifier) ?? qualifier; + return { + database, + filter: completionContext.prefix, + qualifierDatabase: database, + }; + } + + return { + database: options.currentDatabase, + schema: qualifier && completionContext.suggestTables ? qualifier : options.currentSchema, + filter: qualifier && completionContext.suggestTables ? completionContext.prefix : qualifier || completionContext.prefix, + }; +}