diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 1994c8394d..5e21a10495 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -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): 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): Array { @@ -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; }); } diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index b9533ef8a9..b4ee71cb39 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -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; diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts index dc8ab3eaa0..2f2310aa07 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 2afe1642a4..ba6e210cb3 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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, 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 ({ 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 { + 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, }); } } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index d2fff7cc45..686fc0b3a7 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -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"]); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index a8437b5f6c..0c0213bd0f 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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, }; } diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index fe6cdcaab0..f7fcee9fa9 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -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"); +}); +