perf(editor): optimize SQL autocomplete responsiveness

- Add 150ms debounce on async completion path to reduce redundant Tauri IPC calls during rapid typing
- Parallelize schema table fetching with max concurrency of 5
- Separate schema list cache from table completion cache for longer-lived reuse
- Return keyword completions synchronously when no backend data is needed
- Lower snippet base boost for label-only matches so keywords rank above snippets when user types past snippet prefix
This commit is contained in:
t8y2 committed 2026-05-28 23:31:31 +08:00
1 parent 39b960c46c
commit 1d2fec4aee
3 files changed
+290 -229

No files matched your search

+247 -212
View File
@@ -716,6 +716,36 @@ async function formatCurrentSql() {
}
let completionEpoch = 0;
let completionDebounceTimer: ReturnType<typeof setTimeout> | null = null;
function buildCompletionResult(
items: ReturnType<typeof buildSqlCompletionItemsFromContext>,
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<string, SqlCompletionColumn[]>();
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<ReturnType<typeof buildCompletionResult>>((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<string, SqlCompletionColumn[]>();
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<typeof getSqlCompletionContext>,
fullDoc: string,
position: number,
) {
// Handle INSERT column list: fetch columns for the target table
let insertColumnsByTable = new Map<string, SqlCompletionColumn[]>();
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<string, SqlCompletionColumn[]>();
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();
+6 -1
View File
@@ -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,
};
});
}
+37 -16
View File
@@ -84,6 +84,7 @@ export const useConnectionStore = defineStore("connection", () => {
const newConnectionGroupId = ref<string | null>(null);
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
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<string[]> {
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,