diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index e4d1a3596f..1ee21fd9e1 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -1605,8 +1605,14 @@ function onKeydown(event: KeyboardEvent) { node.type === 'group-materialized-views' || node.type === 'group-procedures' || node.type === 'group-functions' || + node.type === 'group-columns' || + node.type === 'group-indexes' || + node.type === 'group-fkeys' || node.type === 'group-triggers' || node.type === 'group-events' || + node.type === 'group-constraints' || + node.type === 'group-table-partitions' || + node.type === 'group-table-subpartitions' || node.type === 'group-sequences' || node.type === 'group-synonyms' || node.type === 'group-jobs' || diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.tableMetadataCounts.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.tableMetadataCounts.spec.ts new file mode 100644 index 0000000000..44b92c3ac4 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.tableMetadataCounts.spec.ts @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import TreeItem from "@/components/sidebar/TreeItem.vue"; +import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime"; +import type { TreeNode } from "@/types/database"; + +const connectionStore = { + activeConnectionId: "connection-1", + connectedIds: new Set(["connection-1"]), + connectingIds: new Set(), + connectionMultiSelectActive: false, + connections: [], + getConfig: () => ({ id: "connection-1", db_type: "postgres" }), + isDefaultDatabase: () => false, + isDefaultSchema: () => false, + isPinnedTreeNodeReorderTarget: () => false, + isTreeNodeChildrenLoaded: () => false, + isTreeNodePinned: () => false, + selectedTreeNodeId: null as string | null, + selectedTreeNodeIds: [] as string[], + selectedTreeNodeIdsSet: new Set(), + sidebarTableSearchQueries: {}, + tableNameFilterForScope: () => undefined, + treeNodes: [], + treeSelectionAnchorId: null as string | null, +}; + +vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => connectionStore })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openDatabaseKeys: new Set() }) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { openDataInNewTab: "" }, + sidebarActivation: "double", + sidebarAllowHorizontalScroll: false, + sidebarHiddenTablePrefixes: [], + sidebarObjectInfoMode: "none", + }, + }), +})); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +const mountedApps: App[] = []; + +function runtimeHost(): SidebarTreeRuntimeHost { + return { + buildContextMenu: vi.fn(() => []), + handleRowClick: vi.fn(), + handleRowDoubleClick: vi.fn(), + handleRowKeydown: vi.fn(), + openPrimaryVisibleFilter: vi.fn(), + openDataInNewTab: vi.fn(), + requestPaste: vi.fn(() => false), + toggleNode: vi.fn(), + }; +} + +async function mountTreeItem(node: TreeNode) { + const container = document.createElement("div"); + document.body.append(container); + const runtime = createSidebarTreeRuntime(); + runtime.bindHost(runtimeHost()); + const app = createApp(defineComponent({ setup: () => () => h(TreeItem, { node, depth: 2 }) })); + mountedApps.push(app); + app.use(i18n); + app.provide(sidebarTreeRuntimeKey, runtime); + app.mount(container); + await nextTick(); + return container; +} + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.replaceChildren(); +}); + +describe("TreeItem table metadata counts", () => { + it("renders counts for loaded table metadata groups, including empty groups", async () => { + const groups = [ + ["group-columns", "tree.columns", 2], + ["group-indexes", "tree.indexes", 1], + ["group-fkeys", "tree.foreignKeys", 3], + ["group-triggers", "tree.triggers", 0], + ["group-constraints", "tree.constraints", 4], + ["group-table-partitions", "tree.partitions", 5], + ["group-table-subpartitions", "tree.subpartitions", 6], + ] as const; + + for (const [type, label, objectCount] of groups) { + const container = await mountTreeItem({ + id: `connection-1:app:orders:${type}`, + label, + type, + connectionId: "connection-1", + database: "app", + tableName: "orders", + objectCount, + children: [], + }); + expect(container.textContent).toContain(String(objectCount)); + } + }); +}); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.xuguTableChildren.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.xuguTableChildren.spec.ts index 7b14f4aae0..08d6630d34 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.xuguTableChildren.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.xuguTableChildren.spec.ts @@ -149,6 +149,68 @@ describe("connectionStore Xugu table child metadata", () => { expect(subpartitions.isExpanded).toBe(true); }); + it("records exact counts for every successfully loaded table metadata group", async () => { + const getColumns = vi.fn().mockResolvedValue([ + { name: "ORDER_ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "CUSTOMER_ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + ]); + const listIndexes = vi.fn().mockResolvedValue([{ name: "IDX_CUSTOMER", columns: ["CUSTOMER_ID"], unique: false }]); + const listForeignKeys = vi.fn().mockResolvedValue([{ name: "FK_CUSTOMER", column: "CUSTOMER_ID", ref_table: "CUSTOMERS", ref_column: "ID" }]); + const listTriggers = vi.fn().mockResolvedValue([{ name: "TR_AUDIT", timing: "BEFORE", event: "INSERT" }]); + const listConstraints = vi.fn().mockResolvedValue([{ name: "PK_SHOP_ORDERS", constraint_type: "PRIMARY KEY", valid: true }]); + const listPartitions = vi.fn().mockResolvedValue([]); + const listSubpartitions = vi.fn().mockResolvedValue([]); + const { store, tableId } = await setup("xugu", { + getColumns, + listIndexes, + listForeignKeys, + listTriggers, + listConstraints, + listPartitions, + listSubpartitions, + }); + + expect(getColumns).not.toHaveBeenCalled(); + expect(listIndexes).not.toHaveBeenCalled(); + expect(listForeignKeys).not.toHaveBeenCalled(); + expect(listTriggers).not.toHaveBeenCalled(); + expect(listConstraints).not.toHaveBeenCalled(); + expect(listPartitions).not.toHaveBeenCalled(); + expect(listSubpartitions).not.toHaveBeenCalled(); + + const groupTypes = ["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-constraints", "group-table-partitions", "group-table-subpartitions"] as const; + for (const groupType of groupTypes) { + const group = findNode(store.treeNodes, tableId)?.children?.find((node) => node.type === groupType); + if (!group) throw new Error(`Missing ${groupType}`); + await store.loadTreeNodeChildren(group); + } + + expect(findNode(store.treeNodes, tableId)?.children?.map((node) => [node.type, node.objectCount])).toEqual([ + ["group-columns", 2], + ["group-constraints", 1], + ["group-fkeys", 1], + ["group-triggers", 1], + ["group-indexes", 1], + ["group-table-partitions", 0], + ["group-table-subpartitions", 0], + ]); + }); + + it("counts the normalized foreign key nodes for a composite foreign key", async () => { + const listForeignKeys = vi.fn().mockResolvedValue([ + { name: "FK_ORDER_CUSTOMER", column: "CUSTOMER_TENANT_ID", ref_table: "CUSTOMERS", ref_column: "TENANT_ID" }, + { name: "FK_ORDER_CUSTOMER", column: "CUSTOMER_ID", ref_table: "CUSTOMERS", ref_column: "ID" }, + ]); + const { config, store, tableId } = await setup("mysql", { listForeignKeys }); + const foreignKeys = findNode(store.treeNodes, `${tableId}:__fkeys`)!; + + await store.loadTreeNodeChildren(foreignKeys); + + expect(listForeignKeys).toHaveBeenCalledWith(config.id, "SHOP_DEMO", "SYSDBA", "SHOP_ORDERS", undefined); + expect(foreignKeys.children).toHaveLength(1); + expect(foreignKeys.objectCount).toBe(1); + }); + it("keeps a rejected Xugu metadata group collapsed and clears its loading state", async () => { const listPartitions = vi.fn().mockRejectedValue(new Error("metadata denied")); const { store, tableId } = await setup("xugu", { listPartitions }); @@ -159,6 +221,7 @@ describe("connectionStore Xugu table child metadata", () => { expect(partitions.isLoading).toBe(false); expect(partitions.isExpanded).toBe(false); expect(partitions.children).toEqual([]); + expect(partitions.objectCount).toBeUndefined(); }); it("shows Xugu trigger level and status without changing other database labels", async () => { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 5c7bb92043..a0cf5181fd 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1683,6 +1683,11 @@ export const useConnectionStore = defineStore("connection", () => { syncConfirmedEmptyTreeNodeId(parent); } + function setLoadedTableMetadataChildren(parent: TreeNode, children: TreeNode[]) { + setChildren(parent, children); + parent.objectCount = parent.children!.length; + } + function removePinnedTreeNodes(nodes: readonly TreeNode[], canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean { const nextPinnedOrder = removePinnedTreeNodesFromOrder(pinnedTreeNodeOrder.value, nodes, canonicalize, legacyKeys); if (nextPinnedOrder.length === pinnedTreeNodeOrder.value.length && nextPinnedOrder.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false; @@ -6405,7 +6410,7 @@ export const useConnectionStore = defineStore("connection", () => { const fields = await listMongoCompletionFields(connectionId, database, table); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren( + setLoadedTableMetadataChildren( targetNode, fields.map((field) => { const column = { @@ -6436,7 +6441,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!targetNode) return; const connConfig = getConfig(connectionId); const isGaussdbM = effectiveDatabaseTypeForConnection(connConfig) === "gaussdb" && connConfig?.driver_profile?.toLowerCase() === "gaussdb-m"; - setChildren( + setLoadedTableMetadataChildren( targetNode, columns.map((col) => ({ id: `${parentId}:${col.name}`, @@ -6471,7 +6476,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!metadataCapabilities.indexes || isMongoView) { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren(targetNode, []); + setLoadedTableMetadataChildren(targetNode, []); targetNode.isExpanded = true; return; } @@ -6480,7 +6485,7 @@ export const useConnectionStore = defineStore("connection", () => { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; const mongoCollectionKind = effectiveDbType === "mongodb" && targetNode.type === "group-indexes" ? mongoCollectionKindFromNode(targetNode) : undefined; - setChildren( + setLoadedTableMetadataChildren( targetNode, indexes.map((idx) => ({ id: `${parentId}:${idx.name}`, @@ -6513,7 +6518,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!metadataCapabilities.foreignKeys) { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren(targetNode, []); + setLoadedTableMetadataChildren(targetNode, []); targetNode.isExpanded = true; return; } @@ -6525,7 +6530,7 @@ export const useConnectionStore = defineStore("connection", () => { indexCompletionForeignKeys(connectionId, database, table, schema, sqlCompletionForeignKeys(fkeys)); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren( + setLoadedTableMetadataChildren( targetNode, fkeys.map((fk) => ({ id: `${parentId}:${fk.name}`, @@ -6558,7 +6563,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!metadataCapabilities.triggers) { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren(targetNode, []); + setLoadedTableMetadataChildren(targetNode, []); targetNode.isExpanded = true; return; } @@ -6567,7 +6572,7 @@ export const useConnectionStore = defineStore("connection", () => { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; const isXugu = effectiveDatabaseTypeForConnection(getConfig(connectionId)) === "xugu"; - setChildren( + setLoadedTableMetadataChildren( targetNode, triggers.map((tr) => { const xuguDetails = isXugu ? [tr.timing, tr.event, tr.level, tr.enabled === false ? i18n.global.t("objects.disabled") : null, tr.valid === false ? i18n.global.t("objects.invalid") : null].filter(Boolean).join(" ยท ") : `${tr.timing} ${tr.event}`; @@ -6604,7 +6609,7 @@ export const useConnectionStore = defineStore("connection", () => { const constraints = await api.listConstraints(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren( + setLoadedTableMetadataChildren( targetNode, constraints.map((constraint) => ({ id: `${parentId}:${constraint.name}`, @@ -6635,7 +6640,7 @@ export const useConnectionStore = defineStore("connection", () => { const partitions = await api.listPartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren( + setLoadedTableMetadataChildren( targetNode, partitions.map((partition) => ({ id: `${parentId}:${partition.name}`, @@ -6666,7 +6671,7 @@ export const useConnectionStore = defineStore("connection", () => { const partitions = await api.listSubpartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren( + setLoadedTableMetadataChildren( targetNode, partitions.map((partition) => ({ id: `${parentId}:${partition.name}`,