🐛 fix(sidebar/kingbase): 修复函数列表重复叠加导致越刷越多

- 元数据 fallback:完整目录查询首次成功后停止,避免多条 SQL 结果叠加
- 函数去重:按 schema+name+type 大小写不敏感去重,折叠同名 overload
- 树节点 key 区分 func/proc,避免同名函数/过程 key 冲突引发虚拟列表刷屏
- 补充 Kingbase/MySQL 函数元数据加载单测锁定回归
This commit is contained in:
Syngnat
2026-07-09 09:20:33 +08:00
parent de868a2763
commit c1cf19ef67
3 changed files with 147 additions and 18 deletions

View File

@@ -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",
]);
});
});

View File

@@ -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<string, any>[],
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";

View File

@@ -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<string>();
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: <CodeOutlined />,
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: <CodeOutlined />,
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);