feat(sql): add semantic SQL completion engine

This commit is contained in:
t8y2 committed 2026-07-04 13:20:13 +08:00
1 parent 32a9496d2a
commit 90f0594e93
16 files changed
+2038 -30

No files matched your search

@@ -29,6 +29,9 @@ import {
shouldAutoOpenSqlCompletion,
extractCteDefinitions,
} from "@/lib/sqlCompletion";
import { sqlCompletionContextFromSemantic } from "@/lib/sqlSemanticCompletion";
import { buildSqlSemanticModel } from "@/lib/sqlSemanticModel";
import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sqlSemanticReferences";
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearchCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongoCompletion";
import { extractIdentifierAt, isSqlKeyword, matchTable, splitQualifiedIdentifier } from "@/lib/sqlNavigation";
@@ -78,6 +81,8 @@ const props = defineProps<{
}>();
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
const emit = defineEmits<{
"update:modelValue": [value: string];
@@ -992,24 +997,29 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
if (!range) return null;
const identifier = range.text;
const parts = identifier.split(".");
const parts = splitQualifiedIdentifier(identifier);
const name = parts[parts.length - 1] ?? identifier;
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(sql, pos, { databaseType: props.databaseType, dialect: props.dialect }) : null;
const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier;
try {
if (cachedTables.length === 0) {
cachedTables = usesLocalOnlyCompletionMetadata()
? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, name, MAX_COMPLETION_TABLES, props.schema)
: await connectionStore.listCompletionTables(props.connectionId, props.database, name, MAX_COMPLETION_TABLES, props.schema);
? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema)
: await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema);
}
let table = matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables);
let table = matchTable(qualifiedTableLookup, cachedTables) ?? matchTable(tableLookupName, cachedTables) ?? matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables);
if (!table && !usesLocalOnlyCompletionMetadata()) {
const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, name, MAX_COMPLETION_TABLES, props.schema);
const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema);
cachedTables = [...cachedTables, ...hoverTables];
table = matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
}
if (table && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
if (table && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
return {
pos: range.from,
end: range.to,
@@ -1019,12 +1029,21 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
};
}
const context = getSqlCompletionContext(sql, pos);
const legacyContext = getSqlCompletionContext(sql, pos);
const context = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyContext) : legacyContext;
const candidates = qualifier ? context.referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : context.referencedTables;
for (const refTable of candidates) {
await ensureColumnsForTable(refTable);
const columns = cachedColumnsByTable.get(completionCacheKey(refTable)) ?? [];
const columns: SqlCompletionColumn[] =
refTable.columns?.map((columnName) => ({
name: columnName,
table: refTable.name,
...(refTable.schema ? { schema: refTable.schema } : {}),
})) ?? [];
if (columns.length === 0) {
await ensureColumnsForTable(refTable);
columns.push(...(cachedColumnsByTable.get(completionCacheKey(refTable)) ?? []));
}
const column = columns.find((col) => col.name.toLowerCase() === name.toLowerCase());
if (!column) continue;
return {
@@ -1164,6 +1183,10 @@ async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]): Prom
const enriched: SqlTableReference[] = [];
const missingTables = new Set<string>();
for (const table of tables) {
if (isStatementLocalSemanticTable(table)) {
enriched.push(table);
continue;
}
try {
const match = await findExactSemanticDiagnosticTable(table);
if (!match) missingTables.add(tableReferenceKey(table));
@@ -1180,6 +1203,7 @@ async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[]):
const seen = new Set<string>();
const targets: SqlTableReference[] = [];
for (const table of tables) {
if (isStatementLocalSemanticTable(table)) continue;
const tableWithInlineColumns = table as SqlTableReference & { columns?: string[] };
if (tableWithInlineColumns.columns && tableWithInlineColumns.columns.length > 0) continue;
const cacheKey = completionCacheKey(table);
@@ -1204,6 +1228,11 @@ async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[]):
return missingTables;
}
function isStatementLocalSemanticTable(table: SqlTableReference): boolean {
const kind = (table as SqlTableReference & { semanticSourceKind?: string }).semanticSourceKind;
return kind === "cte" || kind === "subquery" || kind === "table_function";
}
async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boolean } = {}) {
const currentView = view.value;
const runId = ++semanticDiagnosticRunId;
@@ -1256,12 +1285,15 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
const analysis = await api.analyzeSqlReferences(range.sql, props.formatDialect ?? props.dialect ?? "generic");
if (runId !== semanticDiagnosticRunId) return;
const { tables, missingTables } = await enrichSemanticDiagnosticTables(analysis.tables);
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(range.sql, semanticCursor, { databaseType: props.databaseType, dialect: props.dialect }) : null;
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
const { tables, missingTables } = await enrichSemanticDiagnosticTables(semanticAnalysis.tables);
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables);
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
if (runId !== semanticDiagnosticRunId) return;
const enrichedAnalysis: SqlReferenceAnalysis = { ...analysis, tables };
const enrichedAnalysis: SqlReferenceAnalysis = { ...semanticAnalysis, tables };
nextDiagnostics.push(
...offsetSqlSemanticDiagnostics(
buildSqlSemanticDiagnostics(enrichedAnalysis, {
@@ -1562,7 +1594,10 @@ async function provideMongoCompletions(currentState: import("@codemirror/state")
};
}
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
async function provideSqlCompletions(context: CompletionContext) {
const currentState = context.state;
const position = context.pos;
const explicit = context.explicit;
if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null;
if (!props.connectionId) return null;
const fullDoc = currentState.doc.toString();
@@ -1585,7 +1620,8 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
if (isSqlCompletionSuppressedContext(fullDoc, position)) return null;
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
const completionContext = getSqlCompletionContext(fullDoc, position);
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position);
const completionContext = SEMANTIC_SQL_COMPLETION_ENABLED ? sqlCompletionContextFromSemantic(buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.dialect }), legacyCompletionContext) : legacyCompletionContext;
if (!hasDatabase) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
@@ -1643,6 +1679,9 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
// 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) => {
context.addEventListener("abort", () => {
if (epoch === completionEpoch) completionEpoch++;
});
completionDebounceTimer = setTimeout(async () => {
completionDebounceTimer = null;
if (epoch !== completionEpoch) {
@@ -2250,7 +2289,7 @@ onMounted(async () => {
buildSqlCompletionExtension = () =>
autocompletion({
activateOnTyping: true,
override: [async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos, context.explicit)],
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
});
const dialect = createDbxCodeMirrorSqlDialect(langSql, props.dialect);