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:
lxk955
2026-10-20 00:05:10 +08:00
committed by GitHub
parent b1bdf20af4
commit 7ae993898b
7 changed files with 294 additions and 41 deletions
@@ -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);
});
});
+72 -15
View File
@@ -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"]);
});
});
+43 -22
View File
@@ -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
View File
@@ -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");
});