mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(completion): search tables and columns by comment in SQL editor (#12629)
* feat(completion): search tables and columns by comment in SQL editor (#12033) * fix(test): extract itemHasHanText helper for standalone shouldBypassCompletionFilter eval
This commit is contained in:
@@ -5,6 +5,7 @@ import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { type QueryCompletionItem, type BatchColumnSelectionActionItem } from "./useQueryEditorBatchSelection";
|
||||
import type { CompletionMetadataScope } from "./queryEditorTypes";
|
||||
import { completionMatchRanges } from "@/lib/common/completionMatch";
|
||||
import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
|
||||
import {
|
||||
buildSqlCompletionItemsFromContext,
|
||||
buildPostgresSequenceLiteralCompletionItems,
|
||||
@@ -328,6 +329,13 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
return buildCompletionResult(actions.length > 0 ? [...replacement.items, ...actions] : replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
|
||||
}
|
||||
|
||||
function itemHasHanText(item: QueryCompletionItem | BatchColumnSelectionActionItem): boolean {
|
||||
if (/\p{Script=Han}/u.test(item.label)) return true;
|
||||
if ("comment" in item && typeof item.comment === "string" && /\p{Script=Han}/u.test(item.comment)) return true;
|
||||
if (typeof item.detail === "string" && /\p{Script=Han}/u.test(item.detail)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// CodeMirror's built-in matcher only matches single-character queries against
|
||||
// the label start, and cannot match pinyin initials against Han labels. Our
|
||||
// provider already filters and ranks items itself (substring + pinyin), so
|
||||
@@ -335,7 +343,14 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
function shouldBypassCompletionFilter(prefix: string, items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>): boolean {
|
||||
if (!prefix) return false;
|
||||
if (/\p{Script=Han}/u.test(prefix)) return true;
|
||||
return /^[a-z0-9]+$/i.test(prefix) && items.some((item) => /\p{Script=Han}/u.test(item.label));
|
||||
if (/^[a-z0-9]+$/i.test(prefix) && items.some(itemHasHanText)) return true;
|
||||
return items.some((item) => {
|
||||
const renderedLabel = "displayLabel" in item && typeof item.displayLabel === "string" ? item.displayLabel : item.label;
|
||||
if (renderedLabel.toLowerCase().startsWith(prefix.toLowerCase())) return false;
|
||||
if ("comment" in item && typeof item.comment === "string" && matchesIdentifierSearch(item.comment, prefix)) return true;
|
||||
if (typeof item.detail === "string" && matchesIdentifierSearch(item.detail, prefix)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function completionItemsForBypassedFilter(prefix: string, items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>): Array<QueryCompletionItem | BatchColumnSelectionActionItem> {
|
||||
@@ -346,7 +361,11 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
// prefix, otherwise checked columns cannot be applied from that menu.
|
||||
if (isBatchColumnSelectionAction(item)) return true;
|
||||
const renderedLabel = "displayLabel" in item && typeof item.displayLabel === "string" ? item.displayLabel : item.label;
|
||||
return /\p{Script=Han}/u.test(item.label) || renderedLabel.toLowerCase().startsWith(normalized);
|
||||
if (renderedLabel.toLowerCase().startsWith(normalized)) return true;
|
||||
if (itemHasHanText(item)) return true;
|
||||
if ("comment" in item && typeof item.comment === "string" && matchesIdentifierSearch(item.comment, prefix)) return true;
|
||||
if (typeof item.detail === "string" && matchesIdentifierSearch(item.detail, prefix)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ describe("QueryEditor completion Tab keymap", () => {
|
||||
});
|
||||
|
||||
it("keeps ASCII single-character filtering while allowing Han pinyin matches", () => {
|
||||
const source = [extractFunction("isBatchColumnSelectionAction"), extractFunction("completionItemsForBypassedFilter"), extractFunction("shouldBypassCompletionFilter"), extractFunction("buildCompletionResult")].join("\n");
|
||||
const source = [extractFunction("isBatchColumnSelectionAction"), extractFunction("itemHasHanText"), extractFunction("completionItemsForBypassedFilter"), extractFunction("shouldBypassCompletionFilter"), extractFunction("buildCompletionResult")].join("\n");
|
||||
const javascript = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
@@ -316,7 +316,7 @@ describe("QueryEditor completion Tab keymap", () => {
|
||||
});
|
||||
|
||||
it("bypasses CodeMirror filtering for Han substring matches", () => {
|
||||
const javascript = ts.transpileModule(extractFunction("shouldBypassCompletionFilter"), {
|
||||
const javascript = ts.transpileModule([extractFunction("itemHasHanText"), extractFunction("shouldBypassCompletionFilter")].join("\n"), {
|
||||
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const shouldBypassCompletionFilter = new Function(`${javascript}\nreturn shouldBypassCompletionFilter;`)() as (prefix: string, items: Array<{ label: string }>) => boolean;
|
||||
|
||||
@@ -177,3 +177,36 @@ describe("sqlCompletion identifier column matching", () => {
|
||||
expect(items.some((item) => item.label === "created_at")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sqlCompletion comment matching", () => {
|
||||
it("matches tables by Chinese comment and pinyin", () => {
|
||||
const items = buildSqlCompletionItems("SELECT * FROM yh", "SELECT * FROM yh".length, {
|
||||
databaseType: "mysql",
|
||||
tables: [
|
||||
{ name: "t_user", type: "table", comment: "用户表" },
|
||||
{ name: "t_order", type: "table", comment: "订单表" },
|
||||
],
|
||||
columnsByTable: new Map(),
|
||||
});
|
||||
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "t_user", type: "table" })]));
|
||||
expect(items.some((item) => item.label === "t_order")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches columns by Chinese comment and pinyin", () => {
|
||||
const items = buildSqlCompletionItems("SELECT * FROM t_user WHERE cjsj", "SELECT * FROM t_user WHERE cjsj".length, {
|
||||
databaseType: "mysql",
|
||||
tables: [{ name: "t_user", type: "table" }],
|
||||
columnsByTable: new Map([
|
||||
[
|
||||
"t_user",
|
||||
[
|
||||
{ name: "create_time", table: "t_user", comment: "创建时间" },
|
||||
{ name: "update_time", table: "t_user", comment: "更新时间" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "create_time", type: "column" })]));
|
||||
expect(items.some((item) => item.label === "update_time")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1519,6 +1519,7 @@ export interface SqlCompletionTable {
|
||||
detail?: string;
|
||||
applyName?: string;
|
||||
boost?: number;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface SqlCompletionObject {
|
||||
@@ -2001,7 +2002,8 @@ class SqlCompletionProvider {
|
||||
const isAliasSnippet = item.type === "snippet" && item.apply === formatAliasCompletionApply(item.label);
|
||||
const isExactLabelMatch = !isAliasSnippet && item.label.toLowerCase() === context.prefix.toLowerCase();
|
||||
const isExactFilterTextMatch = item.filterText?.toLowerCase() === context.prefix.toLowerCase();
|
||||
if (isExactLabelMatch || isExactFilterTextMatch) {
|
||||
const isExactCommentMatch = item.comment?.toLowerCase() === context.prefix.toLowerCase();
|
||||
if (isExactLabelMatch || isExactFilterTextMatch || isExactCommentMatch) {
|
||||
item.exactMatch = true;
|
||||
item.boost += EXACT_LABEL_MATCH_BOOST;
|
||||
}
|
||||
@@ -2034,7 +2036,7 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options
|
||||
}
|
||||
if (context.exclusiveColumnSuggestions || shouldAutoOpenColumnCompletion(context, beforeCursor, beforeCursor.length, options.databaseType)) return true;
|
||||
if (context.dataTypeContext) return true;
|
||||
return /[A-Za-z_$@.]/.test(previousChar);
|
||||
return /[A-Za-z_$@.\p{Script=Han}]/u.test(previousChar);
|
||||
}
|
||||
|
||||
function shouldAutoOpenColumnCompletion(context: SqlCompletionContext, sql: string, cursor: number, databaseType: DatabaseType | undefined): boolean {
|
||||
@@ -4708,6 +4710,48 @@ function buildReferencedTableItems(context: SqlCompletionContext, completionTabl
|
||||
return items;
|
||||
}
|
||||
|
||||
export function tableCompletionComment(table: SqlCompletionTable): string | undefined {
|
||||
if (table.comment?.trim()) return table.comment.trim();
|
||||
if (table.detail) {
|
||||
const arrowIndex = table.detail.indexOf("→");
|
||||
if (arrowIndex >= 0) {
|
||||
const comment = table.detail.slice(arrowIndex + 1).trim();
|
||||
if (comment) return comment;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function matchesTableSearch(table: SqlCompletionTable, prefix: string): boolean {
|
||||
if (!prefix) return true;
|
||||
if (matchesPrefix(table.name, prefix)) return true;
|
||||
const comment = tableCompletionComment(table);
|
||||
return !!comment && matchesPrefix(comment, prefix);
|
||||
}
|
||||
|
||||
export function computeTableBoost(table: SqlCompletionTable, prefix: string): number {
|
||||
if (!prefix) return computeBoost(table.name, prefix);
|
||||
const nameScore = computeBoost(table.name, prefix);
|
||||
const comment = tableCompletionComment(table);
|
||||
const commentScore = comment ? computeBoost(comment, prefix) : -1;
|
||||
return Math.max(nameScore, commentScore > 0 ? commentScore - 50 : -1);
|
||||
}
|
||||
|
||||
export function matchesColumnSearch(column: SqlCompletionColumn, prefix: string): boolean {
|
||||
if (!prefix) return true;
|
||||
if (matchesIdentifierSearch(column.name, prefix)) return true;
|
||||
const comment = column.comment?.trim();
|
||||
return !!comment && matchesIdentifierSearch(comment, prefix);
|
||||
}
|
||||
|
||||
export function computeColumnBoost(column: SqlCompletionColumn, displayLabel: string, prefix: string): number {
|
||||
if (!prefix) return Math.max(identifierMatchScore(column.name, prefix), identifierMatchScore(displayLabel, prefix));
|
||||
const nameScore = Math.max(identifierMatchScore(column.name, prefix), identifierMatchScore(displayLabel, prefix));
|
||||
const comment = column.comment?.trim();
|
||||
const commentScore = comment ? identifierMatchScore(comment, prefix) : -1;
|
||||
return Math.max(nameScore, commentScore > 0 ? commentScore - 50 : -1);
|
||||
}
|
||||
|
||||
function buildTableItems(
|
||||
context: Pick<SqlCompletionContext, "prefix" | "qualifier" | "qualifierParts">,
|
||||
tables: SqlCompletionTable[],
|
||||
@@ -4722,8 +4766,8 @@ function buildTableItems(
|
||||
const { prefix } = context;
|
||||
const qualifierSchema = context.qualifierParts?.[context.qualifierParts.length - 1] ?? context.qualifier?.split(".").filter(Boolean).pop();
|
||||
const existingAliases = new Set(referencedTables.map((ref) => ref.alias?.toLowerCase()).filter((alias): alias is string => !!alias));
|
||||
const matchingTables = tables.filter((table) => matchesPrefix(table.name, prefix));
|
||||
// Ambiguity is decided among prefix-matching tables only, matching prior behavior.
|
||||
const matchingTables = tables.filter((table) => matchesTableSearch(table, prefix));
|
||||
// Ambiguity is decided among matching tables only.
|
||||
const schemasByTableName = collectSchemasByTableName(matchingTables);
|
||||
return matchingTables
|
||||
.map((table) => {
|
||||
@@ -4745,13 +4789,19 @@ function buildTableItems(
|
||||
: (casedSuppliedApplyName ?? defaultApplyName);
|
||||
const alias = autoAliasTables ? generateTableCompletionAlias(table.name, existingAliases) : "";
|
||||
const schemaDetail = ambiguousTableName && table.schema ? `${table.schema}.${table.name}` : undefined;
|
||||
const detail = table.detail && schemaDetail ? `${schemaDetail} ${table.detail}` : (table.detail ?? schemaDetail ?? (table.type === "table" ? undefined : table.type));
|
||||
const comment = tableCompletionComment(table);
|
||||
const commentDetail = comment ? `→ ${comment}` : undefined;
|
||||
const tableDetail = table.detail ?? commentDetail;
|
||||
const detail = tableDetail && schemaDetail ? `${schemaDetail} ${tableDetail}` : (tableDetail ?? schemaDetail ?? (table.type === "table" ? undefined : table.type));
|
||||
const nameMatches = matchesPrefix(table.name, prefix);
|
||||
return {
|
||||
label: tableName,
|
||||
filterText: nameMatches ? undefined : comment || prefix,
|
||||
type: "table" as const,
|
||||
detail,
|
||||
apply: formatTableAliasApply(applyName, alias),
|
||||
boost: computeBoost(table.name, prefix) + 1000 + (table.boost ?? 0),
|
||||
boost: computeTableBoost(table, prefix) + 1000 + (table.boost ?? 0),
|
||||
comment,
|
||||
dedupeKey:
|
||||
schemaQualificationMode === "never" && table.schema
|
||||
? `${quoteCasedIdentifier(table.schema, applySqlIdentifierCase(table.schema, identifierCase), dialect)}.${unqualifiedApplyName}`
|
||||
@@ -4784,12 +4834,12 @@ function buildForeignKeyRelatedTableItems(
|
||||
for (const foreignKey of foreignKeys) {
|
||||
if (referencedTableMatchesName(ref, owner.name, owner.schema)) {
|
||||
const target = findCompletionTable(tables, foreignKey.ref_table, foreignKey.ref_schema);
|
||||
if (target && matchesPrefix(target.name, context.prefix)) {
|
||||
if (target && matchesTableSearch(target, context.prefix)) {
|
||||
candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${foreignKey.column} → ${qualifiedCompletionName(foreignKey.ref_table, foreignKey.ref_schema)}.${foreignKey.ref_column}` });
|
||||
}
|
||||
} else if (referencedTableMatchesName(ref, foreignKey.ref_table, foreignKey.ref_schema)) {
|
||||
const target = findCompletionTable(tables, owner.name, owner.schema);
|
||||
if (target && matchesPrefix(target.name, context.prefix)) {
|
||||
if (target && matchesTableSearch(target, context.prefix)) {
|
||||
candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${qualifiedCompletionName(owner.name, owner.schema)}.${foreignKey.column} → ${foreignKey.ref_column}` });
|
||||
}
|
||||
}
|
||||
@@ -4800,7 +4850,7 @@ function buildForeignKeyRelatedTableItems(
|
||||
// Reuse buildTableItems' ambiguity signal so FK candidates qualify with
|
||||
// `schema.table` exactly when regular candidates would. Built from the same
|
||||
// prefix-matching table set buildTableItems uses, keeping the two in lockstep.
|
||||
const schemasByTableName = collectSchemasByTableName(tables.filter((table) => matchesPrefix(table.name, context.prefix)));
|
||||
const schemasByTableName = collectSchemasByTableName(tables.filter((table) => matchesTableSearch(table, context.prefix)));
|
||||
const qualifierSchema = context.qualifierParts?.[context.qualifierParts.length - 1] ?? context.qualifier?.split(".").filter(Boolean).pop();
|
||||
|
||||
return [...candidates.values()]
|
||||
@@ -4811,12 +4861,16 @@ function buildForeignKeyRelatedTableItems(
|
||||
const unqualifiedApplyName = quoteCasedIdentifier(table.name, tableName, dialect);
|
||||
const applyName = qualifiedByContext || schemaQualificationMode === "never" ? unqualifiedApplyName : defaultApplyName;
|
||||
const alias = autoAliasTables ? generateTableCompletionAlias(table.name, existingAliases) : "";
|
||||
const comment = tableCompletionComment(table);
|
||||
const nameMatches = matchesPrefix(table.name, context.prefix);
|
||||
return {
|
||||
label: tableName,
|
||||
filterText: nameMatches ? undefined : comment || context.prefix,
|
||||
type: "table" as const,
|
||||
detail,
|
||||
apply: formatTableAliasApply(applyName, alias),
|
||||
boost: computeBoost(table.name, context.prefix) + 3600,
|
||||
boost: computeTableBoost(table, context.prefix) + 3600,
|
||||
comment,
|
||||
// Mirror buildTableItems' dedupeKey so an FK candidate and the regular
|
||||
// candidate for the same schema-qualified table collapse to one entry.
|
||||
dedupeKey:
|
||||
@@ -5629,7 +5683,7 @@ function completionColumnPrefixCandidates(columns: readonly SqlCompletionColumn[
|
||||
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;
|
||||
if (!column || !matchesColumnSearch(column, prefix)) continue;
|
||||
candidates.push(column);
|
||||
}
|
||||
}
|
||||
@@ -5721,10 +5775,10 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map<str
|
||||
|
||||
const rankedColumns = uniqueColumns
|
||||
.map((column, index) => ({ column, index }))
|
||||
.filter(({ column }) => matchesIdentifierSearch(column.name, context.prefix) || matchesIdentifierSearch(column.displayLabel, context.prefix))
|
||||
.filter(({ column }) => matchesColumnSearch(column, 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));
|
||||
const matchScore = computeColumnBoost(column, column.displayLabel, context.prefix);
|
||||
return { column, index, boost: matchScore + keyBoost + relevanceBoost };
|
||||
})
|
||||
.sort((left, right) => right.boost - left.boost || left.index - right.index)
|
||||
@@ -5734,9 +5788,11 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map<str
|
||||
const quoteBatchColumns = !!batchSelectionMode && quoteIdentifiers !== false && (databaseType === "mysql" || databaseType === "starrocks");
|
||||
|
||||
return rankedColumns.map(({ column, boost }) => {
|
||||
const nameMatches = matchesIdentifierSearch(column.name, context.prefix) || matchesIdentifierSearch(column.displayLabel, context.prefix);
|
||||
const filterText = column.displayLabel !== column.name ? column.name : nameMatches ? undefined : column.comment?.trim() || context.prefix;
|
||||
return {
|
||||
label: column.displayLabel,
|
||||
filterText: column.displayLabel === column.name ? undefined : column.name,
|
||||
filterText,
|
||||
type: "column" as const,
|
||||
detail: buildColumnDetail(column),
|
||||
info: buildColumnInfo(column),
|
||||
@@ -6404,7 +6460,7 @@ function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByT
|
||||
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;
|
||||
if (context.prefix && !matchesColumnSearch(col, context.prefix)) continue;
|
||||
seen.add(key);
|
||||
items.push({
|
||||
label: col.name,
|
||||
@@ -6412,6 +6468,7 @@ function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByT
|
||||
detail: "non-aggregated column — required in GROUP BY",
|
||||
apply: quoteCompletionApplyIdentifier(col.name, dialect),
|
||||
boost: 2800 - items.length,
|
||||
comment: col.comment?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2219,4 +2219,36 @@ describe("connectionStore completion assistant", () => {
|
||||
|
||||
expect(listSchemas).toHaveBeenCalledTimes(52);
|
||||
});
|
||||
|
||||
it("searches local completion tables and column prefixes by comment (#12033)", async () => {
|
||||
const completionAssistantSearch = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{ name: "t_user", kind: "table", schema: "public", comment: "用户基础信息表" },
|
||||
{ name: "t_order", kind: "table", schema: "public", comment: "订单表" },
|
||||
],
|
||||
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.listCompletionTables("pg-1", "app", "", 20, "public");
|
||||
|
||||
// Match by Chinese comment
|
||||
const matchedByComment = store.lookupLocalCompletionTables("pg-1", "app", "用户", 20, "public");
|
||||
expect(matchedByComment.map((t) => t.name)).toEqual(["t_user"]);
|
||||
|
||||
// Match by comment pinyin initials
|
||||
const matchedByPinyin = store.lookupLocalCompletionTables("pg-1", "app", "ddb", 20, "public");
|
||||
expect(matchedByPinyin.map((t) => t.name)).toEqual(["t_order"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2798,6 +2798,16 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return normalized ? `→ ${normalized}` : undefined;
|
||||
}
|
||||
|
||||
function tableCommentFromDetail(detail: string | null | undefined): string | undefined {
|
||||
if (!detail) return undefined;
|
||||
const arrowIndex = detail.indexOf("→");
|
||||
if (arrowIndex >= 0) {
|
||||
const comment = detail.slice(arrowIndex + 1).trim();
|
||||
if (comment) return comment;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string, catalog?: string): SqlCompletionTable[] {
|
||||
return tables.map((table) => {
|
||||
const detail = completionTableDetail(table.comment);
|
||||
@@ -8737,36 +8747,47 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return 1_000 - gaps - text.length;
|
||||
}
|
||||
|
||||
function textMatchScore(text: string, normalized: string): number {
|
||||
if (!normalized) return 0;
|
||||
if (text === normalized) return 9_000 - text.length;
|
||||
if (text.startsWith(normalized)) return 7_500 - text.length;
|
||||
const segments = completionNameSegments(text);
|
||||
if (segments.some((segment) => segment.startsWith(normalized))) return 7_200 - text.length;
|
||||
const acronym = completionNameAcronym(text);
|
||||
if (acronym === normalized) return 7_100 - text.length;
|
||||
if (acronym.startsWith(normalized)) return 6_900 - text.length;
|
||||
// DataGrip-style pinyin initials for Han names, e.g. "zzj" → 总租金,
|
||||
// including ordered subsequences like "zj" → 总租金.
|
||||
if (/^[a-z0-9]+$/.test(normalized) && containsHan(text)) {
|
||||
const pinyinInitials = pinyinFirstLetters(text);
|
||||
if (pinyinInitials === normalized) return 7_050 - text.length;
|
||||
if (pinyinInitials.startsWith(normalized)) return 6_850 - text.length;
|
||||
const subsequence = orderedSubsequenceSpan(pinyinInitials, normalized);
|
||||
if (subsequence) return 5_000 - subsequence.first * 30 - subsequence.span * 10 - text.length;
|
||||
}
|
||||
if (normalized.length <= segments.length && segments.every((segment, index) => segment.startsWith(normalized[index] ?? ""))) return 6_700 - text.length;
|
||||
if (text.includes(normalized)) return 4_000 - text.length;
|
||||
const subsequenceScore = orderedSubsequenceScore(text, normalized);
|
||||
return subsequenceScore;
|
||||
}
|
||||
|
||||
function tableMatchScore(table: SqlCompletionTable, filter: string, preferredSchema?: string): number {
|
||||
const text = table.name.toLowerCase();
|
||||
const schema = table.schema?.toLowerCase();
|
||||
const normalized = filter.trim().toLowerCase();
|
||||
const score = schema && preferredSchema && schema === preferredSchema.toLowerCase() ? 10_000 : 0;
|
||||
if (!normalized) return score;
|
||||
if (text === normalized) return score + 9_000 - text.length;
|
||||
if (text.startsWith(normalized)) return score + 7_500 - text.length;
|
||||
const segments = completionNameSegments(table.name);
|
||||
if (segments.some((segment) => segment.startsWith(normalized))) return score + 7_200 - text.length;
|
||||
const acronym = completionNameAcronym(table.name);
|
||||
if (acronym === normalized) return score + 7_100 - text.length;
|
||||
if (acronym.startsWith(normalized)) return score + 6_900 - text.length;
|
||||
// DataGrip-style pinyin initials for Han names, e.g. "zzj" → 总租金,
|
||||
// including ordered subsequences like "zj" → 总租金.
|
||||
if (/^[a-z0-9]+$/.test(normalized) && containsHan(text)) {
|
||||
const pinyinInitials = pinyinFirstLetters(text);
|
||||
if (pinyinInitials === normalized) return score + 7_050 - text.length;
|
||||
if (pinyinInitials.startsWith(normalized)) return score + 6_850 - text.length;
|
||||
const subsequence = orderedSubsequenceSpan(pinyinInitials, normalized);
|
||||
if (subsequence) return score + 5_000 - subsequence.first * 30 - subsequence.span * 10 - text.length;
|
||||
}
|
||||
if (normalized.length <= segments.length && segments.every((segment, index) => segment.startsWith(normalized[index] ?? ""))) return score + 6_700 - text.length;
|
||||
if (text.includes(normalized)) return score + 4_000 - text.length;
|
||||
const subsequenceScore = orderedSubsequenceScore(text, normalized);
|
||||
return subsequenceScore < 0 ? -1 : score + subsequenceScore;
|
||||
|
||||
const nameScore = textMatchScore(text, normalized);
|
||||
const comment = table.comment?.trim() || tableCommentFromDetail(table.detail);
|
||||
const commentScore = comment ? textMatchScore(comment.toLowerCase(), normalized) : -1;
|
||||
|
||||
const bestScore = Math.max(nameScore, commentScore > 0 ? commentScore - 50 : -1);
|
||||
return bestScore < 0 ? -1 : score + bestScore;
|
||||
}
|
||||
|
||||
function objectMatchScore(object: SqlCompletionObject, filter: string, preferredSchema?: string): number {
|
||||
const tableLike: SqlCompletionTable = { name: object.name, schema: object.schema };
|
||||
const tableLike: SqlCompletionTable = { name: object.name, schema: object.schema, comment: object.comment };
|
||||
return tableMatchScore(tableLike, filter, preferredSchema);
|
||||
}
|
||||
|
||||
@@ -8910,7 +8931,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
if (!reusable) return undefined;
|
||||
return {
|
||||
columns: reusable.columns.filter((column) => column.name.toLowerCase().startsWith(normalizedPrefix)),
|
||||
columns: reusable.columns.filter((column) => column.name.toLowerCase().startsWith(normalizedPrefix) || Boolean(column.comment?.toLowerCase().startsWith(normalizedPrefix))),
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
+91
@@ -4771,3 +4771,94 @@ test("CTE table completions follow identifierCase (#11400)", () => {
|
||||
assert.equal(cteItem?.apply, "my_cte", "should apply lowercase CTE name");
|
||||
});
|
||||
|
||||
test("table and column completions can be searched by comments (#12033)", () => {
|
||||
const tables = [
|
||||
{ name: "t_user", type: "table" as const, comment: "用户基础信息表" },
|
||||
{ name: "t_order", type: "table" as const, comment: "订单表" },
|
||||
{ name: "t_account", type: "table" as const, detail: "→ 账户中心" },
|
||||
{ name: "customer_summary", type: "table" as const, comment: "customer billing summary" },
|
||||
];
|
||||
const columnsByTable = new Map([
|
||||
[
|
||||
"t_user",
|
||||
[
|
||||
{ name: "user_id", table: "t_user", comment: "用户主键ID" },
|
||||
{ name: "nickname", table: "t_user", comment: "用户昵称" },
|
||||
{ name: "created_at", table: "t_user", comment: "创建时间" },
|
||||
{ name: "email_address", table: "t_user", comment: "user electronic mail" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Table matching by Chinese comment substring
|
||||
const hanTableItems = buildSqlCompletionItems("select * from 用", "select * from 用".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
assert.ok(hanTableItems.some((item) => item.label === "t_user"));
|
||||
assert.equal(hanTableItems.some((item) => item.label === "t_order"), false);
|
||||
|
||||
// Table matching by pinyin initials of comment
|
||||
const pinyinTableItems = buildSqlCompletionItems("select * from ddb", "select * from ddb".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
assert.ok(pinyinTableItems.some((item) => item.label === "t_order"));
|
||||
|
||||
// Table matching from detail
|
||||
const detailTableItems = buildSqlCompletionItems("select * from 账户", "select * from 账户".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
assert.ok(detailTableItems.some((item) => item.label === "t_account"));
|
||||
|
||||
// Table matching by English comment
|
||||
const enCommentTableItems = buildSqlCompletionItems("select * from billing", "select * from billing".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
assert.ok(enCommentTableItems.some((item) => item.label === "customer_summary"));
|
||||
|
||||
// Column matching by Chinese comment
|
||||
const hanColItems = buildSqlCompletionItems("select cj from t_user", "select cj".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
assert.ok(hanColItems.some((item) => item.label === "created_at"));
|
||||
|
||||
const hanColSubstringItems = buildSqlCompletionItems("select 昵称 from t_user", "select 昵称".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
assert.ok(hanColSubstringItems.some((item) => item.label === "nickname"));
|
||||
|
||||
// Column matching by English comment
|
||||
const enColItems = buildSqlCompletionItems("select mail from t_user", "select mail".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
assert.ok(enColItems.some((item) => item.label === "email_address"));
|
||||
|
||||
// Name match takes priority over comment match when query matches both
|
||||
const nameVsCommentTables = [
|
||||
{ name: "user_log", type: "table" as const, comment: "系统操作日志" },
|
||||
{ name: "t_history", type: "table" as const, comment: "user login history" },
|
||||
];
|
||||
const priorityItems = buildSqlCompletionItems("select * from user", "select * from user".length, {
|
||||
tables: nameVsCommentTables,
|
||||
columnsByTable: new Map(),
|
||||
databaseType: "mysql",
|
||||
});
|
||||
const userLogIndex = priorityItems.findIndex((item) => item.label === "user_log");
|
||||
const historyIndex = priorityItems.findIndex((item) => item.label === "t_history");
|
||||
assert.ok(userLogIndex >= 0 && historyIndex >= 0);
|
||||
assert.ok(userLogIndex < historyIndex, "Name match should rank ahead of comment match");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user