fix(tree): preserve object group metadata routing

This commit is contained in:
Elias 2026-07-19 14:56:35 +08:00 committed by GitHub
parent 01ecb6020c
commit 7b14ef3b48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 155 additions and 4 deletions

View File

@ -65,6 +65,7 @@ import * as api from "@/lib/backend/api";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint";
import {
@ -513,6 +514,11 @@ async function toggle() {
}
try {
if (await loadSidebarObjectGroup(node, connectionStore)) {
emit("node-toggled", node, wasExpanded);
return;
}
if (node.type === "connection" && node.connectionId) {
const config = connectionStore.getConfig(node.connectionId);
if (config?.db_type === "redis") {
@ -623,10 +629,6 @@ async function toggle() {
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (databaseObjectGroup) {
await connectionStore.loadObjectGroupChildren(node);
}
emit("node-toggled", node, wasExpanded);
} catch (e: any) {

View File

@ -0,0 +1,125 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import type { ConnectionConfig, ObjectInfo, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function mysqlConnection(): ConnectionConfig {
return {
id: "mysql-1",
name: "MySQL",
db_type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
database: "app",
} as ConnectionConfig;
}
function objectGroup(type: "group-triggers" | "group-types", id: string): TreeNode {
return {
id,
label: type === "group-triggers" ? "tree.triggers" : "tree.types",
type,
connectionId: "mysql-1",
database: "app",
schema: "app",
isExpanded: false,
children: [],
};
}
async function createStore({ listObjects, listTriggers }: { listObjects: ReturnType<typeof vi.fn>; listTriggers: ReturnType<typeof vi.fn> }) {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listObjects,
listTriggers,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
const connection = mysqlConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
useSettingsStore().desktopSettings.sidebar_table_page_size = 10;
return { connection, store };
}
describe("sidebar object-group routing", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("uses listObjects for schema-level trigger and type groups, including empty results", async () => {
const listObjects = vi.fn<() => Promise<ObjectInfo[]>>().mockResolvedValue([]);
const listTriggers = vi.fn<() => Promise<never[]>>().mockResolvedValue([]);
const { connection, store } = await createStore({ listObjects, listTriggers });
const triggerGroup = objectGroup("group-triggers", `${connection.id}:app:app:__triggers`);
const typeGroup = objectGroup("group-types", `${connection.id}:app:app:__types`);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [triggerGroup, typeGroup] }];
const storedTriggerGroup = store.treeNodes[0].children![0];
const storedTypeGroup = store.treeNodes[0].children![1];
await loadSidebarObjectGroup(storedTriggerGroup, store);
await loadSidebarObjectGroup(storedTypeGroup, store);
expect(listObjects).toHaveBeenNthCalledWith(1, connection.id, "app", "app", ["TRIGGER"], undefined, 11, 0);
expect(listObjects).toHaveBeenNthCalledWith(2, connection.id, "app", "app", ["TYPE", "TYPE_BODY"], undefined, 11, 0);
expect(listTriggers).not.toHaveBeenCalled();
expect(storedTriggerGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] });
expect(storedTypeGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] });
});
it("keeps table-level trigger groups on listTriggers", async () => {
const listObjects = vi.fn<() => Promise<ObjectInfo[]>>().mockResolvedValue([]);
const listTriggers = vi.fn<() => Promise<never[]>>().mockResolvedValue([]);
const { connection, store } = await createStore({ listObjects, listTriggers });
const tableTriggerGroup: TreeNode = {
...objectGroup("group-triggers", `${connection.id}:app:app:orders:__triggers`),
tableName: "orders",
};
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tableTriggerGroup] }];
const storedTriggerGroup = store.treeNodes[0].children![0];
await loadSidebarObjectGroup(storedTriggerGroup, store);
expect(listTriggers).toHaveBeenCalledWith(connection.id, "app", "app", "orders", undefined);
expect(listObjects).not.toHaveBeenCalled();
expect(storedTriggerGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] });
});
it("propagates rejected schema-level metadata while clearing the loading state", async () => {
const listObjects = vi.fn<() => Promise<ObjectInfo[]>>().mockRejectedValue(new Error("metadata access denied"));
const listTriggers = vi.fn<() => Promise<never[]>>().mockResolvedValue([]);
const { connection, store } = await createStore({ listObjects, listTriggers });
const triggerGroup = objectGroup("group-triggers", `${connection.id}:app:app:__triggers`);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [triggerGroup] }];
const storedTriggerGroup = store.treeNodes[0].children![0];
await expect(loadSidebarObjectGroup(storedTriggerGroup, store)).rejects.toThrow("metadata access denied");
expect(listObjects).toHaveBeenCalledWith(connection.id, "app", "app", ["TRIGGER"], undefined, 11, 0);
expect(listTriggers).not.toHaveBeenCalled();
expect(storedTriggerGroup).toMatchObject({ isExpanded: false, isLoading: false, children: [] });
});
});

View File

@ -0,0 +1,24 @@
import type { TreeNode } from "@/types/database";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
export interface SidebarObjectGroupLoaders {
loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string): Promise<void>;
loadObjectGroupChildren(node: TreeNode): Promise<void>;
}
/**
* Loads object-group metadata from the same path used by the sidebar toggle.
* Table trigger groups are special because they are scoped to one table;
* schema-level trigger groups are regular database object groups.
*/
export async function loadSidebarObjectGroup(node: TreeNode, loaders: SidebarObjectGroupLoaders): Promise<boolean> {
if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loaders.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
return true;
}
if (!objectTypesForGroupNode(node.type)) return false;
await loaders.loadObjectGroupChildren(node);
return true;
}