From 8d23216d60373159d806d57f2336bf8d54507dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Mon, 24 Aug 2026 20:36:58 +0800 Subject: [PATCH] feat(completion): show table metadata comments in SQL completions --- .../metadata/completionTreeIndex.spec.ts | 3 +- .../sql/sqlCompletion.context.spec.ts | 46 +++++++++++++ .../src/lib/metadata/completionTreeIndex.ts | 7 ++ apps/desktop/src/lib/sql/sqlCompletion.ts | 4 +- .../connectionStore.completion.spec.ts | 16 ++--- apps/desktop/src/stores/connectionStore.ts | 68 +++++++++---------- packages/app-tests/sqlCompletion.test.ts | 9 ++- 7 files changed, 104 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts b/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts index 680ecc4150..7555f80ec0 100644 --- a/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts +++ b/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts @@ -41,6 +41,7 @@ describe("completionTreeIndex", () => { connectionId: "conn", database: "hive", schema: "sales_analytics", + comment: "Daily revenue summary", }, { id: "conn:hive:sales_analytics:__tables:daily_revenue_view", @@ -70,7 +71,7 @@ describe("completionTreeIndex", () => { expect(completionSchemasFromTree(tree, "conn", "hive")).toEqual(["sales_analytics"]); const expected = [ - { name: "daily_revenue", schema: "sales_analytics", type: "table" }, + { name: "daily_revenue", schema: "sales_analytics", type: "table", detail: "→ Daily revenue summary" }, { name: "daily_revenue_view", schema: "sales_analytics", type: "view" }, { name: "daily_revenue_mv", schema: "sales_analytics", type: "materialized_view" }, ]; diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index 32bb1f5f18..ec81659aad 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -801,6 +801,52 @@ describe("sqlCompletion scoped context classification", () => { }); describe("sqlCompletion scoped metadata ranking", () => { + it("shows a table metadata detail in the completion item", () => { + const sql = "SELECT * FROM orders"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", type: "table", detail: "→ Customer orders" }], + columnsByTable: new Map(), + }).filter((item) => item.type === "table"); + + expect(items).toEqual([expect.objectContaining({ label: "orders", detail: "→ Customer orders" })]); + }); + + it("hides the redundant table type while preserving view and schema details", () => { + const simpleItems = buildSqlCompletionItems("SELECT * FROM orders", "SELECT * FROM orders".length, { + tables: [{ name: "orders", schema: "public", type: "table" }], + columnsByTable: new Map(), + }).filter((item) => item.type === "table"); + + expect(simpleItems.find((item) => item.label === "orders")?.detail).toBeUndefined(); + + const viewItems = buildSqlCompletionItems("SELECT * FROM order_view", "SELECT * FROM order_view".length, { + tables: [{ name: "order_view", schema: "public", type: "view" }], + columnsByTable: new Map(), + }).filter((item) => item.type === "table"); + + expect(viewItems.find((item) => item.label === "order_view")?.detail).toBe("view"); + + const duplicateItems = buildSqlCompletionItems("SELECT * FROM orders", "SELECT * FROM orders".length, { + tables: [ + { name: "orders", schema: "archive", type: "table" }, + { name: "orders", schema: "sales", type: "table" }, + ], + columnsByTable: new Map(), + }).filter((item) => item.type === "table"); + + expect(duplicateItems.map((item) => item.detail).sort()).toEqual(["archive.orders", "sales.orders"]); + + const annotatedDuplicateItems = buildSqlCompletionItems("SELECT * FROM orders", "SELECT * FROM orders".length, { + tables: [ + { name: "orders", schema: "archive", type: "table", detail: "→ Archived orders" }, + { name: "orders", schema: "sales", type: "table", detail: "→ Current orders" }, + ], + columnsByTable: new Map(), + }).filter((item) => item.type === "table"); + + expect(annotatedDuplicateItems.map((item) => item.detail).sort()).toEqual(["archive.orders → Archived orders", "sales.orders → Current orders"]); + }); + it("ranks exact and prefix table matches ahead of contains/fuzzy matches", () => { const sql = "SELECT * FROM Temp"; const items = buildSqlCompletionItems(sql, sql.length, { diff --git a/apps/desktop/src/lib/metadata/completionTreeIndex.ts b/apps/desktop/src/lib/metadata/completionTreeIndex.ts index 0438af78db..6a35d09071 100644 --- a/apps/desktop/src/lib/metadata/completionTreeIndex.ts +++ b/apps/desktop/src/lib/metadata/completionTreeIndex.ts @@ -4,6 +4,11 @@ import { isTdengineStableTableType } from "@/lib/table/tableEditing"; const TABLE_NODE_TYPES = new Set(["table", "view", "materialized_view"]); +function completionTableDetail(comment: string | null | undefined): string | undefined { + const normalized = comment?.trim(); + return normalized ? `→ ${normalized}` : undefined; +} + export function completionSchemasFromTree(nodes: readonly TreeNode[], connectionId: string, database: string): string[] { const seen = new Set(); const schemas: string[] = []; @@ -27,11 +32,13 @@ export function completionTablesFromTree(nodes: readonly TreeNode[], connectionI // the internal catalog. Keep the two metadata scopes isolated. if ((node.catalog?.toLowerCase() ?? undefined) !== preferredCatalog) return; if (preferredSchema && node.schema?.toLowerCase() !== preferredSchema) return; + const detail = completionTableDetail(node.comment); tables.push({ name: node.tableName || node.label, catalog: node.catalog, schema: node.schema, type: node.type === "materialized_view" ? "materialized_view" : node.type === "view" ? "view" : "table", + ...(detail ? { detail } : {}), ...(isTdengineStableTableType(node.tableType) ? { tableType: node.tableType } : {}), }); }); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index a458174717..27b8350aae 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -3372,10 +3372,12 @@ function buildTableItems( const suppliedApplyNameIsQualified = suppliedApplyName?.includes(".") === true; const applyName = qualifiedByContext ? quoteCompletionApplyIdentifier(table.name, dialect) : ambiguousTableName && !!table.schema && (!suppliedApplyName || !suppliedApplyNameIsQualified) ? defaultApplyName : (suppliedApplyName ?? 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)); return { label: table.name, type: "table" as const, - detail: table.detail ?? (table.schema ? `${table.schema}.${table.name}` : table.type), + detail, apply: formatTableAliasApply(applyName, alias, databaseType, keywordCase), boost: computeBoost(table.name, prefix) + 1000 + (table.boost ?? 0), dedupeKey: table.applyName || ambiguousTableName || (databaseType === "oracle" && table.schema) ? applyName : undefined, diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 253f69d9e5..5364e59f4f 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -138,7 +138,7 @@ describe("connectionStore completion assistant", () => { it("does not replace the active connection during a cold metadata search", async () => { const connectDb = vi.fn().mockResolvedValue("pg-1"); const completionAssistantSearch = vi.fn().mockResolvedValue({ - candidates: [{ name: "users", kind: "table", schema: "public" }], + candidates: [{ name: "users", kind: "table", schema: "public", comment: "Application users" }], incomplete: false, fallback_used: false, }); @@ -161,7 +161,7 @@ describe("connectionStore completion assistant", () => { expect(connectDb).toHaveBeenCalledOnce(); expect(store.connectedIds.has("pg-1")).toBe(true); expect(store.activeConnectionId).toBe("already-active"); - expect(tables).toEqual([{ name: "users", schema: "public", type: "table" }]); + expect(tables).toEqual([{ name: "users", schema: "public", type: "table", detail: "→ Application users" }]); }); it("falls back to the server COMMAND catalog when COMMAND DOCS is unsupported", async () => { @@ -357,7 +357,7 @@ describe("connectionStore completion assistant", () => { it("returns fallback metadata when assistant table search fails", async () => { const completionAssistantSearch = vi.fn().mockRejectedValue(new Error("assistant unavailable")); - const listTables = vi.fn().mockResolvedValue([{ name: "accounts", table_type: "BASE TABLE", comment: null }]); + const listTables = vi.fn().mockResolvedValue([{ name: "accounts", table_type: "BASE TABLE", comment: "Customer accounts" }]); vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/backend/api", () => ({ @@ -376,7 +376,7 @@ describe("connectionStore completion assistant", () => { expect(completionAssistantSearch).toHaveBeenCalledTimes(1); expect(listTables).toHaveBeenCalledWith("pg-1", "app", "public", "acc", 20); - expect(tables).toEqual([{ name: "accounts", schema: "public", type: "table" }]); + expect(tables).toEqual([{ name: "accounts", schema: "public", type: "table", detail: "→ Customer accounts" }]); }); it("keeps schema-qualified local table completion scoped to the selected schema", async () => { @@ -463,8 +463,8 @@ describe("connectionStore completion assistant", () => { it("maps global Oracle tables with safe qualification and schema priority", async () => { const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [ - { name: "DEPT_DICT", kind: "table", schema: "APP", data_type: "TABLE" }, - { name: "DEPT_DICT", kind: "view", schema: "COMM", data_type: "VIEW" }, + { name: "DEPT_DICT", kind: "table", schema: "APP", data_type: "TABLE", comment: "Department dictionary" }, + { name: "DEPT_DICT", kind: "view", schema: "COMM", data_type: "VIEW", comment: "Department dictionary" }, { name: "V_DEPT_DICT", kind: "view", schema: "SYS", data_type: "VIEW" }, { name: "DEPT_DICT_ALIAS", kind: "table", schema: "PUBLIC", data_type: "SYNONYM" }, ], @@ -488,8 +488,8 @@ describe("connectionStore completion assistant", () => { expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema: "APP", parent_schema: null, global_search: true, mask: "DEPT_D" })); expect(tables).toEqual([ - expect.objectContaining({ name: "DEPT_DICT", schema: "APP", applyName: "DEPT_DICT", boost: 2400 }), - expect.objectContaining({ name: "DEPT_DICT", schema: "COMM", applyName: "COMM.DEPT_DICT", boost: 0 }), + expect.objectContaining({ name: "DEPT_DICT", schema: "APP", detail: "APP · table → Department dictionary", applyName: "DEPT_DICT", boost: 2400 }), + expect.objectContaining({ name: "DEPT_DICT", schema: "COMM", detail: "COMM · view → Department dictionary", applyName: "COMM.DEPT_DICT", boost: 0 }), expect.objectContaining({ name: "V_DEPT_DICT", schema: "SYS", applyName: "SYS.V_DEPT_DICT", boost: -1200 }), expect.objectContaining({ name: "DEPT_DICT_ALIAS", schema: "PUBLIC", applyName: "DEPT_DICT_ALIAS", detail: "PUBLIC · synonym", boost: 1200 }), ]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d7194b0d94..f5679bf6f4 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1951,13 +1951,23 @@ export const useConnectionStore = defineStore("connection", () => { return supportsPackageMemberExpansion(databaseType) ? markPackageNodesExpandable(children) : children; } - function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string): SqlCompletionTable[] { - return tables.map((table) => ({ - name: table.name, - schema, - type: sqlObjectNavigationTypeFromTableType(table.table_type), - ...completionStableTableType(table.table_type), - })); + function completionTableDetail(comment: string | null | undefined): string | undefined { + const normalized = comment?.trim(); + return normalized ? `→ ${normalized}` : undefined; + } + + function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string, catalog?: string): SqlCompletionTable[] { + return tables.map((table) => { + const detail = completionTableDetail(table.comment); + return { + name: table.name, + catalog, + schema, + type: sqlObjectNavigationTypeFromTableType(table.table_type), + ...(detail ? { detail } : {}), + ...completionStableTableType(table.table_type), + }; + }); } function completionStableTableType(tableType: string | null | undefined): Partial> { @@ -6907,16 +6917,19 @@ export const useConnectionStore = defineStore("connection", () => { return candidates .filter((candidate) => candidate.kind === "table" || candidate.kind === "view") .map((candidate) => { + const detail = completionTableDetail(candidate.comment); const table: SqlCompletionTable = { name: candidate.name, schema: candidate.schema ?? undefined, type: sqlObjectNavigationTypeFromTableType(candidate.data_type || candidate.kind), + ...(detail ? { detail } : {}), ...completionStableTableType(candidate.data_type), }; if (!withOracleMetadata) return table; + const metadataDetail = candidate.schema ? `${candidate.schema} · ${(candidate.data_type || candidate.kind).toLowerCase()}` : candidate.kind; return { ...table, - detail: candidate.schema ? `${candidate.schema} · ${(candidate.data_type || candidate.kind).toLowerCase()}` : candidate.kind, + detail: table.detail ? `${metadataDetail} ${table.detail}` : metadataDetail, applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema), boost: completionCandidateSchemaBoost(candidate.schema, preferredSchema), }; @@ -7506,13 +7519,7 @@ export const useConnectionStore = defineStore("connection", () => { } catch { if (schema) { const tables = await listCompletionTableMetadata(connectionId, database, schema, trimmedFilter, limit, catalog); - results = tables.map((table) => ({ - name: table.name, - catalog, - schema, - type: sqlObjectNavigationTypeFromTableType(table.table_type), - ...completionStableTableType(table.table_type), - })); + results = tableInfosToCompletionTables(tables, schema, catalog); } else { results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); } @@ -7527,13 +7534,7 @@ export const useConnectionStore = defineStore("connection", () => { } else if (schema) { try { const tables = await listCompletionTableMetadata(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit), catalog); - results = tables.map((table) => ({ - name: table.name, - catalog, - schema, - type: sqlObjectNavigationTypeFromTableType(table.table_type), - ...completionStableTableType(table.table_type), - })); + results = tableInfosToCompletionTables(tables, schema, catalog); } catch { results = []; } @@ -7552,13 +7553,7 @@ export const useConnectionStore = defineStore("connection", () => { let scopedTables: SqlCompletionTable[]; if (schema) { const tables = await listCompletionTableMetadata(connectionId, database, schema, undefined, undefined, catalog); - scopedTables = tables.map((table) => ({ - name: table.name, - catalog, - schema, - type: sqlObjectNavigationTypeFromTableType(table.table_type), - ...completionStableTableType(table.table_type), - })); + scopedTables = tableInfosToCompletionTables(tables, schema, catalog); } else { scopedTables = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); } @@ -7575,12 +7570,7 @@ export const useConnectionStore = defineStore("connection", () => { tables = await listCompletionTableMetadata(connectionId, database, querySchema, relaxedFilter, expandedCompletionLimit(limit), catalog); } if (requestRevision !== completionCacheRevision(connectionId, database)) return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog, options); - completionTablesCache.value[cacheKey] = tables.map((table) => ({ - name: table.name, - catalog, - type: sqlObjectNavigationTypeFromTableType(table.table_type), - ...completionStableTableType(table.table_type), - })); + completionTablesCache.value[cacheKey] = tableInfosToCompletionTables(tables, undefined, catalog); completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey]; indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey], catalog); evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); @@ -7609,7 +7599,13 @@ export const useConnectionStore = defineStore("connection", () => { if (existingIndex != null) { const existing = deduped[existingIndex]; // Loaded tree metadata can distinguish materialized views even when an older completion endpoint only reports VIEW. - deduped[existingIndex] = { ...table, ...existing, type: mergeSqlObjectNavigationType(existing.type, table.type) }; + const detail = existing.detail ?? table.detail; + deduped[existingIndex] = { + ...table, + ...existing, + ...(detail ? { detail } : {}), + type: mergeSqlObjectNavigationType(existing.type, table.type), + }; continue; } indexByKey.set(key, deduped.length); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index b6e154632b..3c543841d3 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1498,7 +1498,8 @@ test("keeps database-qualified FROM input in table suggestion mode", () => { assert.equal(context.exclusiveColumnSuggestions, false); assert.deepEqual( items.map((item) => [item.label, item.type, item.detail]), - [["orders", "table", "other_db.orders"]], + // 单一非同名表不再展示冗余 schema 详情(PR 只在跨 schema 同名表时保留区分信息) + [["orders", "table", undefined]], ); }); @@ -1572,7 +1573,8 @@ test("includes views in exclusive FROM object suggestions", () => { const tableItems = items.filter((item) => item.type === "table"); assert.deepEqual( tableItems.map((item) => [item.label, item.type, item.detail]), - [["ticket_summary", "table", "public.ticket_summary"]], + // 视图保留类型提示,替代旧的 schema 前缀详情 + [["ticket_summary", "table", "view"]], ); }); @@ -1835,7 +1837,8 @@ test("suggests matching table names for partial table input", () => { const tableItems = items.filter((item) => item.type === "table"); assert.deepEqual( tableItems.map((item) => [item.label, item.type, item.detail]), - [["ihli_data", "table", "public.ihli_data"]], + // 单一非同名表不再展示冗余 schema 详情(PR 只在跨 schema 同名表时保留区分信息) + [["ihli_data", "table", undefined]], ); });