feat(sidebar): show table metadata group counts

This commit is contained in:
cowking authored and GitHub committed 2026-09-11 14:14:30 +08:00
1 parent c834421c49
commit 72faf4887b
4 files changed
+191 -11

No files matched your search

@@ -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' ||
@@ -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<string>(),
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<string>(),
sidebarTableSearchQueries: {},
tableNameFilterForScope: () => undefined,
treeNodes: [],
treeSelectionAnchorId: null as string | null,
};
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => connectionStore }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }) }));
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));
}
});
});
@@ -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 () => {
+16 -11
View File
@@ -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}`,