feat(completion): optimize qualified SQL completion queries

This commit is contained in:
miracle authored and GitHub committed 2026-08-22 15:41:13 +08:00
1 parent fe269a28db
commit 3442acd52c
6 files changed
+393 -66

No files matched your search

+90 -32
View File
@@ -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;