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);