feat(sidebar): show table metadata group counts
This commit is contained in:
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' ||
|
||||
|
||||
+106
@@ -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 () => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user