diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 1f52ac3d8a..2e69401b38 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -38,7 +38,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport"; import * as api from "@/lib/api"; import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sqlCompletion"; -import type { DatabaseType, ForeignKeyInfo, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; +import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; const props = defineProps<{ modelValue: string; @@ -131,6 +131,7 @@ const completionTranslations = computed(() => ({ functionDescriptions: Object.fromEntries(SQL_FUNCTION_NAMES.map((name) => [name, t(`editor.completion.functionDescriptions.${name}`)])) as Record, })); const MAX_COMPLETION_TABLES = 200; +const MAX_JOIN_FK_PREFETCH_TABLES = 24; const liveFontSize = ref(settingsStore.editorSettings.fontSize); const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize); const isGestureZooming = ref(false); @@ -455,25 +456,26 @@ async function ensureForeignKeysForTable(table: { name: string; schema?: string if (cachedForeignKeysByTable.has(cacheKey) || !props.connectionId || props.database == null) return; const target = completionMetadataTarget(table); if (!target) return; - const querySchema = target.schema ?? target.database; try { - const foreignKeys = await api.listForeignKeys(props.connectionId, target.database, querySchema, table.name); - cachedForeignKeysByTable.set( - cacheKey, - foreignKeys.map((foreignKey: ForeignKeyInfo) => ({ - name: foreignKey.name, - column: foreignKey.column, - ref_schema: foreignKey.ref_schema, - ref_table: foreignKey.ref_table, - ref_column: foreignKey.ref_column, - })), - ); + const foreignKeys = await connectionStore.listCompletionForeignKeys(props.connectionId, target.database, table.name, target.schema); + cachedForeignKeysByTable.set(cacheKey, foreignKeys); } catch (e) { console.warn(`[DBX] Failed to load foreign keys for ${cacheKey}:`, e); cachedForeignKeysByTable.set(cacheKey, []); } } +async function ensureForeignKeysForTables(tables: Array<{ name: string; schema?: string | null }>) { + const seen = new Set(); + const uniqueTables = tables.filter((table) => { + const key = completionCacheKey(table).toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + await Promise.all(uniqueTables.map((table) => ensureForeignKeysForTable(table))); +} + function createHoverDom(title: string, detail: string, rows: string[] = []) { const dom = document.createElement("div"); dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"; @@ -1058,6 +1060,10 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { columnsByTable.set(cacheKey, localColumns); } + const localForeignKeys = target ? connectionStore.lookupLocalCompletionForeignKeys(props.connectionId, target.database, refTable.name, target.schema) : []; + if (localForeignKeys.length > 0) { + cachedForeignKeysByTable.set(cacheKey, localForeignKeys); + } } if (tables.length === 0 && completionObjects.length === 0 && schemaNames.length === 0 && columnsByTable.size === 0 && (completionContext.exclusiveTableSuggestions || completionContext.exclusiveColumnSuggestions || completionContext.exclusiveRoutineSuggestions)) { @@ -1089,6 +1095,9 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { cachedTables = mergeCompletionTables(cachedTables, tables); + if (completionContext.suggestTables && completionContext.referencedTables.length > 0) { + void ensureForeignKeysForTables([...completionContext.referencedTables, ...tables.slice(0, MAX_JOIN_FK_PREFETCH_TABLES)]); + } }) .catch(() => {}); } @@ -1129,6 +1138,9 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType {}); } + if ((completionContext.suggestTables || completionContext.suggestJoinConditions) && completionContext.referencedTables.length > 0) { + void ensureForeignKeysForTables(completionContext.referencedTables); + } } function mergeCompletionTables(existing: Array<{ name: string; schema?: string; type?: "table" | "view" }>, incoming: Array<{ name: string; schema?: string; type?: "table" | "view" }>) { @@ -1271,13 +1283,9 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext ); if (epoch !== completionEpoch) return null; - if (completionContext.suggestJoinConditions) { - await Promise.all( - refs.map(async (refTable) => { - if (refTable.columns && refTable.columns.length > 0) return; - await ensureForeignKeysForTable(refTable); - }), - ); + if ((completionContext.suggestTables || completionContext.suggestJoinConditions) && refs.length > 0) { + const fkPrefetchTables = completionContext.suggestTables ? [...refs, ...tables.slice(0, MAX_JOIN_FK_PREFETCH_TABLES)] : refs; + await ensureForeignKeysForTables(fkPrefetchTables.filter((table) => !("columns" in table) || !table.columns || table.columns.length === 0)); if (epoch !== completionEpoch) return null; } @@ -1307,7 +1315,12 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext if (cached) { columnsByTable.set(cacheKey, cached); } - const cachedForeignKeys = cachedForeignKeysByTable.get(cacheKey); + let cachedForeignKeys = cachedForeignKeysByTable.get(cacheKey); + if (!cachedForeignKeys) { + const target = completionMetadataTarget(refTable); + cachedForeignKeys = target ? connectionStore.lookupLocalCompletionForeignKeys(props.connectionId!, target.database, refTable.name, target.schema) : []; + if (cachedForeignKeys.length > 0) cachedForeignKeysByTable.set(cacheKey, cachedForeignKeys); + } if (cachedForeignKeys) { foreignKeysByTable.set(cacheKey, cachedForeignKeys); } diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index e725bf9ba0..26c6da6d1f 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -2154,17 +2154,17 @@ function buildForeignKeyRelatedTableItems(context: SqlCompletionContext, tables: const candidates = new Map(); for (const ref of context.referencedTables) { for (const [ownerKey, foreignKeys] of foreignKeysByTable.entries()) { - const ownerName = ownerKey.split(".").filter(Boolean).pop() ?? ownerKey; + const owner = foreignKeyOwnerFromKey(ownerKey); for (const foreignKey of foreignKeys) { - if (referencedTableMatchesName(ref, ownerName)) { + if (referencedTableMatchesName(ref, owner.name, owner.schema)) { const target = findCompletionTable(tables, foreignKey.ref_table, foreignKey.ref_schema); if (target && matchesPrefix(target.name, context.prefix)) { - candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${foreignKey.column} → ${foreignKey.ref_table}.${foreignKey.ref_column}` }); + candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${foreignKey.column} → ${qualifiedCompletionName(foreignKey.ref_table, foreignKey.ref_schema)}.${foreignKey.ref_column}` }); } } else if (referencedTableMatchesName(ref, foreignKey.ref_table, foreignKey.ref_schema)) { - const target = findCompletionTable(tables, ownerName); + const target = findCompletionTable(tables, owner.name, owner.schema); if (target && matchesPrefix(target.name, context.prefix)) { - candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${ownerName}.${foreignKey.column} → ${foreignKey.ref_column}` }); + candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${qualifiedCompletionName(owner.name, owner.schema)}.${foreignKey.column} → ${foreignKey.ref_column}` }); } } } @@ -2182,6 +2182,17 @@ function buildForeignKeyRelatedTableItems(context: SqlCompletionContext, tables: .sort(compareCompletionItems); } +function foreignKeyOwnerFromKey(ownerKey: string): { name: string; schema?: string } { + const parts = ownerKey.split(".").filter(Boolean); + const name = parts.pop() ?? ownerKey; + const schema = parts.pop(); + return { name, schema }; +} + +function qualifiedCompletionName(name: string, schema?: string | null): string { + return schema ? `${schema}.${name}` : name; +} + function findCompletionTable(tables: SqlCompletionTable[], name: string, schema?: string | null): SqlCompletionTable | undefined { const normalizedName = normalizeIdentifierPart(name); const normalizedSchema = schema ? normalizeIdentifierPart(schema) : undefined; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 20d0cb3f52..6ad8193a40 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/utils"; import { ref, computed, watch } from "vue"; -import type { ColumnInfo, ConnectionConfig, ObjectInfo, SidebarLayout, TreeNode } from "@/types/database"; +import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, ObjectInfo, SidebarLayout, TreeNode } from "@/types/database"; import { applyPinnedTreeNodeState, orderPinnedFirst } from "@/lib/pinnedItems"; import { reconcileLayout, @@ -17,7 +17,7 @@ import { reorderEntry as reorderEntryOp, type DropPosition, } from "@/lib/sidebarLayout"; -import type { SqlCompletionColumn, SqlCompletionObject, SqlCompletionTable } from "@/lib/sqlCompletion"; +import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sqlCompletion"; import * as api from "@/lib/api"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/databaseCapabilities"; @@ -103,12 +103,14 @@ export const useConnectionStore = defineStore("connection", () => { const completionTablesCache = ref>({}); const completionObjectsCache = ref>({}); const completionColumnsCache = ref>({}); + const completionForeignKeysCache = ref>({}); const completionDatabasesCache = ref>({}); const elasticsearchCompletionIndicesCache = ref>({}); const schemaListCache = ref>({}); const completionTableIndex = new Map(); const completionObjectIndex = new Map(); const completionColumnIndex = new Map(); + const completionForeignKeyIndex = new Map(); const completionInFlight = new Map>(); const transferSource = ref<{ connectionId: string; database: string } | null>(null); const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null); @@ -516,6 +518,9 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of Object.keys(completionColumnsCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionColumnsCache.value[key]; } + for (const key of Object.keys(completionForeignKeysCache.value)) { + if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionForeignKeysCache.value[key]; + } for (const key of Object.keys(schemaListCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete schemaListCache.value[key]; } @@ -531,6 +536,9 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of completionColumnIndex.keys()) { if (key.startsWith(cachePrefix)) completionColumnIndex.delete(key); } + for (const key of completionForeignKeyIndex.keys()) { + if (key.startsWith(cachePrefix)) completionForeignKeyIndex.delete(key); + } for (const key of completionInFlight.keys()) { if (key.startsWith(cachePrefix)) completionInFlight.delete(key); } @@ -1368,6 +1376,10 @@ export const useConnectionStore = defineStore("connection", () => { } const querySchema = metadataQuerySchema(connectionId, database, schema); const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table); + const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`; + completionForeignKeysCache.value[cacheKey] = fkeys; + evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX); + indexCompletionForeignKeys(connectionId, database, table, schema, sqlCompletionForeignKeys(fkeys)); setChildren( node, fkeys.map((fk) => ({ @@ -1561,6 +1573,10 @@ export const useConnectionStore = defineStore("connection", () => { return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}`; } + function completionForeignKeysKey(connectionId: string, database: string, table: string, schema?: string): string { + return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}:fkeys`; + } + function touchCompletionIndex(index: Map, key: string, value: T, max = COMPLETION_CACHE_MAX) { index.set(key, { ...value, touched: Date.now() }); if (index.size <= max) return; @@ -1578,6 +1594,32 @@ export const useConnectionStore = defineStore("connection", () => { return promise; } + function completionNameSegments(name: string): string[] { + return name + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[\s_.:-]+/) + .map((segment) => segment.trim().toLowerCase()) + .filter(Boolean); + } + + function completionNameAcronym(name: string): string { + return completionNameSegments(name) + .map((segment) => segment[0]) + .join(""); + } + + function orderedSubsequenceScore(text: string, filter: string): number { + let index = 0; + let gaps = 0; + for (const ch of filter) { + const found = text.indexOf(ch, index); + if (found < 0) return -1; + gaps += found - index; + index = found + 1; + } + return 1_000 - gaps - text.length; + } + function tableMatchScore(table: SqlCompletionTable, filter: string, preferredSchema?: string): number { const text = table.name.toLowerCase(); const schema = table.schema?.toLowerCase(); @@ -1585,15 +1627,16 @@ export const useConnectionStore = defineStore("connection", () => { let score = schema && preferredSchema && schema === preferredSchema.toLowerCase() ? 10_000 : 0; if (!normalized) return score; if (text === normalized) return score + 9_000 - text.length; - if (text.startsWith(normalized)) return score + 7_000 - text.length; + if (text.startsWith(normalized)) return score + 7_500 - text.length; + const segments = completionNameSegments(table.name); + if (segments.some((segment) => segment.startsWith(normalized))) return score + 7_200 - text.length; + const acronym = completionNameAcronym(table.name); + if (acronym === normalized) return score + 7_100 - text.length; + if (acronym.startsWith(normalized)) return score + 6_900 - text.length; + if (normalized.length <= segments.length && segments.every((segment, index) => segment.startsWith(normalized[index] ?? ""))) return score + 6_700 - text.length; if (text.includes(normalized)) return score + 4_000 - text.length; - let index = 0; - for (const ch of normalized) { - index = text.indexOf(ch, index); - if (index < 0) return -1; - index++; - } - return score + 1_000 - text.length; + const subsequenceScore = orderedSubsequenceScore(text, normalized); + return subsequenceScore < 0 ? -1 : score + subsequenceScore; } function objectMatchScore(object: SqlCompletionObject, filter: string, preferredSchema?: string): number { @@ -1641,6 +1684,22 @@ export const useConnectionStore = defineStore("connection", () => { }); } + function sqlCompletionForeignKeys(foreignKeys: ForeignKeyInfo[]): SqlCompletionForeignKey[] { + return foreignKeys.map((foreignKey) => ({ + name: foreignKey.name, + column: foreignKey.column, + ref_schema: foreignKey.ref_schema, + ref_table: foreignKey.ref_table, + ref_column: foreignKey.ref_column, + })); + } + + function indexCompletionForeignKeys(connectionId: string, database: string, table: string, schema: string | undefined, foreignKeys: SqlCompletionForeignKey[]) { + touchCompletionIndex(completionForeignKeyIndex, completionForeignKeysKey(connectionId, database, table, schema), { + foreignKeys, + }); + } + 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; @@ -1687,6 +1746,10 @@ export const useConnectionStore = defineStore("connection", () => { return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema))?.columns ?? []; } + function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] { + return completionForeignKeyIndex.get(completionForeignKeysKey(connectionId, database, table, schema))?.foreignKeys ?? []; + } + function databaseNamesFromTree(connectionId: string): string[] { const node = findNode(treeNodes.value, connectionId); if (!node?.children) return []; @@ -1973,6 +2036,28 @@ export const useConnectionStore = defineStore("connection", () => { return columns; } + async function listCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise { + if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(getConfig(connectionId)) && !schema) { + return []; + } + const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId))); + if (!metadataCapabilities.foreignKeys) return []; + + const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`; + if (!completionForeignKeysCache.value[cacheKey]) { + await withCompletionInFlight(`${cacheKey}:fkeys`, async () => { + await ensureConnected(connectionId); + const querySchema = metadataQuerySchema(connectionId, database, schema); + completionForeignKeysCache.value[cacheKey] = await api.listForeignKeys(connectionId, database, querySchema, table); + evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX); + }); + } + + const foreignKeys = sqlCompletionForeignKeys(completionForeignKeysCache.value[cacheKey]); + indexCompletionForeignKeys(connectionId, database, table, schema, foreignKeys); + return foreignKeys; + } + function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise { return listCompletionTables(connectionId, database, filter, limit, schema); } @@ -1993,6 +2078,10 @@ export const useConnectionStore = defineStore("connection", () => { return listCompletionColumns(connectionId, database, table, schema); } + function refreshCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise { + return listCompletionForeignKeys(connectionId, database, table, schema); + } + function findNode(nodes: TreeNode[], id: string): TreeNode | null { for (const node of nodes) { if (node.id === id) return node; @@ -2485,16 +2574,19 @@ export const useConnectionStore = defineStore("connection", () => { listCompletionTables, listCompletionObjects, listCompletionColumns, + listCompletionForeignKeys, listCompletionSchemas, listCompletionDatabases, lookupLocalCompletionTables, lookupLocalCompletionObjects, lookupLocalCompletionColumns, + lookupLocalCompletionForeignKeys, lookupLocalCompletionSchemas, lookupLocalCompletionDatabases, refreshCompletionTables, refreshCompletionObjects, refreshCompletionColumns, + refreshCompletionForeignKeys, refreshCompletionSchemas, refreshCompletionDatabases, listElasticsearchCompletionIndices, diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 240bc3afa9..5e10e0cda0 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1318,6 +1318,38 @@ test("boosts foreign-key related table candidates in JOIN table context", () => assert.ok(items[0]?.detail?.includes("related by")); }); +test("boosts inbound foreign-key table candidates in JOIN table context", () => { + const foreignKeysByTable = new Map([["public.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "public", ref_table: "customers", ref_column: "id" }]]]); + const sql = "select * from public.customers c join ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + foreignKeysByTable, + }); + + assert.equal(items[0]?.label, "orders"); + assert.equal(items[0]?.type, "table"); + assert.ok(items[0]?.detail?.includes("orders.customer_id")); +}); + +test("uses owner schema when ranking inbound foreign-key table candidates", () => { + const foreignKeysByTable = new Map([["sales.orders", [{ name: "orders_customer_id_fkey", column: "customer_id", ref_schema: "crm", ref_table: "customers", ref_column: "id" }]]]); + const sql = "select * from crm.customers c join ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "orders", schema: "public", type: "table" }, + { name: "orders", schema: "sales", type: "table" }, + { name: "customers", schema: "crm", type: "table" }, + ], + columnsByTable, + foreignKeysByTable, + }); + + assert.equal(items[0]?.label, "orders"); + assert.equal(items[0]?.detail, "related by sales.orders.customer_id → id"); + assert.equal(items[0]?.apply, "orders"); +}); + test("suggests composite explicit foreign-key join conditions", () => { const colsWithCompositeFk = new Map([ [