mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
feat(completion): optimize qualified SQL completion queries
This commit is contained in:
@@ -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<string, SqlCompletionObject[]>();
|
||||
// Persistent column cache keyed by "schema.table" or "table"
|
||||
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
const cachedPrefixColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
|
||||
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
||||
const loadedColumnsByTable = new Set<string>();
|
||||
@@ -650,10 +650,19 @@ function completionColumnRequestContext(reference?: Pick<SqlCompletionReferenced
|
||||
};
|
||||
}
|
||||
|
||||
async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
|
||||
async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">, 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<SqlCompletionScope, "database" | "schema">;
|
||||
|
||||
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<string>();
|
||||
@@ -3165,6 +3198,23 @@ function unregisterTableReferenceDropListener() {
|
||||
}
|
||||
|
||||
let completionEpoch = 0;
|
||||
let tableCompletionRefreshActive = false;
|
||||
let latestTableCompletionRefresh: (() => Promise<void>) | null = null;
|
||||
|
||||
function queueTableCompletionRefresh(task: () => Promise<void>): 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<typeof setTimeout> | null = null;
|
||||
let typedCompletionActivationUntil = 0;
|
||||
let suppressNextSqlCompletionAutoStartUntil = 0;
|
||||
@@ -3837,13 +3887,25 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
|
||||
|
||||
const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext);
|
||||
if (qualifiedColumnTarget) {
|
||||
const cacheKey = completionCacheKey(qualifiedColumnTarget, scope);
|
||||
const cached = cachedColumnsByTable.get(cacheKey);
|
||||
const reference = completionContext.referencedTables.find((table) => 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<typeof getS
|
||||
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);
|
||||
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<typeof
|
||||
});
|
||||
if (!localOnlyMetadata && !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)))) {
|
||||
const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier;
|
||||
void connectionStore
|
||||
.refreshCompletionTables(connectionId, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch, scope.schema, props.catalog)
|
||||
.then((tables) => {
|
||||
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<typeof
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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<CompletionInfo>);
|
||||
apply?: string;
|
||||
replaceClosingQuote?: '"' | "'";
|
||||
boost: number;
|
||||
@@ -3756,6 +3757,8 @@ function buildSelectAllColumnItems(context: SqlCompletionContext, columnsByTable
|
||||
|
||||
function buildInsertAllColumnItems(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>, 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<string, SqlCompletionColumn[]>): Array<SqlCompletionColumn & { key: string }> {
|
||||
interface CompletionColumnSearchIndex {
|
||||
entries: Array<{ normalizedName: string; index: number }>;
|
||||
}
|
||||
|
||||
const completionColumnSearchIndexes = new WeakMap<readonly SqlCompletionColumn[], CompletionColumnSearchIndex>();
|
||||
|
||||
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<number>();
|
||||
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<string, SqlCompletionColumn[]>, prefix = ""): Array<SqlCompletionColumn & { key: string }> {
|
||||
const allColumns: Array<SqlCompletionColumn & { key: string }> = [];
|
||||
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<string, SqlCompletionColumn[]>, 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<str
|
||||
// keywords so they rank at the top instead of being interleaved.
|
||||
const relevanceBoost = context.referencedTables.length > 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<T extends SqlCompletionColumn & { key: string }>(table: SqlCompletionReferencedTable, columns: readonly T[]): T[] {
|
||||
@@ -4203,7 +4267,7 @@ function applyReferencedColumnAliases<T extends SqlCompletionColumn>(table: SqlC
|
||||
|
||||
function hasMatchingReferencedColumnPrefix(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>): 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<string, SqlCompletionColumn[]>, foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>, 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;
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -459,6 +459,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
|
||||
const completionObjectIndex = new Map<string, { touched: number; objects: SqlCompletionObject[] }>();
|
||||
const completionColumnIndex = new Map<string, { touched: number; columns: SqlCompletionColumn[] }>();
|
||||
const completionColumnPrefixIndex = new Map<string, { touched: number; columns: SqlCompletionColumn[]; complete: boolean }>();
|
||||
const completionForeignKeyIndex = new Map<string, { touched: number; foreignKeys: SqlCompletionForeignKey[] }>();
|
||||
const completionInFlight = new Map<string, Promise<unknown>>();
|
||||
const completionCacheRevisions = ref<Record<string, number>>({});
|
||||
@@ -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<SqlCompletionColumn[]> {
|
||||
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,
|
||||
|
||||
+1
-1
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user