diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 6efedd9105..c263212a73 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -98,6 +98,7 @@ import type { CompletionAssistantObjectKind, DatabaseType, SqlReferenceAnalysis, const props = defineProps<{ modelValue: string; connectionId?: string; + catalog?: string; database?: string; schema?: string; clientSessionId?: string; @@ -416,18 +417,18 @@ function completionColumnRequestContext() { }; } -async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string) { +async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog) { const requestedVersion = props.completionContextVersion; const sessionScoped = usesOracleSessionCompletionColumns(schema); - const columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext()); + const columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(), catalog); if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context"); return columns; } -async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string) { +async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog) { const requestedVersion = props.completionContextVersion; const sessionScoped = usesOracleSessionCompletionColumns(schema); - const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext()); + const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(), catalog); if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context"); return columns; } @@ -739,7 +740,7 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv // Right-click must stay instant: resolve from completion/tree caches and keep the legacy table fallback when metadata is unavailable. const candidate = resolveQueryContextCandidateDatabase(parsedCandidate, connectionStore.lookupLocalCompletionDatabases(parsedCandidate.connectionId, parsedCandidate.database, MAX_COMPLETION_TABLES)); - const tables = connectionStore.lookupLocalCompletionTables(candidate.connectionId, candidate.database, candidate.tableName, MAX_COMPLETION_TABLES, candidate.schema); + const tables = connectionStore.lookupLocalCompletionTables(candidate.connectionId, candidate.database, candidate.tableName, MAX_COMPLETION_TABLES, candidate.schema, props.catalog); contextObjectTarget.value = resolveQueryContextObjectTarget(candidate, tables); } @@ -1490,7 +1491,7 @@ function identifierRangeAt(sql: string, pos: number): { from: number; to: number return { from, to, text }; } -function completionCacheKey(table: { name: string; database?: string | null; schema?: string | null }) { +function completionCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }) { const schema = table.schema ?? props.schema; const database = supportsDatabaseSchemaQualifierCompletion() ? table.database : undefined; return schema ? `${database ? `${database}.` : ""}${schema}.${table.name}` : table.name; @@ -1505,10 +1506,10 @@ function insertHintCacheKey(table: { name: string; schema?: string | null; datab return completionCacheKey(table); } -function insertHintMetadataTarget(table: { name: string; schema?: string | null; database?: string | null }): { database: string; schema?: string } | null { +function insertHintMetadataTarget(table: { name: string; schema?: string | null; database?: string | null }): { database: string; schema?: string; catalog?: string } | null { if (props.database == null) return null; if (table.database) { - return { database: table.database, schema: table.schema ?? undefined }; + return { database: table.database, schema: table.schema ?? undefined, catalog: props.catalog }; } return completionMetadataTarget(table); } @@ -1539,7 +1540,7 @@ function requestInsertValueHintTableColumns(table: string, schema?: string, data cachedInsertValueHintColumnsByTable.set(cacheKey, insertValueHintColumnNames(databaseType, columns)); return; } - const columns = await listCompletionColumnsForEditor(connectionId, target.database, table, target.schema); + const columns = await listCompletionColumnsForEditor(connectionId, target.database, table, target.schema, target.catalog); cachedColumnsByTable.set(cacheKey, columns); }; void loadColumns() @@ -1575,15 +1576,15 @@ function allowsOnDemandQualifiedTableCompletion(prefix: string): boolean { return prefix.trim().length >= PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX; } -function completionMetadataTarget(table: { name: string; database?: string | null; schema?: string | null }): { database: string; schema?: string } | null { +function completionMetadataTarget(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }): { database: string; schema?: string; catalog?: string } | null { if (props.database == null) return null; if (supportsDatabaseSchemaQualifierCompletion() && table.database) { - return { database: table.database, schema: table.schema ?? undefined }; + return { database: table.database, schema: table.schema ?? undefined, catalog: table.catalog ?? props.catalog }; } if (supportsDatabaseQualifierCompletion() && table.schema) { - return { database: table.schema }; + return { database: table.schema, catalog: table.catalog ?? props.catalog }; } - return { database: props.database, schema: table.schema ?? props.schema }; + return { database: props.database, schema: table.schema ?? props.schema, catalog: table.catalog ?? props.catalog }; } function isVirtualCompletionTableReference(table: { name: string; database?: string | null; schema?: string | null }): boolean { @@ -1601,8 +1602,9 @@ function completionQualifiedTableTarget(completionContext: ReturnType completionTablesMatch(item, table)); if (localExact) return localExact; - const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, false, props.schema); + const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, false, props.schema, target.catalog); cachedTables = mergeCompletionTables(cachedTables, remoteMatches); return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null; } @@ -1628,7 +1630,7 @@ async function ensureColumnsForTable(table: { name: string; database?: string | if (!props.connectionId || props.database == null) return false; const target = completionMetadataTarget(table); if (!target) return false; - const columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema); + const columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema, target.catalog); cachedColumnsByTable.set(cacheKey, columns); loadedColumnsByTable.add(cacheKey.toLowerCase()); return true; @@ -1751,13 +1753,13 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) try { if (cachedTables.length === 0) { cachedTables = usesLocalOnlyCompletionMetadata() - ? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema) - : await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, false, props.schema); + ? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, props.catalog) + : await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, false, props.schema, props.catalog); } let table = matchTable(qualifiedTableLookup, cachedTables) ?? matchTable(tableLookupName, cachedTables) ?? matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables); if (!table && !usesLocalOnlyCompletionMetadata()) { - const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema, false, props.schema); + const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema, false, props.schema, props.catalog); cachedTables = mergeCompletionTables(cachedTables, hoverTables); table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); } @@ -2627,7 +2629,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { columnsByTable.set(completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }), insertColumns); } @@ -2653,7 +2655,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { columnsByTable.set(cacheKey, localColumns); } @@ -2682,7 +2684,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { columnsByTable.set(cacheKey, localColumns); } @@ -2732,7 +2734,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { const scopedTables = tables.map((table) => ({ ...table, database: table.database ?? tableLookupTarget.database })); cachedTables = mergeCompletionTables(cachedTables, scopedTables); @@ -2773,7 +2775,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { if (columns.length > 0) cachedColumnsByTable.set(qualifiedColumnCacheKey, columns); }) @@ -2789,7 +2791,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { if (columns.length > 0) cachedColumnsByTable.set(cacheKey, columns); }) @@ -2803,9 +2805,9 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType [`${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(), index])); + const indexes = new Map(existing.map((table, index) => [`${table.catalog ?? ""}.${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(), index])); for (const table of incoming) { - const key = `${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(); + const key = `${table.catalog ?? ""}.${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(); const index = indexes.get(key); if (index == null) { indexes.set(key, merged.length); @@ -2827,10 +2829,10 @@ function withCompletionLatencyBudget(remote: Promise, local: T): Promise((resolve) => setTimeout(() => resolve(local), COMPLETION_REMOTE_LATENCY_BUDGET_MS))]); } -function listCompletionTablesWithLatencyBudget(connectionId: string, database: string, filter: string, limit: number, schema?: string, globalSearch = false): Promise { - const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, globalSearch ? undefined : schema).map((table) => ({ ...table, database: table.database ?? database })); - const remote = connectionStore.listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, props.schema).then((tables) => { - const scopedTables = tables.map((table) => ({ ...table, database: table.database ?? database })); +function listCompletionTablesWithLatencyBudget(connectionId: string, database: string, filter: string, limit: number, schema?: string, globalSearch = false, catalog = props.catalog): Promise { + const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, globalSearch ? undefined : schema, catalog).map((table) => ({ ...table, catalog: table.catalog ?? catalog, database: table.database ?? database })); + const remote = connectionStore.listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, props.schema, catalog).then((tables) => { + const scopedTables = tables.map((table) => ({ ...table, catalog: table.catalog ?? catalog, database: table.database ?? database })); cachedTables = mergeCompletionTables(cachedTables, scopedTables); return scopedTables; }); @@ -2845,6 +2847,10 @@ interface RoutineCompletionTarget { } function shouldLoadCompletionObjects(completionContext: ReturnType): boolean { + // Doris/StarRocks external catalogs currently expose table and column + // metadata through catalog-aware APIs. The generic routine/object endpoint + // is catalogless and would query the internal catalog, aborting completion. + if (props.catalog) return false; const routineContext = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions); return routineContext && !isReferencedTableQualifier(completionContext); } @@ -2927,7 +2933,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext 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) + ? 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)) { @@ -2939,7 +2945,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext let completionObjects = shouldLoadObjects ? (localOnlyMetadata ? lookupLocalCompletionObjectsForContext(completionContext) : await listCompletionObjectsForContext(completionContext)) : cachedCompletionObjects; if (epoch !== completionEpoch) return null; - if (props.databaseType !== "oracle" && !localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) { + if (!props.catalog && props.databaseType !== "oracle" && !localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) { const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier); if (schemaObjects.length > 0) { completionObjects = schemaObjects; @@ -2967,7 +2973,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 && !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); + 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); } else if (schemaTables.length === 0 && allowsOnDemandQualifiedTableCompletion(completionContext.prefix)) { @@ -2993,7 +2999,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext }); const unresolvedRefs = refs.filter((rt) => !usesOracleSessionCompletionColumns(rt.schema) && !rt.schema && !rt.columns && !isVirtualCompletionTableReference(rt)); if (!localOnlyMetadata && unresolvedRefs.length > 0) { - const lookupGroups = await Promise.all(unresolvedRefs.map((rt) => connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema, false, props.schema))); + const lookupGroups = await Promise.all(unresolvedRefs.map((rt) => connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema, false, props.schema, props.catalog))); if (epoch !== completionEpoch) return null; const lookupTables = lookupGroups.flat(); refs = refs.map((rt) => { @@ -3038,7 +3044,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext try { const target = completionMetadataTarget(refTable); if (!target) return; - const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema); + const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog); if (epoch !== completionEpoch) return; if (columns.length === 0) return; cachedColumnsByTable.set(cacheKey, columns); @@ -3839,8 +3845,8 @@ onMounted(async () => { if (cachedTables.length === 0) { // Some metadata providers only accept table-name masks, not schema.table masks. cachedTables = usesLocalOnlyCompletionMetadata() - ? connectionStore.lookupLocalCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema) - : await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema, false, props.schema); + ? connectionStore.lookupLocalCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema, props.catalog) + : await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema, false, props.schema, props.catalog); } // 1. Check if it's a table name @@ -3901,7 +3907,7 @@ onMounted(async () => { try { const target = completionMetadataTarget(refTable); if (!target) continue; - cols = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema); + cols = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog); cachedColumnsByTable.set(cacheKey, cols); } catch { continue; @@ -4038,6 +4044,15 @@ watch( }, ); +watch( + () => props.catalog, + () => { + refreshCompletionCache(); + setSemanticDiagnostics([]); + scheduleSemanticDiagnostics(); + }, +); + watch( () => props.schema, () => { diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index b1857b5b0d..49bbc9072e 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -826,6 +826,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand auto-focus :model-value="activeTab.sql" :connection-id="activeTab.connectionId" + :catalog="activeTab.catalog" :database="activeTab.database" :schema="activeTab.schema" :client-session-id="activeTab.id" diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 4fbbddb76f..a89805e120 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1089,7 +1089,7 @@ async function newQuery() { await newSelectTemplate(); return; } - queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema); + queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema, undefined, node.catalog); return; } const connection = connectionStore.getConfig(node.connectionId); diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts index bb6534d225..33c4589fe8 100644 --- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts @@ -50,4 +50,11 @@ describe("openTabsPersistence originalSql round-trip", () => { expect(restored.sql).toBe("SELECT 1"); expect(restored.originalSql).toBe(""); }); + + it("preserves an external Doris catalog across tab restore", () => { + const [restored] = roundTrip([queryTab({ database: "dbx_catalog_completion", catalog: "dbx_mysql_catalog" })]); + + expect(restored.database).toBe("dbx_catalog_completion"); + expect(restored.catalog).toBe("dbx_mysql_catalog"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts b/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts index e48605807c..c981838fbf 100644 --- a/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts +++ b/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts @@ -77,4 +77,27 @@ describe("completionTreeIndex", () => { expect(completionTablesFromTree(tree, "conn", "hive", "sales_analytics")).toEqual(expected); expect(completionTablesFromTree(tree, "conn", "hive")).toEqual(expected); }); + + it("isolates Doris internal and external catalog tables with the same database name", () => { + const tree: TreeNode[] = [ + { + id: "internal-orders", + label: "orders", + type: "table", + connectionId: "doris", + database: "sales", + }, + { + id: "hive-orders", + label: "orders", + type: "table", + connectionId: "doris", + catalog: "hive_catalog", + database: "sales", + }, + ]; + + expect(completionTablesFromTree(tree, "doris", "sales")).toEqual([{ name: "orders", schema: undefined, type: "table" }]); + expect(completionTablesFromTree(tree, "doris", "sales", undefined, "hive_catalog")).toEqual([{ name: "orders", catalog: "hive_catalog", schema: undefined, type: "table" }]); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/dorisCatalogCompletion.spec.ts b/apps/desktop/src/lib/__tests__/sql/dorisCatalogCompletion.spec.ts new file mode 100644 index 0000000000..7652c4804f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/dorisCatalogCompletion.spec.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8"); +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); +const sidebarRuntimeSource = readFileSync(new URL("../../../components/sidebar/SidebarTreeRuntimeHost.vue", import.meta.url), "utf8"); +const openTabsPersistenceSource = readFileSync(new URL("../../app/openTabsPersistence.ts", import.meta.url), "utf8"); + +describe("Doris external catalog completion wiring", () => { + it("preserves the selected catalog from the sidebar query action into QueryEditor", () => { + expect(sidebarRuntimeSource).toContain('queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema, undefined, node.catalog)'); + expect(contentAreaSource).toContain(':catalog="activeTab.catalog"'); + expect(queryEditorSource).toContain("catalog?: string;"); + expect(openTabsPersistenceSource).toContain("catalog: tab.catalog"); + }); + + it("includes catalog scope in table and column completion requests", () => { + expect(queryEditorSource).toMatch(/lookupLocalCompletionTables\([\s\S]*?props\.catalog/); + expect(queryEditorSource).toMatch(/listCompletionTables\([\s\S]*?props\.catalog/); + expect(queryEditorSource).toMatch(/listCompletionColumns\([\s\S]*?catalog\)/); + expect(queryEditorSource).toMatch(/watch\(\s*\(\) => props\.catalog,[\s\S]*?refreshCompletionCache\(\)/); + expect(queryEditorSource).toMatch(/function shouldLoadCompletionObjects[\s\S]*?if \(props\.catalog\) return false/); + expect(queryEditorSource).toMatch(/if \(!props\.catalog && props\.databaseType !== "oracle"[\s\S]*?listCompletionObjects/); + }); +}); diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index 24cdf72020..f9d3bc8989 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -20,6 +20,7 @@ export interface SavedOpenTab { customTitle?: boolean; connectionId: string; database: string; + catalog?: string; schema?: string; sql: string; originalSql?: string; @@ -84,6 +85,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { ...(tab.customTitle ? { customTitle: true } : {}), connectionId: tab.connectionId, database: tab.database, + ...(tab.catalog !== undefined ? { catalog: tab.catalog } : {}), schema: tab.schema, sql: shouldPersistTabSql(tab) ? tab.sql : "", // Only round-trip originalSql for plain query tabs (no savedSqlId / externalSqlPath): diff --git a/apps/desktop/src/lib/metadata/completionTreeIndex.ts b/apps/desktop/src/lib/metadata/completionTreeIndex.ts index 696c06e73b..de82f44320 100644 --- a/apps/desktop/src/lib/metadata/completionTreeIndex.ts +++ b/apps/desktop/src/lib/metadata/completionTreeIndex.ts @@ -16,14 +16,19 @@ export function completionSchemasFromTree(nodes: readonly TreeNode[], connection return schemas; } -export function completionTablesFromTree(nodes: readonly TreeNode[], connectionId: string, database: string, schema?: string): SqlCompletionTable[] { +export function completionTablesFromTree(nodes: readonly TreeNode[], connectionId: string, database: string, schema?: string, catalog?: string): SqlCompletionTable[] { const preferredSchema = schema?.toLowerCase(); + const preferredCatalog = catalog?.toLowerCase(); const tables: SqlCompletionTable[] = []; visitTree(nodes, (node) => { if (node.connectionId !== connectionId || node.database !== database || !TABLE_NODE_TYPES.has(node.type)) return; + // External catalogs can contain databases and tables with the same names as + // the internal catalog. Keep the two metadata scopes isolated. + if ((node.catalog?.toLowerCase() ?? undefined) !== preferredCatalog) return; if (preferredSchema && node.schema?.toLowerCase() !== preferredSchema) return; tables.push({ name: node.tableName || node.label, + catalog: node.catalog, schema: node.schema, type: node.type === "materialized_view" ? "materialized_view" : node.type === "view" ? "view" : "table", }); @@ -43,7 +48,7 @@ function dedupeCompletionTables(tables: SqlCompletionTable[]): SqlCompletionTabl const seen = new Set(); const result: SqlCompletionTable[] = []; for (const table of tables) { - const key = `${table.schema ?? ""}.${table.name}`.toLowerCase(); + const key = `${table.catalog ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(); if (seen.has(key)) continue; seen.add(key); result.push(table); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 02aca1a84c..a01dca32dc 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1255,6 +1255,7 @@ function sqlAliasKeywordWords(...sources: Array): string[] { export interface SqlCompletionTable { name: string; + catalog?: string; database?: string; schema?: string; type?: SqlObjectNavigationType; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 0b12f5787b..c89b35e36b 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -49,6 +49,18 @@ function sqlServerConnection(): ConnectionConfig { } as ConnectionConfig; } +function dorisConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "doris-1", + name: "Doris", + db_type: "doris", + port: 9030, + username: "root", + database: "sales", + } as ConnectionConfig; +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -468,6 +480,72 @@ describe("connectionStore completion assistant", () => { expect(maxActiveColumns).toBe(2); }); + it("keeps Doris table completion caches isolated by catalog", async () => { + const listTables = vi.fn(async (...args: unknown[]) => { + const catalog = args[7]; + return [{ name: catalog ? "external_orders" : "internal_orders", table_type: "BASE TABLE", comment: null }]; + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + listTables, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [dorisConnection()]; + store.connectedIds.add("doris-1"); + + const internal = await store.listCompletionTables("doris-1", "sales", "orders", 20); + const external = await store.listCompletionTables("doris-1", "sales", "orders", 20, undefined, false, undefined, "hive_catalog"); + + expect(listTables).toHaveBeenNthCalledWith(1, "doris-1", "sales", "sales", "orders", 20); + expect(listTables).toHaveBeenNthCalledWith(2, "doris-1", "sales", "", "orders", 20, undefined, undefined, "hive_catalog"); + expect(internal).toEqual([{ name: "internal_orders", catalog: undefined, type: "table" }]); + expect(external).toEqual([{ name: "external_orders", catalog: "hive_catalog", type: "table" }]); + expect(store.lookupLocalCompletionTables("doris-1", "sales", "", 20)).toEqual(internal); + expect(store.lookupLocalCompletionTables("doris-1", "sales", "", 20, undefined, "hive_catalog")).toEqual(external); + }); + + it("passes Doris catalog to column metadata and isolates same-name table caches", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [], incomplete: false, fallback_used: false }); + const getColumns = vi.fn(async (_connectionId: string, _database: string, _schema: string, _table: string, catalog?: string) => [ + { + name: catalog ? "external_id" : "internal_id", + data_type: "BIGINT", + is_nullable: false, + column_default: null, + is_primary_key: true, + extra: null, + comment: null, + }, + ]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [dorisConnection()]; + store.connectedIds.add("doris-1"); + + const internal = await store.listCompletionColumns("doris-1", "sales", "orders"); + const external = await store.listCompletionColumns("doris-1", "sales", "orders", undefined, undefined, "hive_catalog"); + + expect(completionAssistantSearch).toHaveBeenCalledTimes(1); + expect(getColumns).toHaveBeenNthCalledWith(1, "doris-1", "sales", "sales", "orders", undefined, undefined); + expect(getColumns).toHaveBeenNthCalledWith(2, "doris-1", "sales", "sales", "orders", "hive_catalog", undefined); + expect(internal.map((column) => column.name)).toEqual(["internal_id"]); + expect(external.map((column) => column.name)).toEqual(["external_id"]); + expect(store.lookupLocalCompletionColumns("doris-1", "sales", "orders").map((column) => column.name)).toEqual(["internal_id"]); + expect(store.lookupLocalCompletionColumns("doris-1", "sales", "orders", undefined, "hive_catalog").map((column) => column.name)).toEqual(["external_id"]); + }); + it("evicts old completion database entries", async () => { const listDatabases = vi.fn(async (connectionId: string) => [{ name: `db_${connectionId}` }]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 4f415243f8..2147d4b07e 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -3263,7 +3263,7 @@ export const useConnectionStore = defineStore("connection", () => { const tables = await withMetadataLoadTimeout(connectionId, api.listTables(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog), "tables"); const hasMore = searchFilter ? false : tables.length > pageSize; const pageTables = hasMore ? tables.slice(0, pageSize) : tables; - indexCompletionTables(connectionId, database, undefined, tableInfosToCompletionTables(pageTables, undefined)); + indexCompletionTables(connectionId, database, undefined, tableInfosToCompletionTables(pageTables, undefined), catalog); let children = buildTableTreeNodes({ nodeId: node.id, connectionId, @@ -4199,8 +4199,12 @@ export const useConnectionStore = defineStore("connection", () => { return `${connectionId}:${database}:${schema?.toLowerCase() ?? ""}`; } - function completionColumnsKey(connectionId: string, database: string, table: string, schema?: string): string { - return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}`; + function completionTableScopeKey(connectionId: string, database: string, schema?: string, catalog?: string): string { + return `${connectionId}:${database}:${catalog?.toLowerCase() ?? ""}:${schema?.toLowerCase() ?? ""}`; + } + + function completionColumnsKey(connectionId: string, database: string, table: string, schema?: string, catalog?: string): string { + return `${completionTableScopeKey(connectionId, database, schema, catalog)}:${table.toLowerCase()}`; } function completionForeignKeysKey(connectionId: string, database: string, table: string, schema?: string): string { @@ -4453,13 +4457,14 @@ export const useConnectionStore = defineStore("connection", () => { return tableMatchScore(tableLike, filter, preferredSchema); } - function indexCompletionTables(connectionId: string, database: string, schema: string | undefined, tables: SqlCompletionTable[]) { + function indexCompletionTables(connectionId: string, database: string, schema: string | undefined, tables: SqlCompletionTable[], catalog?: string) { const groups = new Map(); for (const table of tables) { const tableSchema = table.schema ?? schema; - const key = completionScopeKey(connectionId, database, tableSchema); + const tableCatalog = table.catalog ?? catalog; + const key = completionTableScopeKey(connectionId, database, tableSchema, tableCatalog); const list = groups.get(key) ?? []; - list.push({ ...table, schema: tableSchema }); + list.push({ ...table, catalog: tableCatalog, schema: tableSchema }); groups.set(key, list); } for (const [key, group] of groups) { @@ -4487,8 +4492,8 @@ export const useConnectionStore = defineStore("connection", () => { } } - function indexCompletionColumns(connectionId: string, database: string, table: string, schema: string | undefined, columns: SqlCompletionColumn[]) { - touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema), { + function indexCompletionColumns(connectionId: string, database: string, table: string, schema: string | undefined, columns: SqlCompletionColumn[], catalog?: string) { + touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema, catalog), { columns, }); } @@ -4509,11 +4514,12 @@ export const useConnectionStore = defineStore("connection", () => { }); } - function lookupLocalCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): SqlCompletionTable[] { - const allScopes = [...completionTableIndex.entries()].filter(([key]) => key.startsWith(`${connectionId}:${database}:`)).map(([, entry]) => entry); - const preferred = schema ? completionTableIndex.get(completionScopeKey(connectionId, database, schema)) : undefined; + function lookupLocalCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, catalog?: string): SqlCompletionTable[] { + const scopePrefix = `${connectionId}:${database}:${catalog?.toLowerCase() ?? ""}:`; + const allScopes = [...completionTableIndex.entries()].filter(([key]) => key.startsWith(scopePrefix)).map(([, entry]) => entry); + const preferred = schema ? completionTableIndex.get(completionTableScopeKey(connectionId, database, schema, catalog)) : undefined; const scopes = schema ? (preferred ? [preferred] : []) : allScopes; - const treeTables = completionTablesFromTree(treeNodes.value, connectionId, database, schema); + const treeTables = completionTablesFromTree(treeNodes.value, connectionId, database, schema, catalog); const ranked = scopes .flatMap((entry) => entry?.tables ?? []) .concat(treeTables) @@ -4567,8 +4573,8 @@ export const useConnectionStore = defineStore("connection", () => { return result; } - function lookupLocalCompletionColumns(connectionId: string, database: string, table: string, schema?: string): SqlCompletionColumn[] { - return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema))?.columns ?? []; + function lookupLocalCompletionColumns(connectionId: string, database: string, table: string, schema?: string, catalog?: string): SqlCompletionColumn[] { + return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema, catalog))?.columns ?? []; } function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] { @@ -4686,7 +4692,12 @@ export const useConnectionStore = defineStore("connection", () => { }); } - async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise { + function listCompletionTableMetadata(connectionId: string, database: string, schema: string, filter?: string, limit?: number, catalog?: string): Promise { + if (catalog) return api.listTables(connectionId, database, schema, filter, limit, undefined, undefined, catalog); + return api.listTables(connectionId, database, schema, filter, limit); + } + + async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string, catalog?: string): Promise { const trimmedFilter = filter.trim(); const normalizedFilter = trimmedFilter.toLowerCase(); // Remote queries (Dameng/Oracle) are case-sensitive, so the cache key must @@ -4694,7 +4705,7 @@ export const useConnectionStore = defineStore("connection", () => { // second lookup returns the first's stale results. Local lookups below stay // case-insensitive because tableMatchScore normalizes internally. const relaxedFilter = relaxedCompletionTableFilter(trimmedFilter); - const cacheKey = `${connectionId}:${database}:${trimmedFilter}:${limit ?? ""}:${schema ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}`; + const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${trimmedFilter}:${limit ?? ""}:${schema ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}`; if (completionTablesCache.value[cacheKey]) { return completionTablesCache.value[cacheKey]; } @@ -4711,14 +4722,15 @@ export const useConnectionStore = defineStore("connection", () => { results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema); } catch { if (schema) { - const tables = await api.listTables(connectionId, database, schema, trimmedFilter, limit); + const tables = await listCompletionTableMetadata(connectionId, database, schema, trimmedFilter, limit, catalog); results = tables.map((table) => ({ name: table.name, + catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), })); } else { - results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit); + results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); } } if (results.length === 0 && relaxedFilter) { @@ -4730,9 +4742,10 @@ export const useConnectionStore = defineStore("connection", () => { } } else if (schema) { try { - const tables = await api.listTables(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit)); + const tables = await listCompletionTableMetadata(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit), catalog); results = tables.map((table) => ({ name: table.name, + catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), })); @@ -4740,41 +4753,44 @@ export const useConnectionStore = defineStore("connection", () => { results = []; } } else { - results = lookupLocalCompletionTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit)); + results = lookupLocalCompletionTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), undefined, catalog); } } const limitedTables = limit ? dedupeCompletionTables(results).slice(0, limit) : results; completionTablesCache.value[cacheKey] = limitedTables; - indexCompletionTables(connectionId, database, undefined, limitedTables); + indexCompletionTables(connectionId, database, undefined, limitedTables, catalog); evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; } if (schema) { - const tables = await api.listTables(connectionId, database, schema); + const tables = await listCompletionTableMetadata(connectionId, database, schema, undefined, undefined, catalog); completionTablesCache.value[cacheKey] = tables.map((table) => ({ name: table.name, + catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), })); } else { - completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit); + completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); } - indexCompletionTables(connectionId, database, undefined, completionTablesCache.value[cacheKey]); + indexCompletionTables(connectionId, database, undefined, completionTablesCache.value[cacheKey], catalog); evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; } - let tables = await api.listTables(connectionId, database, database, trimmedFilter, limit); + const querySchema = catalog ? "" : database; + let tables = await listCompletionTableMetadata(connectionId, database, querySchema, trimmedFilter, limit, catalog); if (tables.length === 0 && relaxedFilter) { - tables = await api.listTables(connectionId, database, database, relaxedFilter, expandedCompletionLimit(limit)); + tables = await listCompletionTableMetadata(connectionId, database, querySchema, relaxedFilter, expandedCompletionLimit(limit), catalog); } completionTablesCache.value[cacheKey] = tables.map((table) => ({ name: table.name, + catalog, type: sqlObjectNavigationTypeFromTableType(table.table_type), })); completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey]; - indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey]); + indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey], catalog); evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; }, @@ -4796,7 +4812,7 @@ export const useConnectionStore = defineStore("connection", () => { const indexByKey = new Map(); const deduped: SqlCompletionTable[] = []; for (const table of tables) { - const key = `${table.schema ?? ""}.${table.name}`.toLowerCase(); + const key = `${table.catalog ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(); const existingIndex = indexByKey.get(key); if (existingIndex != null) { const existing = deduped[existingIndex]; @@ -4907,7 +4923,7 @@ export const useConnectionStore = defineStore("connection", () => { return deduped; } - async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }): Promise { + async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }, catalog?: string): Promise { const config = getConfig(connectionId); const completionSchema = schema?.trim() || undefined; const usesOracleCurrentSchema = config?.db_type === "oracle" && !completionSchema; @@ -4915,13 +4931,13 @@ export const useConnectionStore = defineStore("connection", () => { return []; } const sessionCacheScope = usesOracleCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : ""; - const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}${sessionCacheScope}`; + const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${schema || ""}:${table}${sessionCacheScope}`; if (!completionColumnsCache.value[cacheKey]) { await withCompletionInFlight( `${cacheKey}:columns`, async () => { await ensureConnected(connectionId); - if (!usesOracleCurrentSchema) { + if (!usesOracleCurrentSchema && !catalog) { try { const assistantColumns = await listCompletionAssistantColumns(connectionId, database, table, completionSchema); if (assistantColumns.length > 0) { @@ -4945,7 +4961,7 @@ export const useConnectionStore = defineStore("connection", () => { } } const querySchema = usesOracleCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema); - completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table, undefined, usesOracleCurrentSchema ? context?.clientSessionId : undefined); + completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table, catalog, usesOracleCurrentSchema ? context?.clientSessionId : undefined); evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); }, { scope: completionLimiterScope(connectionId, database), kind: "columns" }, @@ -4960,7 +4976,7 @@ export const useConnectionStore = defineStore("connection", () => { isNullable: column.is_nullable, comment: column.comment, })); - if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, table, schema, columns); + if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, table, schema, columns, catalog); return columns; } @@ -4990,8 +5006,8 @@ export const useConnectionStore = defineStore("connection", () => { return foreignKeys; } - function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise { - return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema); + function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string, catalog?: string): Promise { + return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog); } function refreshCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string): Promise { @@ -5006,8 +5022,8 @@ export const useConnectionStore = defineStore("connection", () => { return listCompletionDatabases(connectionId); } - function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }): Promise { - return listCompletionColumns(connectionId, database, table, schema, context); + function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }, catalog?: string): Promise { + return listCompletionColumns(connectionId, database, table, schema, context, catalog); } function refreshCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise {