diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 2d95ab8dc0..81031e3c7f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -334,7 +334,6 @@ const completionTranslations = computed(() => ({ const MAX_COMPLETION_TABLES = 200; const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2; const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20; -const MAX_JOIN_FK_PREFETCH_TABLES = 24; const MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES = 4; const liveFontSize = ref(settingsStore.editorSettings.fontSize); const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize); @@ -617,6 +616,7 @@ let cachedTables: SqlCompletionTable[] = []; const cachedCompletionObjectsByScope = new Map(); // Persistent column cache keyed by "schema.table" or "table" const cachedColumnsByTable = new Map(); +const cachedPrefixColumnsByTable = new Map(); const cachedInsertValueHintColumnsByTable = new Map(); const cachedForeignKeysByTable = new Map(); const loadedColumnsByTable = new Set(); @@ -650,10 +650,19 @@ function completionColumnRequestContext(reference?: Pick) { +async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick, prefix?: string) { const requestedVersion = props.completionContextVersion; const sessionScoped = usesOracleSessionCompletionColumns(schema); - const columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog); + let columns: SqlCompletionColumn[]; + if (prefix && prefix.length >= 2 && (props.databaseType === "postgres" || props.databaseType === "mysql")) { + try { + columns = await connectionStore.listCompletionColumnsByPrefix(connectionId, database, table, schema, prefix, catalog, completionColumnRequestContext(reference)); + } catch { + columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog); + } + } else { + columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog); + } if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context"); return columns; } @@ -2156,11 +2165,35 @@ function identifierRangeAt(sql: string, pos: number): { from: number; to: number type CompletionMetadataScope = Pick; -function completionCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }, scope?: CompletionMetadataScope) { +function completionCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope?: CompletionMetadataScope) { const schema = table.schema ?? scope?.schema ?? props.schema; const scopedDatabase = scope && scope.database !== props.database ? scope.database : undefined; const database = supportsDatabaseSchemaQualifierCompletion() ? (table.database ?? scopedDatabase) : undefined; - return schema ? `${database ? `${database}.` : ""}${schema}.${table.name}` : table.name; + const baseKey = schema ? `${database ? `${database}.` : ""}${schema}.${table.name}` : table.name; + if (props.databaseType !== "postgres" || (!table.nameQuoted && !table.schemaQuoted)) return baseKey; + return `${baseKey}:quoted:s=${table.schemaQuoted ? "1" : "0"}:t=${table.nameQuoted ? "1" : "0"}`; +} + +function completionPrefixCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope: CompletionMetadataScope | undefined, prefix: string) { + return `${completionCacheKey(table, scope)}:prefix:${prefix.trim().toLowerCase()}`; +} + +function lookupCachedPrefixColumns(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope: CompletionMetadataScope | undefined, prefix: string): SqlCompletionColumn[] | undefined { + const normalizedPrefix = prefix.trim().toLowerCase(); + const exactKey = completionPrefixCacheKey(table, scope, normalizedPrefix); + const exact = cachedPrefixColumnsByTable.get(exactKey); + if (exact) return exact; + + const marker = `${completionCacheKey(table, scope)}:prefix:`; + let best: { prefix: string; columns: SqlCompletionColumn[] } | undefined; + for (const [key, columns] of cachedPrefixColumnsByTable) { + if (!key.startsWith(marker)) continue; + const cachedPrefix = key.slice(marker.length); + if (!normalizedPrefix.startsWith(cachedPrefix) || (best && cachedPrefix.length <= best.prefix.length)) continue; + best = { prefix: cachedPrefix, columns }; + } + if (!best) return undefined; + return best.columns.filter((column) => column.name.toLowerCase().startsWith(normalizedPrefix)); } const pendingInsertValueHintColumnLoads = new Set(); @@ -3165,6 +3198,23 @@ function unregisterTableReferenceDropListener() { } let completionEpoch = 0; +let tableCompletionRefreshActive = false; +let latestTableCompletionRefresh: (() => Promise) | null = null; + +function queueTableCompletionRefresh(task: () => Promise): void { + latestTableCompletionRefresh = task; + if (tableCompletionRefreshActive) return; + tableCompletionRefreshActive = true; + void (async () => { + while (latestTableCompletionRefresh) { + const next = latestTableCompletionRefresh; + latestTableCompletionRefresh = null; + await next(); + } + })().finally(() => { + tableCompletionRefreshActive = false; + }); +} let completionDebounceTimer: ReturnType | null = null; let typedCompletionActivationUntil = 0; let suppressNextSqlCompletionAutoStartUntil = 0; @@ -3837,13 +3887,25 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType completionTablesMatch(table, qualifiedColumnTarget)); + const qualifiedCacheTable = { ...qualifiedColumnTarget, nameQuoted: reference?.nameQuoted, schemaQuoted: reference?.schemaQuoted }; + const cacheKey = completionCacheKey(qualifiedCacheTable, scope); + const cachedPrefix = completionContext.prefix.length >= 2 && (props.databaseType === "postgres" || props.databaseType === "mysql") ? lookupCachedPrefixColumns(qualifiedCacheTable, scope, completionContext.prefix) : undefined; + const cached = cachedPrefix ?? cachedColumnsByTable.get(cacheKey); if (cached) { columnsByTable.set(cacheKey, cached); } else { const target = completionMetadataTarget(qualifiedColumnTarget, scope); - const localColumns = target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema, target.catalog) : []; + const prefixColumns = + target && completionContext.prefix.length >= 2 && (props.databaseType === "postgres" || props.databaseType === "mysql") + ? connectionStore.lookupLocalCompletionColumnsByPrefix(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema, completionContext.prefix, target.catalog, completionColumnRequestContext(reference)) + : []; + const localColumns = + prefixColumns.length > 0 + ? prefixColumns + : target && !usesOracleSessionCompletionColumns(target.schema) + ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema, target.catalog, completionColumnRequestContext(reference)) + : []; if (localColumns.length > 0) { columnsByTable.set(cacheKey, localColumns); } @@ -3866,13 +3928,22 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType= 2 && + isReferencedTableQualifier(completionContext) && + (refTable.alias?.toLowerCase() === completionContext.qualifier.toLowerCase() || refTable.name.toLowerCase() === completionContext.qualifier.toLowerCase()) + ? completionContext.prefix + : undefined; + const cached = (prefixCompletion ? lookupCachedPrefixColumns(refTable, scope, prefixCompletion) : undefined) ?? cachedColumnsByTable.get(cacheKey); if (cached) { columnsByTable.set(cacheKey, cached); continue; } const target = completionMetadataTarget(refTable, scope); - const localColumns = target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema, target.catalog, refTable) : []; + const prefixColumns = target && prefixCompletion ? connectionStore.lookupLocalCompletionColumnsByPrefix(props.connectionId, target.database, refTable.name, target.schema, prefixCompletion, target.catalog, refTable) : []; + const localColumns = prefixColumns.length > 0 ? prefixColumns : target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema, target.catalog, refTable) : []; if (localColumns.length > 0) { columnsByTable.set(cacheKey, localColumns); } @@ -3938,16 +4009,21 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { + const refreshEpoch = completionEpoch; + queueTableCompletionRefresh(async () => { + if (refreshEpoch !== completionEpoch) return; + try { + const tables = await connectionStore.refreshCompletionTables(connectionId, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch, scope.schema, props.catalog); + if (refreshEpoch !== completionEpoch) return; const scopedTables = tables.map((table) => ({ ...table, database: table.database ?? tableLookupTarget.database })); cachedTables = mergeCompletionTables(cachedTables, scopedTables); - if (completionContext.suggestTables && completionContext.referencedTables.length > 0) { - void ensureForeignKeysForTables([...completionContext.referencedTables, ...scopedTables.slice(0, MAX_JOIN_FK_PREFETCH_TABLES)]); + if (completionContext.suggestJoinConditions && completionContext.referencedTables.length > 0) { + void ensureForeignKeysForTables(completionContext.referencedTables); } - }) - .catch(() => {}); + } catch { + // Local candidates remain available when the remote refresh fails. + } + }); } if (!localOnlyMetadata && shouldLoadCompletionObjects(completionContext)) { const completionObjectScope = routineCompletionScopeForContext(completionContext, scope); @@ -4009,7 +4085,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType {}); } } - if (!tableNameCompletion && (completionContext.suggestTables || completionContext.suggestJoinConditions) && completionContext.referencedTables.length > 0) { + if (!tableNameCompletion && completionContext.suggestJoinConditions && completionContext.referencedTables.length > 0) { void ensureForeignKeysForTables(completionContext.referencedTables); } } @@ -4278,20 +4354,27 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext const tableNameCompletion = isTableNameCompletionContext(completionContext); const shouldFetchColumnsForCompletion = !tableNameCompletion && (!onDemandOnlyColumns || completionContext.suggestColumns || completionContext.exclusiveColumnSuggestions || !!completionContext.insertTable); + const hasQualifiedColumnPrefix = (props.databaseType === "postgres" || props.databaseType === "mysql") && completionContext.qualifier && completionContext.prefix.length >= 2 && isReferencedTableQualifier(completionContext); + const columnRefs = hasQualifiedColumnPrefix + ? refs.filter((refTable) => refTable.alias?.toLowerCase() === completionContext.qualifier!.toLowerCase() || refTable.name.toLowerCase() === completionContext.qualifier!.toLowerCase() || (!!qualifiedColumnTarget && completionTablesMatch(refTable, qualifiedColumnTarget))) + : refs.slice(0, 4); if (shouldFetchColumnsForCompletion) { await Promise.all( - refs.map(async (refTable) => { + columnRefs.map(async (refTable) => { if (isVirtualCompletionTableReference(refTable)) return; if (refTable.columns && refTable.columns.length > 0) return; const cacheKey = completionCacheKey(refTable, scope); - if (cachedColumnsByTable.has(cacheKey)) return; + const prefixCompletion = hasQualifiedColumnPrefix ? completionContext.prefix : undefined; + const prefixCacheKey = prefixCompletion ? completionPrefixCacheKey(refTable, scope, prefixCompletion) : undefined; + if (prefixCompletion ? lookupCachedPrefixColumns(refTable, scope, prefixCompletion) : cachedColumnsByTable.has(cacheKey)) return; try { const target = completionMetadataTarget(refTable, scope); if (!target) return; - const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog, refTable); + const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog, refTable, prefixCompletion); if (epoch !== completionEpoch) return; if (columns.length === 0) return; - cachedColumnsByTable.set(cacheKey, columns); + if (prefixCacheKey) cachedPrefixColumnsByTable.set(prefixCacheKey, columns); + else cachedColumnsByTable.set(cacheKey, columns); } catch (e) { console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e); } @@ -4300,9 +4383,8 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext } if (epoch !== completionEpoch) return null; - if (!tableNameCompletion && (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 (!tableNameCompletion && completionContext.suggestJoinConditions && refs.length > 0) { + await ensureForeignKeysForTables(refs.filter((table) => !("columns" in table) || !table.columns || table.columns.length === 0)); if (epoch !== completionEpoch) return null; } @@ -4315,6 +4397,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext } } else { for (const refTable of refs) { + if (hasQualifiedColumnPrefix && !columnRefs.includes(refTable)) continue; if (refTable.columns && refTable.columns.length > 0) { const key = refTable.name; columnsByTable.set( @@ -4328,7 +4411,15 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext continue; } const cacheKey = completionCacheKey(refTable, scope); - const cached = cachedColumnsByTable.get(cacheKey); + const prefixCompletion = + (props.databaseType === "postgres" || props.databaseType === "mysql") && + completionContext.qualifier && + completionContext.prefix.length >= 2 && + isReferencedTableQualifier(completionContext) && + (refTable.alias?.toLowerCase() === completionContext.qualifier.toLowerCase() || refTable.name.toLowerCase() === completionContext.qualifier.toLowerCase()) + ? completionContext.prefix + : undefined; + const cached = (prefixCompletion ? lookupCachedPrefixColumns(refTable, scope, prefixCompletion) : undefined) ?? cachedColumnsByTable.get(cacheKey); if (cached) { columnsByTable.set(cacheKey, cached); } @@ -4446,6 +4537,7 @@ function refreshCompletionCache() { cachedTables = []; cachedCompletionObjectsByScope.clear(); cachedColumnsByTable.clear(); + cachedPrefixColumnsByTable.clear(); cachedInsertValueHintColumnsByTable.clear(); loadedColumnsByTable.clear(); cachedForeignKeysByTable.clear(); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorColumnPrefixCache.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorColumnPrefixCache.spec.ts new file mode 100644 index 0000000000..e084c56cb3 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorColumnPrefixCache.spec.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +describe("QueryEditor column prefix cache", () => { + it("hydrates qualified completions from the persistent prefix cache", () => { + const prefixLookups = queryEditorSource.match(/lookupLocalCompletionColumnsByPrefix/g) ?? []; + + expect(prefixLookups.length).toBeGreaterThanOrEqual(2); + expect(queryEditorSource).toContain("completionColumnRequestContext(reference)"); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index b27275db7b..a458174717 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1,4 +1,5 @@ import { Cassandra, MariaSQL, MSSQL, MySQL, PLSQL, PostgreSQL, SQLite, StandardSQL } from "@codemirror/lang-sql"; +import type { Completion, CompletionInfo } from "@codemirror/autocomplete"; import type { DatabaseType, SqlSnippet } from "@/types/database"; import { buildMongoCompletionItemsFromContext, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; import { CLOUDFLARE_D1_COMMON_FUNCTION_NAMES } from "@/lib/sql/cloudflareD1"; @@ -1279,7 +1280,7 @@ export interface SqlCompletionItem { filterText?: string; type: "keyword" | "table" | "column" | "snippet" | "function" | "schema" | "variable" | "text"; detail?: string; - info?: string; + info?: string | ((completion: Completion) => CompletionInfo | Promise); apply?: string; replaceClosingQuote?: '"' | "'"; boost: number; @@ -3756,6 +3757,8 @@ function buildSelectAllColumnItems(context: SqlCompletionContext, columnsByTable function buildInsertAllColumnItems(context: SqlCompletionContext, columnsByTable: Map, t?: SqlCompletionTranslations, dialect?: SqlCompletionApplyDialect, keywordCase?: SqlKeywordCase): SqlCompletionItem[] { if (!context.insertTable) return []; + // The INSERT column prefix controls the replacement/filter range, but the + // all-column snippet must always expand the complete target table. const columns = uniqueColumnsByName(columnsForInsertTarget(context, columnsByTable)); if (columns.length === 0) return []; @@ -3844,7 +3847,7 @@ function buildComparisonValueItems(context: SqlCompletionContext, columnsByTable // Find the column's data type let dataType: string | undefined; for (const [, cols] of columnsByTable) { - for (const col of cols) { + for (const col of completionColumnPrefixCandidates(cols, unqualified, 256)) { if (col.name.toLowerCase() === unqualified.toLowerCase()) { if (qualifier) { const qualLower = qualifier.toLowerCase(); @@ -4090,10 +4093,63 @@ function activeQueryBlockSql(sql: string): string { return activeSelectIndex == null ? sql : sql.slice(activeSelectIndex); } -function collectCompletionColumns(columnsByTable: Map): Array { +interface CompletionColumnSearchIndex { + entries: Array<{ normalizedName: string; index: number }>; +} + +const completionColumnSearchIndexes = new WeakMap(); + +function completionColumnSearchIndex(columns: readonly SqlCompletionColumn[]): CompletionColumnSearchIndex { + const cached = completionColumnSearchIndexes.get(columns); + if (cached) return cached; + const index: CompletionColumnSearchIndex = { + entries: columns.map((column, index) => ({ normalizedName: column.name.trim().toLowerCase(), index })).sort((left, right) => left.normalizedName.localeCompare(right.normalizedName) || left.index - right.index), + }; + completionColumnSearchIndexes.set(columns, index); + return index; +} + +function completionColumnPrefixCandidates(columns: readonly SqlCompletionColumn[], prefix: string, limit = 256): SqlCompletionColumn[] { + if (!prefix) return [...columns]; + const normalizedPrefix = prefix.trim().toLowerCase(); + if (!normalizedPrefix) return [...columns]; + const index = completionColumnSearchIndex(columns).entries; + let low = 0; + let high = index.length; + while (low < high) { + const middle = (low + high) >>> 1; + if ((index[middle]?.normalizedName ?? "") < normalizedPrefix) low = middle + 1; + else high = middle; + } + const candidates: SqlCompletionColumn[] = []; + const seen = new Set(); + for (let position = low; position < index.length && candidates.length < limit; position += 1) { + const entry = index[position]; + if (!entry || !entry.normalizedName.startsWith(normalizedPrefix)) break; + const column = columns[entry.index]; + if (column) { + candidates.push(column); + seen.add(entry.index); + } + } + // Preserve fuzzy/substring matching when the prefix index has not filled + // the bounded candidate pool, while avoiding a second scan for the common + // case where a prefix already has enough results. + if (candidates.length < limit) { + for (let indexPosition = 0; indexPosition < columns.length && candidates.length < limit; indexPosition += 1) { + if (seen.has(indexPosition)) continue; + const column = columns[indexPosition]; + if (!column || !matchesIdentifierSearch(column.name, prefix)) continue; + candidates.push(column); + } + } + return candidates; +} + +function collectCompletionColumns(columnsByTable: Map, prefix = ""): Array { const allColumns: Array = []; for (const [key, cols] of columnsByTable.entries()) { - for (const col of cols) { + for (const col of completionColumnPrefixCandidates(cols, prefix)) { allColumns.push({ ...col, key }); } } @@ -4115,8 +4171,10 @@ function columnsForInsertTarget(context: SqlCompletionContext, columnsByTable: M } function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map, dialect?: SqlCompletionApplyDialect): SqlCompletionItem[] { - // Collect all columns from the map (all tables have been fetched) - const allColumns = collectCompletionColumns(columnsByTable); + // Build a bounded candidate pool before doing duplicate detection, ranking, + // and completion object materialization. Canonical column arrays remain the + // source of truth; the per-array prefix index is only a derived accelerator. + const allColumns = collectCompletionColumns(columnsByTable, context.prefix); // Handle INSERT column list: filter to only the target table let relevantCols = allColumns; @@ -4168,22 +4226,28 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map 0 || !!context.qualifier || !!context.insertTable ? 2000 : 0; - return uniqueColumns - .filter((column) => matchesIdentifierSearch(column.name, context.prefix) || matchesIdentifierSearch(column.displayLabel, context.prefix)) - .map((column) => { + const rankedColumns = uniqueColumns + .map((column, index) => ({ column, index })) + .filter(({ column }) => matchesIdentifierSearch(column.name, context.prefix) || matchesIdentifierSearch(column.displayLabel, context.prefix)) + .map(({ column, index }) => { const keyBoost = isKeyColumn(column.name) ? 500 : 0; const matchScore = Math.max(identifierMatchScore(column.name, context.prefix), identifierMatchScore(column.displayLabel, context.prefix)); - return { - label: column.displayLabel, - filterText: column.displayLabel === column.name ? undefined : column.name, - type: "column" as const, - detail: buildColumnDetail(column), - info: buildColumnInfo(column), - apply: buildColumnApply(column, context, dialect), - boost: matchScore + keyBoost + relevanceBoost, - }; + return { column, index, boost: matchScore + keyBoost + relevanceBoost }; }) - .sort(compareCompletionItems); + .sort((left, right) => right.boost - left.boost || left.index - right.index) + .slice(0, context.insertTable || !context.prefix ? 50 : context.qualifier ? 30 : 20); + + return rankedColumns.map(({ column, boost }) => { + return { + label: column.displayLabel, + filterText: column.displayLabel === column.name ? undefined : column.name, + type: "column" as const, + detail: buildColumnDetail(column), + info: buildColumnInfo(column), + apply: buildColumnApply(column, context, dialect), + boost, + }; + }); } function completionColumnsForReferencedTable(table: SqlCompletionReferencedTable, columns: readonly T[]): T[] { @@ -4203,7 +4267,7 @@ function applyReferencedColumnAliases(table: SqlC function hasMatchingReferencedColumnPrefix(context: SqlCompletionContext, columnsByTable: Map): boolean { if (!context.suggestColumns || !context.prefix || context.referencedTables.length === 0) return false; - return context.referencedTables.some((table) => columnsForReferencedTable(table, columnsByTable).some((column) => matchesIdentifierSearch(column.name, context.prefix))); + return context.referencedTables.some((table) => completionColumnPrefixCandidates(columnsForReferencedTable(table, columnsByTable), context.prefix, 1).length > 0); } function qualifiedTableTargetFromContext(context: SqlCompletionContext): { database?: string; schema: string; table: string } | null { @@ -4265,21 +4329,15 @@ function buildColumnDetail(column: SqlCompletionColumn): string { if (column.isNullable === false) { detail += " NOT NULL"; } - const comment = column.comment?.trim(); - if (comment) { - detail += ` -- ${comment}`; - } return detail; } function buildColumnInfo(column: SqlCompletionColumn): string | undefined { - const parts = [ - column.schema ? `${column.schema}.${column.table}.${column.name}` : `${column.table}.${column.name}`, - column.dataType ? `Type: ${column.dataType}` : undefined, - column.isNullable === false ? "Nullable: no" : column.isNullable === true ? "Nullable: yes" : undefined, - column.comment?.trim() ? `Comment: ${column.comment.trim()}` : undefined, - ].filter((part): part is string => !!part); - return parts.length > 1 ? parts.join("\n") : undefined; + const hasDetails = !!column.dataType || column.isNullable !== undefined || !!column.comment?.trim(); + if (!hasDetails) return undefined; + const title = column.schema ? `${column.schema}.${column.table}.${column.name}` : `${column.table}.${column.name}`; + const details = [column.dataType ? `Type: ${column.dataType}` : undefined, column.isNullable === false ? "Nullable: no" : column.isNullable === true ? "Nullable: yes" : undefined, column.comment?.trim() ? `Comment: ${column.comment.trim()}` : undefined].filter((part): part is string => !!part); + return [title, ...details].join("\n"); } function buildJoinConditionItems(context: SqlCompletionContext, columnsByTable: Map, foreignKeysByTable?: Map, dialect?: SqlCompletionApplyDialect, keywordCase?: SqlKeywordCase): SqlCompletionItem[] { @@ -4786,7 +4844,7 @@ function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByT const items: SqlCompletionItem[] = []; for (const [, cols] of columnsByTable) { - for (const col of cols) { + for (const col of completionColumnPrefixCandidates(cols, context.prefix, 256)) { const key = col.name.toLowerCase(); if (!nonAggSet.has(key) || seen.has(key)) continue; if (context.prefix && !matchesIdentifierSearch(col.name, context.prefix)) continue; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 8127a3ac57..253f69d9e5 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -582,6 +582,78 @@ describe("connectionStore completion assistant", () => { ]); }); + it("keeps PostgreSQL quoted column caches separate from unquoted identifiers", async () => { + const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null; parent_schema?: string | null }) => ({ + candidates: [ + { + name: request.parent_name === "Orders" ? "QuotedId" : "lower_id", + kind: "column", + parent_schema: request.parent_schema, + parent_name: request.parent_name, + data_type: "integer", + }, + ], + incomplete: false, + fallback_used: false, + })); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns: vi.fn(), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [postgresConnection()]; + store.connectedIds.add("pg-1"); + + const unquoted = await store.listCompletionColumns("pg-1", "app", "orders", "public", { tableQuoted: false, schemaQuoted: false }); + const quoted = await store.listCompletionColumns("pg-1", "app", "Orders", "Public", { tableQuoted: true, schemaQuoted: true }); + + expect(completionAssistantSearch).toHaveBeenCalledTimes(2); + expect(unquoted.map((column) => column.name)).toEqual(["lower_id"]); + expect(quoted.map((column) => column.name)).toEqual(["QuotedId"]); + expect(store.lookupLocalCompletionColumns("pg-1", "app", "orders", "public", undefined, { tableQuoted: false, schemaQuoted: false }).map((column) => column.name)).toEqual(["lower_id"]); + expect(store.lookupLocalCompletionColumns("pg-1", "app", "Orders", "Public", undefined, { tableQuoted: true, schemaQuoted: true }).map((column) => column.name)).toEqual(["QuotedId"]); + }); + + it("reads PostgreSQL prefix columns back with the same quoted context", async () => { + const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null; parent_schema?: string | null }) => ({ + candidates: (request.parent_name === "Orders" ? ["QuotedName"] : ["name", "native"]).map((name) => ({ + name, + kind: "column", + parent_schema: request.parent_schema, + parent_name: request.parent_name, + data_type: "text", + })), + incomplete: false, + fallback_used: false, + })); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [postgresConnection()]; + store.connectedIds.add("pg-1"); + + await store.listCompletionColumnsByPrefix("pg-1", "app", "orders", "public", "na", undefined, { tableQuoted: false, schemaQuoted: false }); + const narrowed = await store.listCompletionColumnsByPrefix("pg-1", "app", "orders", "public", "nam", undefined, { tableQuoted: false, schemaQuoted: false }); + await store.listCompletionColumnsByPrefix("pg-1", "app", "Orders", "Public", "Na", undefined, { tableQuoted: true, schemaQuoted: true }); + + expect(completionAssistantSearch).toHaveBeenCalledTimes(2); + expect(narrowed.map((column) => column.name)).toEqual(["name"]); + expect(store.lookupLocalCompletionColumnsByPrefix("pg-1", "app", "orders", "public", "na", undefined, { tableQuoted: false, schemaQuoted: false }).map((column) => column.name)).toEqual(["name", "native"]); + expect(store.lookupLocalCompletionColumnsByPrefix("pg-1", "app", "orders", "public", "nam", undefined, { tableQuoted: false, schemaQuoted: false }).map((column) => column.name)).toEqual(["name"]); + expect(store.lookupLocalCompletionColumnsByPrefix("pg-1", "app", "Orders", "Public", "Na", undefined, { tableQuoted: true, schemaQuoted: true }).map((column) => column.name)).toEqual(["QuotedName"]); + }); + it("normalizes unquoted Oracle aliases while preserving quoted case before catalog lookup", async () => { const completionAssistantSearch = vi.fn(); const getColumns = vi.fn().mockResolvedValue([]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index f640cf6e41..2c141bd398 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -459,6 +459,7 @@ export const useConnectionStore = defineStore("connection", () => { const completionTableIndex = new Map(); const completionObjectIndex = new Map(); const completionColumnIndex = new Map(); + const completionColumnPrefixIndex = new Map(); const completionForeignKeyIndex = new Map(); const completionInFlight = new Map>(); const completionCacheRevisions = ref>({}); @@ -2903,6 +2904,9 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of completionColumnIndex.keys()) { if (key.startsWith(cachePrefix)) completionColumnIndex.delete(key); } + for (const key of completionColumnPrefixIndex.keys()) { + if (key.startsWith(cachePrefix)) completionColumnPrefixIndex.delete(key); + } for (const key of completionForeignKeyIndex.keys()) { if (key.startsWith(cachePrefix)) completionForeignKeyIndex.delete(key); } @@ -6360,12 +6364,21 @@ export const useConnectionStore = defineStore("connection", () => { } function completionColumnsKey(connectionId: string, database: string, table: string, schema?: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): string { - if (getConfig(connectionId)?.db_type === "oracle") { + const databaseType = getConfig(connectionId)?.db_type; + if (databaseType === "oracle") { const normalizedTable = context?.tableQuoted === false ? table.toUpperCase() : table; const normalizedSchema = schema && context?.schemaQuoted === false ? schema.toUpperCase() : (schema ?? ""); return `${connectionId}:${database}:${catalog ?? ""}:${normalizedSchema}:${normalizedTable}`; } - return `${completionTableScopeKey(connectionId, database, schema, catalog)}:${table.toLowerCase()}`; + const baseKey = `${completionTableScopeKey(connectionId, database, schema, catalog)}:${table.toLowerCase()}`; + if (databaseType !== "postgres" || (!context?.tableQuoted && !context?.schemaQuoted)) return baseKey; + const quotedSchema = context.schemaQuoted ? encodeURIComponent(schema ?? "") : ""; + const quotedTable = context.tableQuoted ? encodeURIComponent(table) : ""; + return `${baseKey}:quoted:s=${quotedSchema}:t=${quotedTable}`; + } + + function completionColumnPrefixKey(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): string { + return `${completionColumnsKey(connectionId, database, table, schema, catalog, context)}:prefix:${prefix.trim().toLowerCase()}`; } function completionForeignKeysKey(connectionId: string, database: string, table: string, schema?: string): string { @@ -6410,6 +6423,13 @@ export const useConnectionStore = defineStore("connection", () => { removed++; } } + for (const key of completionColumnPrefixIndex.keys()) { + const prefixMarker = key.toLowerCase().lastIndexOf(":prefix:"); + const baseKey = prefixMarker >= 0 ? key.slice(0, prefixMarker) : key; + if (!matches(baseKey)) continue; + completionColumnPrefixIndex.delete(key); + removed++; + } return removed; } @@ -6774,7 +6794,7 @@ export const useConnectionStore = defineStore("connection", () => { requestRevision, ); const columns = completionAssistantColumns(response.candidates, table, schema, context); - if (columns.length > 0 && requestRevision === completionCacheRevision(connectionId, database)) indexCompletionColumns(connectionId, database, table, schema, columns); + if (columns.length > 0 && requestRevision === completionCacheRevision(connectionId, database)) indexCompletionColumns(connectionId, database, table, schema, columns, undefined, context); return columns; } @@ -6872,12 +6892,16 @@ export const useConnectionStore = defineStore("connection", () => { } } - function indexCompletionColumns(connectionId: string, database: string, table: string, schema: string | undefined, columns: SqlCompletionColumn[], catalog?: string) { - touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema, catalog), { + function indexCompletionColumns(connectionId: string, database: string, table: string, schema: string | undefined, columns: SqlCompletionColumn[], catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }) { + touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema, catalog, context), { columns, }); } + function indexCompletionColumnPrefix(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, columns: SqlCompletionColumn[], complete: boolean, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }) { + touchCompletionIndex(completionColumnPrefixIndex, completionColumnPrefixKey(connectionId, database, table, schema, prefix, catalog, context), { columns, complete }); + } + function sqlCompletionForeignKeys(foreignKeys: ForeignKeyInfo[]): SqlCompletionForeignKey[] { return foreignKeys.map((foreignKey) => ({ name: foreignKey.name, @@ -6957,10 +6981,75 @@ export const useConnectionStore = defineStore("connection", () => { return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema, catalog, context))?.columns ?? []; } + function completionColumnPrefixEntry(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): { columns: SqlCompletionColumn[]; complete: boolean } | undefined { + const normalizedPrefix = prefix.trim().toLowerCase(); + const exact = completionColumnPrefixIndex.get(completionColumnPrefixKey(connectionId, database, table, schema, normalizedPrefix, catalog, context)); + if (exact) return exact; + + const marker = `${completionColumnsKey(connectionId, database, table, schema, catalog, context)}:prefix:`; + let reusable: { prefix: string; columns: SqlCompletionColumn[] } | undefined; + for (const [key, entry] of completionColumnPrefixIndex) { + if (!entry.complete || !key.startsWith(marker)) continue; + const cachedPrefix = key.slice(marker.length); + if (!normalizedPrefix.startsWith(cachedPrefix) || (reusable && cachedPrefix.length <= reusable.prefix.length)) continue; + reusable = { prefix: cachedPrefix, columns: entry.columns }; + } + if (!reusable) return undefined; + return { + columns: reusable.columns.filter((column) => column.name.toLowerCase().startsWith(normalizedPrefix)), + complete: true, + }; + } + + function lookupLocalCompletionColumnsByPrefix(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] { + return completionColumnPrefixEntry(connectionId, database, table, schema, prefix, catalog, context)?.columns ?? []; + } + function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] { return completionForeignKeyIndex.get(completionForeignKeysKey(connectionId, database, table, schema))?.foreignKeys ?? []; } + async function listCompletionColumnsByPrefix(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): Promise { + const normalizedPrefix = prefix.trim(); + if (normalizedPrefix.length < 2) return []; + const databaseType = effectiveDatabaseTypeForConnection(getConfig(connectionId)); + if (databaseType !== "postgres" && databaseType !== "mysql") return []; + const completionTable = table; + const completionSchema = schema?.trim() || (databaseType === "mysql" ? database : undefined); + const cacheKey = completionColumnPrefixKey(connectionId, database, completionTable, completionSchema, normalizedPrefix, catalog, context); + const cached = completionColumnPrefixEntry(connectionId, database, completionTable, completionSchema, normalizedPrefix, catalog, context); + if (cached) return cached.columns; + const requestRevision = completionCacheRevision(connectionId, database); + return withCompletionInFlight( + `column-prefix:${requestRevision}:${cacheKey}`, + async () => { + const existing = completionColumnPrefixIndex.get(cacheKey); + if (existing) return existing.columns; + await ensureConnected(connectionId); + const response = await completionAssistantSearch( + { + connection_id: connectionId, + database, + schema: completionSchema ?? null, + object_kinds: ["column"], + mask: normalizedPrefix, + max_results: 128, + parent_schema: completionSchema ?? null, + parent_name: completionTable, + match_mode: "prefix", + }, + requestRevision, + ); + const columns = completionAssistantColumns(response.candidates, completionTable, completionSchema, context); + if (requestRevision === completionCacheRevision(connectionId, database)) { + indexCompletionColumnPrefix(connectionId, database, completionTable, completionSchema, normalizedPrefix, columns, !response.incomplete, catalog, context); + } + return columns; + }, + { scope: completionLimiterScope(connectionId, database), kind: "column-prefix" }, + ); + } + function databaseNamesFromTree(connectionId: string): string[] { const node = findConnectionNode(connectionId); if (!node?.children) return []; @@ -7402,13 +7491,14 @@ export const useConnectionStore = defineStore("connection", () => { return []; } const sessionCacheScope = usesOracleCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : ""; - const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${completionSchema || ""}:${completionTable}${sessionCacheScope}`; + const cacheKey = `${completionColumnsKey(connectionId, database, completionTable, completionSchema, catalog, context)}${sessionCacheScope}`; if (!completionColumnsCache.value[cacheKey]) { const requestRevision = completionCacheRevision(connectionId, database); await withCompletionInFlight( `${cacheKey}:columns`, async () => { await ensureConnected(connectionId); + // Use assistant metadata opportunistically, then fall back to canonical metadata. if (!usesOracleCurrentSchema && !catalog) { try { const assistantColumns = await listCompletionAssistantColumns(connectionId, database, completionTable, completionSchema, context, requestRevision); @@ -7456,7 +7546,7 @@ export const useConnectionStore = defineStore("connection", () => { isNullable: column.is_nullable, comment: column.comment, })); - if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, completionTable, completionSchema, columns, catalog); + if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, completionTable, completionSchema, columns, catalog, context); return columns; } @@ -8396,6 +8486,7 @@ export const useConnectionStore = defineStore("connection", () => { listCompletionTables, listCompletionObjects, listCompletionColumns, + listCompletionColumnsByPrefix, listCompletionForeignKeys, listCompletionSchemas, listCompletionDatabases, @@ -8403,6 +8494,7 @@ export const useConnectionStore = defineStore("connection", () => { lookupLocalCompletionTables, lookupLocalCompletionObjects, lookupLocalCompletionColumns, + lookupLocalCompletionColumnsByPrefix, lookupLocalCompletionForeignKeys, lookupLocalCompletionSchemas, lookupLocalCompletionDatabases, diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index f80670a4ed..b6e154632b 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1224,7 +1224,7 @@ test("shows column comments in WHERE field completions", () => { }); const column = items.find((item) => item.type === "column" && item.label === "status"); - assert.equal(column?.detail, "public.orders [varchar] NOT NULL -- Order lifecycle state"); + assert.equal(column?.detail, "public.orders [varchar] NOT NULL"); assert.equal(column?.info, "public.orders.status\nType: varchar\nNullable: no\nComment: Order lifecycle state"); });