diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 847f667756..7ab8b7ea2a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -716,6 +716,36 @@ async function formatCurrentSql() { } let completionEpoch = 0; +let completionDebounceTimer: ReturnType | null = null; + +function buildCompletionResult( + items: ReturnType, + position: number, + prefixLength: number, + fullDoc: string, +) { + if (items.length === 0) return null; + return { + from: position - prefixLength, + filter: false, + options: items.map((item) => + (item.type === "snippet" || item.type === "function") && item.apply + ? codeMirrorSnippetCompletion(item.apply, { + label: item.label, + type: item.type, + detail: item.detail, + boost: item.boost, + }) + : { + label: item.label, + type: item.type, + detail: item.detail, + boost: item.boost, + }, + ), + validFor: getSqlCompletionResultValidFor(fullDoc, position), + }; +} async function provideSqlCompletions( currentState: import("@codemirror/state").EditorState, @@ -741,236 +771,241 @@ async function provideSqlCompletions( translations: completionTranslations.value, snippets: settingsStore.editorSettings.snippets, }); - if (items.length === 0) return null; - return { - from: position - completionContext.prefix.length, - filter: false, - options: items.map((item) => - (item.type === "snippet" || item.type === "function") && item.apply - ? codeMirrorSnippetCompletion(item.apply, { - label: item.label, - type: item.type, - detail: item.detail, - boost: item.boost, - }) - : { - label: item.label, - type: item.type, - detail: item.detail, - boost: item.boost, - }, - ), - validFor: getSqlCompletionResultValidFor(fullDoc, position), - }; + return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc); } - // Handle INSERT column list: fetch columns for the target table - let insertColumnsByTable = new Map(); - if (completionContext.insertTable) { - try { - const insertCols = await connectionStore.listCompletionColumns( - props.connectionId, - props.database, - completionContext.insertTable, - completionContext.insertSchema, - ); - if (epoch !== completionEpoch) return null; - if (insertCols.length > 0) { - const insertKey = completionContext.insertSchema - ? `${completionContext.insertSchema}.${completionContext.insertTable}` - : completionContext.insertTable; - insertColumnsByTable.set(insertKey, insertCols); - } - } catch { - // ignore - } - } + const needsAsyncData = + completionContext.suggestTables || + !!completionContext.qualifier || + !!completionContext.insertTable || + completionContext.exclusiveColumnSuggestions || + completionContext.referencedTables.length > 0; - const shouldLoadTables = completionContext.suggestTables || !!completionContext.qualifier; - let tables = shouldLoadTables - ? await connectionStore.listCompletionTables( - props.connectionId, - props.database, - completionContext.qualifier || completionContext.prefix, - MAX_COMPLETION_TABLES, - ) - : cachedTables; - if (epoch !== completionEpoch) return null; - - // Fetch schemas for schema completion (only in table-suggesting context without qualifier) - let schemaNames: string[] = []; - if (completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) { - try { - schemaNames = await api.listSchemas(props.connectionId, props.database); - if (epoch !== completionEpoch) return null; - } catch { - // ignore - } - } - - // If qualifier didn't match any table names, try it as a schema name - let qualifierIsSchema = false; - if ( - completionContext.qualifier && - tables.length === 0 && - (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions) - ) { - const schemaTables = await connectionStore.listCompletionTables( - props.connectionId, - props.database, - completionContext.prefix, - MAX_COMPLETION_TABLES, - completionContext.qualifier, - ); - if (schemaTables.length > 0) { - tables = schemaTables; - qualifierIsSchema = true; - } - if (epoch !== completionEpoch) return null; - } - - // Collect referenced tables — enrich with schema from filtered table lookup - let refs = completionContext.referencedTables.map((rt) => { - if (!rt.schema) { - const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase()); - if (cached && cached.schema) { - return { ...rt, schema: cached.schema }; - } - } - return rt; - }); - const unresolvedRefs = refs.filter((rt) => !rt.schema && !rt.columns); - if (unresolvedRefs.length > 0) { - const lookupGroups = await Promise.all( - unresolvedRefs.map((rt) => - connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20), - ), - ); - if (epoch !== completionEpoch) return null; - const lookupTables = lookupGroups.flat(); - refs = refs.map((rt) => { - if (rt.schema || rt.columns) return rt; - const matched = lookupTables.find((table) => table.name.toLowerCase() === rt.name.toLowerCase()); - return matched?.schema ? { ...rt, schema: matched.schema } : rt; + if (!needsAsyncData) { + const items = buildSqlCompletionItemsFromContext(completionContext, { + tables: [], + columnsByTable: new Map(), + schemas: [], + translations: completionTranslations.value, + snippets: settingsStore.editorSettings.snippets, }); + return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc); } - // If no referenced tables but qualifier exists, infer table from tables list - if (refs.length === 0 && completionContext.qualifier) { - const q = completionContext.qualifier.toLowerCase(); - const matched = tables.filter((t) => t.name.toLowerCase() === q || t.name.toLowerCase().endsWith("." + q)); - refs = matched.map((t) => ({ name: t.name, schema: t.schema })); + // Cancel any pending debounced completion + if (completionDebounceTimer) { + clearTimeout(completionDebounceTimer); + completionDebounceTimer = null; } - // Populate CTE columns from parsed definitions (no backend call needed) - const cteDefs = extractCteDefinitions(fullDoc); - for (const refTable of refs) { - if (refTable.columns) continue; // Already has columns from CTE parsing - const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase()); - if (cteDef) { - refTable.columns = cteDef.columns; - } - } - - await Promise.all( - refs.map(async (refTable) => { - // Skip backend fetch if columns already provided by CTE parsing - if (refTable.columns && refTable.columns.length > 0) return; - const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; - if (cachedColumnsByTable.has(cacheKey)) { + // Debounce the full async flow and return the promise to CodeMirror. + // This prevents wasted backend calls during rapid typing while still + // showing table/column names in the first popup. + return new Promise>((resolve) => { + completionDebounceTimer = setTimeout(async () => { + completionDebounceTimer = null; + if (epoch !== completionEpoch) { + resolve(null); return; } try { - const columns = await connectionStore.listCompletionColumns( - props.connectionId!, - props.database!, - refTable.name, - refTable.schema, - ); - if (epoch !== completionEpoch) return; - if (columns.length === 0) return; - cachedColumnsByTable.set(cacheKey, columns); - } catch (e) { - console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e); + const result = await performAsyncCompletionWithResult(epoch, completionContext, fullDoc, position); + resolve(result); + } catch { + resolve(null); } - }), - ); - if (epoch !== completionEpoch) return null; - - // Build columnsByTable — from cache or CTE definitions - const columnsByTable = new Map(); - if (insertColumnsByTable.size > 0) { - for (const [key, cols] of insertColumnsByTable.entries()) { - columnsByTable.set(key, cols); - } - } else { - for (const refTable of refs) { - // Use CTE columns if available - if (refTable.columns && refTable.columns.length > 0) { - const key = refTable.name; - columnsByTable.set( - key, - refTable.columns.map((name) => ({ - name, - table: refTable.name, - dataType: undefined, - })), - ); - continue; - } - const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; - const cached = cachedColumnsByTable.get(cacheKey); - if (cached) { - columnsByTable.set(cacheKey, cached); - } - } - } - - const effectiveContext = qualifierIsSchema - ? { - ...completionContext, - qualifier: undefined, - suggestTables: true, - suggestColumns: false, - exclusiveColumnSuggestions: false, - } - : completionContext; - - const items = buildSqlCompletionItemsFromContext(effectiveContext, { - tables, - columnsByTable, - schemas: schemaNames, - translations: completionTranslations.value, - snippets: settingsStore.editorSettings.snippets, + }, 150); }); - - if (items.length === 0) return null; - - return { - from: position - completionContext.prefix.length, - filter: false, - options: items.map((item) => - (item.type === "snippet" || item.type === "function") && item.apply - ? codeMirrorSnippetCompletion(item.apply, { - label: item.label, - type: item.type, - detail: item.detail, - boost: item.boost, - }) - : { - label: item.label, - type: item.type, - detail: item.detail, - boost: item.boost, - }, - ), - validFor: getSqlCompletionResultValidFor(fullDoc, position), - }; } catch { return null; } } +async function performAsyncCompletionWithResult( + epoch: number, + completionContext: ReturnType, + fullDoc: string, + position: number, +) { + // Handle INSERT column list: fetch columns for the target table + let insertColumnsByTable = new Map(); + if (completionContext.insertTable) { + try { + const insertCols = await connectionStore.listCompletionColumns( + props.connectionId!, + props.database!, + completionContext.insertTable, + completionContext.insertSchema, + ); + if (epoch !== completionEpoch) return null; + if (insertCols.length > 0) { + const insertKey = completionContext.insertSchema + ? `${completionContext.insertSchema}.${completionContext.insertTable}` + : completionContext.insertTable; + insertColumnsByTable.set(insertKey, insertCols); + } + } catch { + // ignore + } + } + + const shouldLoadTables = completionContext.suggestTables || !!completionContext.qualifier; + let tables = shouldLoadTables + ? await connectionStore.listCompletionTables( + props.connectionId!, + props.database!, + completionContext.qualifier || completionContext.prefix, + MAX_COMPLETION_TABLES, + ) + : cachedTables; + if (epoch !== completionEpoch) return null; + + // Fetch schemas for schema completion + let schemaNames: string[] = []; + if (completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) { + try { + schemaNames = await connectionStore.listCompletionSchemas(props.connectionId!, props.database!); + if (epoch !== completionEpoch) return null; + } catch { + // ignore + } + } + + // If qualifier didn't match any table names, try it as a schema name + let qualifierIsSchema = false; + if ( + completionContext.qualifier && + tables.length === 0 && + (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions) + ) { + const schemaTables = await connectionStore.listCompletionTables( + props.connectionId!, + props.database!, + completionContext.prefix, + MAX_COMPLETION_TABLES, + completionContext.qualifier, + ); + if (schemaTables.length > 0) { + tables = schemaTables; + qualifierIsSchema = true; + } + if (epoch !== completionEpoch) return null; + } + + // Collect referenced tables — enrich with schema from filtered table lookup + let refs = completionContext.referencedTables.map((rt) => { + if (!rt.schema) { + const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase()); + if (cached && cached.schema) { + return { ...rt, schema: cached.schema }; + } + } + return rt; + }); + const unresolvedRefs = refs.filter((rt) => !rt.schema && !rt.columns); + if (unresolvedRefs.length > 0) { + const lookupGroups = await Promise.all( + unresolvedRefs.map((rt) => + connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20), + ), + ); + if (epoch !== completionEpoch) return null; + const lookupTables = lookupGroups.flat(); + refs = refs.map((rt) => { + if (rt.schema || rt.columns) return rt; + const matched = lookupTables.find((table) => table.name.toLowerCase() === rt.name.toLowerCase()); + return matched?.schema ? { ...rt, schema: matched.schema } : rt; + }); + } + + // If no referenced tables but qualifier exists, infer table from tables list + if (refs.length === 0 && completionContext.qualifier) { + const q = completionContext.qualifier.toLowerCase(); + const matched = tables.filter((t) => t.name.toLowerCase() === q || t.name.toLowerCase().endsWith("." + q)); + refs = matched.map((t) => ({ name: t.name, schema: t.schema })); + } + + // Populate CTE columns from parsed definitions + const cteDefs = extractCteDefinitions(fullDoc); + for (const refTable of refs) { + if (refTable.columns) continue; + const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase()); + if (cteDef) { + refTable.columns = cteDef.columns; + } + } + + await Promise.all( + refs.map(async (refTable) => { + if (refTable.columns && refTable.columns.length > 0) return; + const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; + if (cachedColumnsByTable.has(cacheKey)) return; + try { + const columns = await connectionStore.listCompletionColumns( + props.connectionId!, + props.database!, + refTable.name, + refTable.schema, + ); + if (epoch !== completionEpoch) return; + if (columns.length === 0) return; + cachedColumnsByTable.set(cacheKey, columns); + } catch (e) { + console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e); + } + }), + ); + if (epoch !== completionEpoch) return null; + + // Build columnsByTable — from cache or CTE definitions + const columnsByTable = new Map(); + if (insertColumnsByTable.size > 0) { + for (const [key, cols] of insertColumnsByTable.entries()) { + columnsByTable.set(key, cols); + } + } else { + for (const refTable of refs) { + if (refTable.columns && refTable.columns.length > 0) { + const key = refTable.name; + columnsByTable.set( + key, + refTable.columns.map((name) => ({ + name, + table: refTable.name, + dataType: undefined, + })), + ); + continue; + } + const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name; + const cached = cachedColumnsByTable.get(cacheKey); + if (cached) { + columnsByTable.set(cacheKey, cached); + } + } + } + + const effectiveContext = qualifierIsSchema + ? { + ...completionContext, + qualifier: undefined, + suggestTables: true, + suggestColumns: false, + exclusiveColumnSuggestions: false, + } + : completionContext; + + const items = buildSqlCompletionItemsFromContext(effectiveContext, { + tables, + columnsByTable, + schemas: schemaNames, + translations: completionTranslations.value, + snippets: settingsStore.editorSettings.snippets, + }); + + return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc); +} + async function refreshCompletionCache() { cachedTables = []; cachedColumnsByTable.clear(); diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index ae8c237f63..9f59065bf7 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -1942,12 +1942,17 @@ function buildSnippetItems(prefix: string, snippets: SqlSnippet[]): SqlCompletio .map((snippet) => { const boostByPrefix = computeBoost(snippet.prefix, prefix); const boostByLabel = computeBoost(snippet.label, prefix); + const matchesByPrefix = matchesPrefix(snippet.prefix, prefix); + // When the user types past the snippet prefix (e.g. "sele" vs prefix "sel"), + // they are likely typing the actual keyword — reduce the base boost so + // the real keyword can rank higher. + const baseBoost = matchesByPrefix ? 4000 : 0; return { label: snippet.label, type: "snippet" as const, detail: snippet.body, apply: snippet.body, - boost: Math.max(boostByPrefix, boostByLabel) + 4000, + boost: Math.max(boostByPrefix, boostByLabel) + baseBoost, }; }); } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 063cec5770..f2659f4f26 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -84,6 +84,7 @@ export const useConnectionStore = defineStore("connection", () => { const newConnectionGroupId = ref(null); const completionTablesCache = ref>({}); const completionColumnsCache = ref>({}); + const schemaListCache = ref>({}); const transferSource = ref<{ connectionId: string; database: string } | null>(null); const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null); const dataCompareSource = ref<{ @@ -515,6 +516,9 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of Object.keys(completionColumnsCache.value)) { if (key.startsWith(cachePrefix)) delete completionColumnsCache.value[key]; } + for (const key of Object.keys(schemaListCache.value)) { + if (key.startsWith(cachePrefix)) delete schemaListCache.value[key]; + } } async function removeConnection(id: string) { @@ -1358,6 +1362,16 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function listCompletionSchemas(connectionId: string, database: string): Promise { + const cacheKey = `${connectionId}:${database}`; + if (schemaListCache.value[cacheKey]) { + return schemaListCache.value[cacheKey]; + } + const schemas = await api.listSchemas(connectionId, database); + schemaListCache.value[cacheKey] = schemas; + return schemas; + } + async function listCompletionTables( connectionId: string, database: string, @@ -1374,25 +1388,31 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (isSchemaAwareDatabase(connectionId)) { - const schemas = schema ? [schema] : await api.listSchemas(connectionId, database); + const schemas = schema ? [schema] : await listCompletionSchemas(connectionId, database); if (normalizedFilter || limit) { - const limitedTables: SqlCompletionTable[] = []; - for (const schema of schemas) { - try { - const remaining = limit ? Math.max(limit - limitedTables.length, 0) : undefined; - if (remaining === 0) break; - const tables = await api.listTables(connectionId, database, schema, normalizedFilter, remaining); - limitedTables.push( - ...tables.map((table) => ({ - name: table.name, - schema, - type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const), - })), - ); - } catch { - /* ignore schema failures */ + const batchSize = 5; + const results: SqlCompletionTable[] = []; + for (let i = 0; i < schemas.length && results.length < (limit ?? Infinity); i += batchSize) { + const batch = schemas.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map(async (s) => { + try { + const tables = await api.listTables(connectionId, database, s, normalizedFilter, limit); + return tables.map((table) => ({ + name: table.name, + schema: s, + type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const), + })) as SqlCompletionTable[]; + } catch { + return [] as SqlCompletionTable[]; + } + }), + ); + for (const group of batchResults) { + results.push(...group); } } + const limitedTables = limit ? results.slice(0, limit) : results; completionTablesCache.value[cacheKey] = limitedTables; evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; @@ -1830,6 +1850,7 @@ export const useConnectionStore = defineStore("connection", () => { loadTriggers, listCompletionTables, listCompletionColumns, + listCompletionSchemas, exportConnectionsToFile, readImportFile, importConnectionsFromFile,