feat(completion): suggest database qualifiers

This commit is contained in:
t8y2
2026-06-10 11:08:13 +08:00
parent 3a52d961fc
commit 410e3bbfcb
2 changed files with 120 additions and 13 deletions
@@ -34,6 +34,7 @@ import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestu
import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/codemirrorTrimmedSelectionLayer";
import { selectionMatchOccurrences } from "@/lib/codemirrorSelectionMatches";
import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport";
import * as api from "@/lib/api";
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sqlCompletion";
@@ -416,10 +417,24 @@ function completionCacheKey(table: { name: string; schema?: string | null }) {
return schema ? `${schema}.${table.name}` : table.name;
}
function supportsDatabaseQualifierCompletion(): boolean {
return !!props.databaseType && !isSchemaAware(props.databaseType) && !isSingleDatabase(props.databaseType);
}
function completionMetadataTarget(table: { name: string; schema?: string | null }): { database: string; schema?: string } | null {
if (props.database == null) return null;
if (supportsDatabaseQualifierCompletion() && table.schema) {
return { database: table.schema };
}
return { database: props.database, schema: table.schema ?? props.schema };
}
async function ensureColumnsForTable(table: { name: string; schema?: string | null }) {
const cacheKey = completionCacheKey(table);
if (cachedColumnsByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
const columns = await connectionStore.listCompletionColumns(props.connectionId, props.database, table.name, table.schema ?? props.schema);
const target = completionMetadataTarget(table);
if (!target) return;
const columns = await connectionStore.listCompletionColumns(props.connectionId, target.database, table.name, target.schema);
if (columns.length === 0) return;
cachedColumnsByTable.set(cacheKey, columns);
}
@@ -427,9 +442,11 @@ async function ensureColumnsForTable(table: { name: string; schema?: string | nu
async function ensureForeignKeysForTable(table: { name: string; schema?: string | null }) {
const cacheKey = completionCacheKey(table);
if (cachedForeignKeysByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
const querySchema = table.schema ?? props.schema ?? props.database;
const target = completionMetadataTarget(table);
if (!target) return;
const querySchema = target.schema ?? target.database;
try {
const foreignKeys = await api.listForeignKeys(props.connectionId, props.database, querySchema, table.name);
const foreignKeys = await api.listForeignKeys(props.connectionId, target.database, querySchema, table.name);
cachedForeignKeysByTable.set(
cacheKey,
foreignKeys.map((foreignKey: ForeignKeyInfo) => ({
@@ -801,6 +818,22 @@ function buildCompletionResult(items: QueryCompletionItem[], from: number, valid
};
}
function findExactName(names: string[], value: string): string | undefined {
return names.find((name) => name.toLowerCase() === value.toLowerCase());
}
function mergeCompletionQualifierNames(primary: string[], secondary: string[]): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const name of [...primary, ...secondary]) {
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
merged.push(name);
}
return merged;
}
function completionOptionForItem(item: QueryCompletionItem) {
const record = () => {
recordCompletionSelection(item.label, item.type);
@@ -949,17 +982,21 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number) {
if (!props.connectionId || props.database == null) return null;
const databaseNames = supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable ? connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES) : [];
const qualifierDatabase = completionContext.qualifier ? findExactName(databaseNames, completionContext.qualifier) : undefined;
const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext));
const tableLookupSchema = completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
const tableLookupDatabase = qualifierDatabase ?? props.database;
const tableLookupSchema = qualifierDatabase ? undefined : completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
const tableLookupFilter = completionContext.qualifier && completionContext.suggestTables ? completionContext.prefix : completionContext.qualifier || completionContext.prefix;
const tables = shouldLoadTables ? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema) : cachedTables;
const tables = shouldLoadTables ? connectionStore.lookupLocalCompletionTables(props.connectionId, tableLookupDatabase, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema) : cachedTables;
const shouldLoadObjects = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
const completionObjects = shouldLoadObjects
? connectionStore.lookupLocalCompletionObjects(props.connectionId, props.database, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier && !completionContext.exclusiveColumnSuggestions ? completionContext.qualifier : props.schema)
: cachedCompletionObjects;
const schemaNames = completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable ? connectionStore.lookupLocalCompletionSchemas(props.connectionId, props.database, completionContext.prefix, MAX_COMPLETION_TABLES) : [];
const schemaNames =
completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable ? mergeCompletionQualifierNames(connectionStore.lookupLocalCompletionSchemas(props.connectionId, props.database, completionContext.prefix, MAX_COMPLETION_TABLES), databaseNames) : [];
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
if (completionContext.insertTable) {
@@ -986,7 +1023,8 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
columnsByTable.set(cacheKey, cached);
continue;
}
const localColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, refTable.name, refTable.schema ?? props.schema);
const target = completionMetadataTarget(refTable);
const localColumns = target ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema) : [];
if (localColumns.length > 0) {
columnsByTable.set(cacheKey, localColumns);
}
@@ -1034,6 +1072,9 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
}
if (completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) {
void connectionStore.refreshCompletionSchemas(connectionId, database).catch(() => {});
if (supportsDatabaseQualifierCompletion()) {
void connectionStore.refreshCompletionDatabases(connectionId).catch(() => {});
}
}
if (completionContext.insertTable) {
const insertTable = completionContext.insertTable;
@@ -1049,8 +1090,10 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
if (refTable.columns && refTable.columns.length > 0) continue;
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
if (cachedColumnsByTable.has(cacheKey)) continue;
const target = completionMetadataTarget(refTable);
if (!target) continue;
void connectionStore
.refreshCompletionColumns(connectionId, database, refTable.name, refTable.schema ?? props.schema)
.refreshCompletionColumns(connectionId, target.database, refTable.name, target.schema)
.then((columns) => {
if (columns.length > 0) cachedColumnsByTable.set(cacheKey, columns);
})
@@ -1088,7 +1131,20 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
}
const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext));
let tables = shouldLoadTables ? await connectionStore.listCompletionTables(props.connectionId!, props.database!, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, props.schema) : cachedTables;
let databaseNames: string[] = [];
if (supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable) {
try {
databaseNames = await connectionStore.listCompletionDatabases(props.connectionId!);
if (epoch !== completionEpoch) return null;
} catch {
databaseNames = [];
}
}
const qualifierDatabase = completionContext.qualifier ? findExactName(databaseNames, completionContext.qualifier) : undefined;
const tableLookupDatabase = qualifierDatabase ?? props.database!;
const tableLookupSchema = qualifierDatabase ? undefined : props.schema;
const tableLookupFilter = completionContext.qualifier && !qualifierDatabase ? completionContext.qualifier : completionContext.prefix;
let tables = shouldLoadTables ? await connectionStore.listCompletionTables(props.connectionId!, tableLookupDatabase, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema) : cachedTables;
if (epoch !== completionEpoch) return null;
const shouldLoadObjects = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
@@ -1108,16 +1164,17 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
let schemaNames: string[] = [];
if (completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) {
try {
schemaNames = await connectionStore.listCompletionSchemas(props.connectionId!, props.database!);
const schemas = await connectionStore.listCompletionSchemas(props.connectionId!, props.database!);
schemaNames = mergeCompletionQualifierNames(schemas, databaseNames);
if (epoch !== completionEpoch) return null;
} catch {
// ignore
schemaNames = databaseNames;
}
}
// If qualifier didn't match any table names, try it as a schema name
let qualifierIsSchema = false;
if (completionContext.qualifier && !isReferencedTableQualifier(completionContext) && tables.length === 0 && (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)) {
if (completionContext.qualifier && !qualifierDatabase && !isReferencedTableQualifier(completionContext) && 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;
@@ -1171,7 +1228,9 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
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 ?? props.schema);
const target = completionMetadataTarget(refTable);
if (!target) return;
const columns = await connectionStore.listCompletionColumns(props.connectionId!, target.database, refTable.name, target.schema);
if (epoch !== completionEpoch) return;
if (columns.length === 0) return;
cachedColumnsByTable.set(cacheKey, columns);
@@ -102,6 +102,7 @@ export const useConnectionStore = defineStore("connection", () => {
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
const completionObjectsCache = ref<Record<string, SqlCompletionObject[]>>({});
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
const completionDatabasesCache = ref<Record<string, string[]>>({});
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
@@ -1611,10 +1612,50 @@ export const useConnectionStore = defineStore("connection", () => {
.slice(0, limit);
}
function lookupLocalCompletionDatabases(connectionId: string, filter = "", limit = 50): string[] {
const databases = completionDatabasesCache.value[connectionId] ?? databaseNamesFromTree(connectionId);
const normalized = filter.trim().toLowerCase();
return databases
.filter((database) => fuzzyTextMatch(database, normalized))
.sort((a, b) => tableMatchScore({ name: b }, normalized) - tableMatchScore({ name: a }, normalized))
.slice(0, limit);
}
function lookupLocalCompletionColumns(connectionId: string, database: string, table: string, schema?: string): SqlCompletionColumn[] {
return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema))?.columns ?? [];
}
function databaseNamesFromTree(connectionId: string): string[] {
const node = findNode(treeNodes.value, connectionId);
if (!node?.children) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const child of node.children) {
if (child.type !== "database" || !child.database) continue;
const key = child.database.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
names.push(child.database);
}
return names;
}
async function listCompletionDatabases(connectionId: string): Promise<string[]> {
if (completionDatabasesCache.value[connectionId]) {
return completionDatabasesCache.value[connectionId];
}
return withCompletionInFlight(`${connectionId}:completion-databases`, async () => {
await ensureConnected(connectionId);
const config = getConfig(connectionId);
const databases = await api.listDatabases(connectionId);
completionDatabasesCache.value[connectionId] = filterDatabaseNamesForConnection(
databases.map((database) => database.name),
config,
);
return completionDatabasesCache.value[connectionId];
});
}
async function listCompletionSchemas(connectionId: string, database: string): Promise<string[]> {
const cacheKey = `${connectionId}:${database}`;
if (schemaListCache.value[cacheKey]) {
@@ -1882,6 +1923,10 @@ export const useConnectionStore = defineStore("connection", () => {
return listCompletionSchemas(connectionId, database);
}
function refreshCompletionDatabases(connectionId: string): Promise<string[]> {
return listCompletionDatabases(connectionId);
}
function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionColumn[]> {
return listCompletionColumns(connectionId, database, table, schema);
}
@@ -2378,14 +2423,17 @@ export const useConnectionStore = defineStore("connection", () => {
listCompletionObjects,
listCompletionColumns,
listCompletionSchemas,
listCompletionDatabases,
lookupLocalCompletionTables,
lookupLocalCompletionObjects,
lookupLocalCompletionColumns,
lookupLocalCompletionSchemas,
lookupLocalCompletionDatabases,
refreshCompletionTables,
refreshCompletionObjects,
refreshCompletionColumns,
refreshCompletionSchemas,
refreshCompletionDatabases,
listElasticsearchCompletionIndices,
exportConnectionsToFile,
readImportFile,