feat(completion): optimize qualified SQL completion queries
This commit is contained in:
1 parent
fe269a28db
commit
3442acd52c
6 files changed
+393
-66
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user