From c1cf19ef676f527158c600834cf0fd8197d33ee3 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 9 Jul 2026 09:20:33 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sidebar/kingbase):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=87=BD=E6=95=B0=E5=88=97=E8=A1=A8=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=8F=A0=E5=8A=A0=E5=AF=BC=E8=87=B4=E8=B6=8A=E5=88=B7?= =?UTF-8?q?=E8=B6=8A=E5=A4=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 元数据 fallback:完整目录查询首次成功后停止,避免多条 SQL 结果叠加 - 函数去重:按 schema+name+type 大小写不敏感去重,折叠同名 overload - 树节点 key 区分 func/proc,避免同名函数/过程 key 冲突引发虚拟列表刷屏 - 补充 Kingbase/MySQL 函数元数据加载单测锁定回归 --- .../sidebar/sidebarMetadataLoaders.test.ts | 96 +++++++++++++++++++ .../sidebar/sidebarMetadataLoaders.ts | 14 ++- .../sidebar/useSidebarTreeLoaders.tsx | 55 +++++++---- 3 files changed, 147 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts index 6292d566..3305d279 100644 --- a/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts @@ -6,9 +6,11 @@ vi.mock("../../../wailsjs/go/app/App", () => ({ import { DBQuery } from "../../../wailsjs/go/app/App"; import { + buildFunctionsMetadataQuerySpecs, buildPackagesMetadataQuerySpecs, buildSchemasMetadataQuerySpecs, buildSequencesMetadataQuerySpecs, + loadFunctions, loadPackages, loadSequences, loadViews, @@ -132,3 +134,97 @@ describe("Oracle object metadata loaders", () => { }); }); }); + +describe("Kingbase/PG routine metadata loaders", () => { + it("builds multi-step function fallback queries for kingbase", () => { + const specs = buildFunctionsMetadataQuerySpecs("kingbase", "ldf_server_dbs"); + expect(specs.length).toBeGreaterThanOrEqual(2); + expect(specs[0]?.sql).toContain("pg_proc"); + expect(specs.some((spec) => spec.sql.includes("information_schema.routines"))).toBe(true); + }); + + it("does not stack the same kingbase function when multiple catalog fallbacks succeed", async () => { + let queryCount = 0; + mockedDBQuery.mockImplementation(async (_config: unknown, _dbName: string, sql: string) => { + queryCount += 1; + if (sql.includes("pg_proc") && sql.includes("prokind")) { + return { + success: true, + message: "", + data: [ + { schema_name: "ldf_server", routine_name: "p1", routine_type: "FUNCTION" }, + { schema_name: "ldf_server", routine_name: "p1", routine_type: "PROCEDURE" }, + { schema_name: "ldf_server", routine_name: "pk_zero_fn", routine_type: "FUNCTION" }, + // overload rows with same name/type must collapse + { schema_name: "ldf_server", routine_name: "p1", routine_type: "FUNCTION" }, + { schema_name: "LDF_SERVER", routine_name: "p1", routine_type: "FUNCTION" }, + ], + }; + } + if (sql.includes("information_schema.routines")) { + return { + success: true, + message: "", + data: [ + { schema_name: "ldf_server", routine_name: "p1", routine_type: "FUNCTION" }, + { schema_name: "ldf_server", routine_name: "p1", routine_type: "PROCEDURE" }, + { schema_name: "ldf_server", routine_name: "pk_zero_fn", routine_type: "FUNCTION" }, + ], + }; + } + if (sql.includes("pg_proc")) { + return { + success: true, + message: "", + data: [ + { schema_name: "ldf_server", routine_name: "p1", routine_type: "FUNCTION" }, + { schema_name: "ldf_server", routine_name: "p1", routine_type: "FUNCTION" }, + { schema_name: "ldf_server", routine_name: "pk_zero_fn", routine_type: "FUNCTION" }, + ], + }; + } + return { success: false, message: "", data: [] }; + }); + + const result = await loadFunctions({ config: { type: "kingbase" } }, "ldf_server_dbs"); + + expect(result.supported).toBe(true); + // First full catalog success must short-circuit fallback queries. + expect(queryCount).toBe(1); + const p1Funcs = result.routines.filter((item) => item.routineName.toLowerCase().endsWith(".p1") && item.routineType === "FUNCTION"); + const p1Procs = result.routines.filter((item) => item.routineName.toLowerCase().endsWith(".p1") && item.routineType === "PROCEDURE"); + expect(p1Funcs).toHaveLength(1); + expect(p1Procs).toHaveLength(1); + expect(result.routines.filter((item) => item.routineName.toLowerCase().includes("pk_zero_fn"))).toHaveLength(1); + }); + + it("still collects complementary SHOW FUNCTION/PROCEDURE fallbacks for MySQL", async () => { + mockedDBQuery.mockImplementation(async (_config: unknown, _dbName: string, sql: string) => { + if (sql.includes("information_schema.routines")) { + return { success: false, message: "no routines view", data: [] }; + } + if (sql.includes("SHOW FUNCTION STATUS")) { + return { + success: true, + message: "", + data: [{ Db: "app", Name: "fn_a", Type: "FUNCTION" }], + }; + } + if (sql.includes("SHOW PROCEDURE STATUS")) { + return { + success: true, + message: "", + data: [{ Db: "app", Name: "sp_b", Type: "PROCEDURE" }], + }; + } + return { success: false, message: "", data: [] }; + }); + + const result = await loadFunctions({ config: { type: "mysql" } }, "app"); + expect(result.supported).toBe(true); + expect(result.routines.map((item) => `${item.routineType}:${item.routineName}`).sort()).toEqual([ + "FUNCTION:app.fn_a", + "PROCEDURE:app.sp_b", + ]); + }); +}); diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts index fcd7b91f..0f8fde7e 100644 --- a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts @@ -733,8 +733,14 @@ const queryMetadataRowsBySpecs = async ( const config = buildSidebarRuntimeConfig(conn, dbName); const results: MetadataQueryResult[] = []; let hasSuccessfulQuery = false; + // Full queries (no inferredType) are mutually exclusive fallbacks: first success wins. + // Partial queries (inferredType set) are complementary (e.g. SHOW FUNCTION + SHOW PROCEDURE). + let hasFullSuccess = false; for (const spec of normalizedSpecs) { + if (hasFullSuccess) { + break; + } try { const result = await DBQuery( buildRpcConnectionConfig(config) as any, @@ -749,6 +755,11 @@ const queryMetadataRowsBySpecs = async ( rows: result.data as Record[], inferredType: spec.inferredType, }); + if (!spec.inferredType) { + // Primary/fallback full catalog query succeeded — do not merge later fallbacks + // (Kingbase/PG 上多条成功会把同一函数叠加多次). + hasFullSuccess = true; + } } catch { // 忽略单条查询失败,继续尝试后续回退语句 } @@ -1013,7 +1024,8 @@ const loadFunctions = async ( ? "PROCEDURE" : "FUNCTION"; const fullName = buildQualifiedName(schemaName, routineName); - const uniqueKey = `${fullName}@@${normalizedType}`; + // Case-insensitive dedupe: Kingbase/PG 多条 catalog SQL 或 overload 行容易同名大小写不一致 + const uniqueKey = `${fullName.toLowerCase()}@@${normalizedType}`; if (!fullName || seen.has(uniqueKey)) return; seen.add(uniqueKey); const typeLabel = normalizedType === "PROCEDURE" ? "P" : "F"; diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx index 7e8a8d46..0ea9447f 100644 --- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -705,15 +705,31 @@ export const useSidebarTreeLoaders = ({ return deduped; })(); - const routineEntries = routineRows.map((routine: any) => { - const parsed = splitQualifiedName(routine.routineName); - const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F'; - return { - ...routine, - schemaName: parsed.schemaName, - displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`, - }; - }); + const routineEntries = (() => { + const deduped: Array<{ routineName: string; routineType: string; schemaName: string; displayName: string }> = []; + const routineSeen = new Set(); + routineRows.forEach((routine: any) => { + const parsed = splitQualifiedName(routine.routineName); + const routineType = String(routine.routineType || 'FUNCTION').toUpperCase().includes('PROC') + ? 'PROCEDURE' + : 'FUNCTION'; + const schemaName = String(parsed.schemaName || routine.schemaName || '').trim(); + const objectName = String(parsed.objectName || routine.routineName || '').trim(); + if (!objectName) return; + const routineName = String(routine.routineName || objectName).trim(); + const typeLabel = routineType === 'PROCEDURE' ? 'P' : 'F'; + const dedupeKey = `${schemaName.toLowerCase()}@@${objectName.toLowerCase()}@@${routineType}`; + if (routineSeen.has(dedupeKey)) return; + routineSeen.add(dedupeKey); + deduped.push({ + routineName, + routineType, + schemaName, + displayName: `${objectName} [${typeLabel}]`, + }); + }); + return deduped; + })(); const sequenceEntries = sequenceRows.map((sequence: any) => { const parsed = splitQualifiedName(sequence.sequenceName); @@ -858,14 +874,19 @@ export const useSidebarTreeLoaders = ({ isLeaf: true, }); - const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({ - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`, - icon: , - type: 'routine', - dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName }, - isLeaf: true, - }); + const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => { + const typeToken = entry.routineType === 'PROCEDURE' ? 'proc' : 'func'; + const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.routineName); + return { + title: entry.displayName, + // 必须带 routineType:同名函数/过程否则 key 冲突,虚拟列表会叠成“同一函数无限重复” + key: `${conn.id}-${conn.dbName}-routine-${typeToken}-${keyName}`, + icon: , + type: 'routine', + dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName }, + isLeaf: true, + }; + }; const buildSequenceNode = (entry: { sequenceName: string; schemaName: string; displayName: string }): TreeNode => { const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.sequenceName);