Files
dbx/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts
T

544 lines
25 KiB
TypeScript
Vendored

import { describe, expect, it } from "vitest";
import { appendTableTreeLoadMoreNode, buildGroupedObjectTreeNodes, buildObjectGroupPlaceholderNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, mergeTableInfosIntoObjects, mergeTableTreePageChildren, tablePartitionGroups, withoutTableTreeLoadMoreNodes } from "@/lib/table/tableTree";
import type { ObjectInfo, TableInfo, TreeNode } from "@/types/database";
const context = {
nodeId: "connection:db",
connectionId: "connection",
database: "db",
};
describe("case-sensitive database objects", () => {
const views: ObjectInfo[] = [
{ name: "dbx_issue4529_case_V1", object_type: "VIEW", schema: "dbx_test" },
{ name: "dbx_issue4529_case_v1", object_type: "VIEW", schema: "dbx_test" },
];
it("keeps table metadata entries whose names differ only by case", () => {
const tables: TableInfo[] = views.map((view) => ({ name: view.name, table_type: "VIEW", comment: null }));
const merged = mergeTableInfosIntoObjects([], tables, "dbx_test");
expect(merged.map((object) => object.name)).toEqual(["dbx_issue4529_case_V1", "dbx_issue4529_case_v1"]);
});
it("keeps simple tree nodes whose names differ only by case", () => {
const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "dbx_test", objects: views });
expect(nodes.map((node) => node.label)).toEqual(["dbx_issue4529_case_V1", "dbx_issue4529_case_v1"]);
expect(new Set(nodes.map((node) => node.id)).size).toBe(2);
});
it("keeps grouped tree nodes whose names differ only by case", () => {
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "dbx_test", objects: views });
const viewGroup = groups.find((node) => node.type === "group-views");
expect(viewGroup?.objectCount).toBe(2);
expect(viewGroup?.children?.map((node) => node.label)).toEqual(["dbx_issue4529_case_V1", "dbx_issue4529_case_v1"]);
expect(new Set(viewGroup?.children?.map((node) => node.id) ?? []).size).toBe(2);
});
it("preserves view validity in simple and grouped trees", () => {
const objects: ObjectInfo[] = [
{ name: "VALID_VIEW", object_type: "VIEW", schema: "dbx_test", valid: true },
{ name: "INVALID_VIEW", object_type: "VIEW", schema: "dbx_test", valid: false },
];
const simple = buildSimpleObjectTreeNodes({ ...context, schema: "dbx_test", objects });
expect(simple.map((node) => node.valid)).toEqual([false, true]);
const grouped = buildGroupedObjectTreeNodes({ ...context, schema: "dbx_test", objects });
expect(grouped.find((node) => node.type === "group-views")?.children?.map((node) => node.valid)).toEqual([false, true]);
});
it("preserves view validity when building the table-like tree path", () => {
const tables: TableInfo[] = [
{ name: "VALID_VIEW", table_type: "VIEW", valid: true },
{ name: "INVALID_VIEW", table_type: "VIEW", valid: false },
];
const nodes = buildTableTreeNodes({
...context,
schema: "dbx_test",
tables,
});
expect(nodes.map((node) => node.valid)).toEqual([false, true]);
const grouped = buildGroupedObjectTreeNodes({
...context,
schema: "dbx_test",
objects: mergeTableInfosIntoObjects([], tables, "dbx_test"),
});
expect(grouped.find((node) => node.type === "group-views")?.children?.map((node) => node.valid)).toEqual([false, true]);
});
it("keeps table nodes whose names differ only by case across pages", () => {
const firstPage = buildTableTreeNodes({
...context,
schema: "dbx_test",
tables: [{ name: "dbx_issue4529_case_T1", table_type: "BASE TABLE", comment: null }],
});
const secondPage = buildTableTreeNodes({
...context,
schema: "dbx_test",
tables: [{ name: "dbx_issue4529_case_t1", table_type: "BASE TABLE", comment: null }],
});
const merged = mergeTableTreePageChildren(firstPage, secondPage, context.connectionId, context.database);
expect(merged.map((node) => node.label)).toEqual(["dbx_issue4529_case_T1", "dbx_issue4529_case_t1"]);
});
});
describe("PostgreSQL overloaded routines", () => {
it("keeps routines with the same name distinct by identity arguments", () => {
const objects: ObjectInfo[] = [
{ name: "calc", object_type: "FUNCTION", schema: "public", signature: "integer" },
{ name: "calc", object_type: "FUNCTION", schema: "public", signature: "integer, integer" },
{ name: "calc", object_type: "FUNCTION", schema: "public", signature: "numeric" },
];
const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "public", objects });
expect(nodes.map((node) => ({ label: node.label, objectName: node.objectName, signature: node.signature }))).toEqual(
expect.arrayContaining([
{ label: "calc(integer)", objectName: "calc", signature: "integer" },
{ label: "calc(integer, integer)", objectName: "calc", signature: "integer, integer" },
{ label: "calc(numeric)", objectName: "calc", signature: "numeric" },
]),
);
expect(new Set(nodes.map((node) => node.id)).size).toBe(3);
});
it("keeps grouped routine nodes distinct by identity arguments", () => {
const objects: ObjectInfo[] = [
{ name: "calc", object_type: "FUNCTION", schema: "public", signature: "integer" },
{ name: "calc", object_type: "FUNCTION", schema: "public", signature: "integer, integer" },
{ name: "calc", object_type: "FUNCTION", schema: "public", signature: "numeric" },
];
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "public", objects });
const functionGroup = groups.find((node) => node.type === "group-functions");
expect(functionGroup?.children?.map((node) => ({ label: node.label, objectName: node.objectName, signature: node.signature }))).toEqual([
{ label: "calc(integer)", objectName: "calc", signature: "integer" },
{ label: "calc(integer, integer)", objectName: "calc", signature: "integer, integer" },
{ label: "calc(numeric)", objectName: "calc", signature: "numeric" },
]);
expect(new Set(functionGroup?.children?.map((node) => node.id) ?? []).size).toBe(3);
});
});
describe("PostgreSQL custom type metadata", () => {
it("preserves type kind and member capability in simple and grouped trees", () => {
const objects: ObjectInfo[] = [
{ name: "address", object_type: "TYPE", schema: "public", custom_type_kind: "composite", has_members: true },
{ name: "email", object_type: "TYPE", schema: "public", custom_type_kind: "domain", has_members: false },
];
const simple = buildSimpleObjectTreeNodes({ ...context, schema: "public", objects });
expect(simple.map((node) => ({ name: node.label, kind: node.customTypeKind, hasMembers: node.hasMembers }))).toEqual([
{ name: "address", kind: "composite", hasMembers: true },
{ name: "email", kind: "domain", hasMembers: false },
]);
const grouped = buildGroupedObjectTreeNodes({ ...context, schema: "public", objects });
expect(grouped.find((node) => node.type === "group-types")?.children?.map((node) => ({ name: node.label, kind: node.customTypeKind, hasMembers: node.hasMembers }))).toEqual([
{ name: "address", kind: "composite", hasMembers: true },
{ name: "email", kind: "domain", hasMembers: false },
]);
});
});
describe("programmable database objects", () => {
it("renders only the synonym group for the Xugu public-synonym scope", () => {
const groups = buildObjectGroupPlaceholderNodes({
...context,
schema: "\u0000DBX_XUGU_PUBLIC_SYNONYMS",
objectTypes: ["SYNONYM"],
});
expect(groups).toEqual([expect.objectContaining({ type: "group-synonyms", label: "tree.synonyms" })]);
});
it("coalesces Xugu package specification and body into one top-level node", () => {
const objects: ObjectInfo[] = [
{ name: "DBX_UI_PKG", object_type: "PACKAGE", schema: "APP", valid: true },
{ name: "DBX_UI_PKG", object_type: "PACKAGE_BODY", schema: "APP", valid: false },
];
const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "APP", objects, databaseType: "xugu" });
expect(nodes).toHaveLength(1);
expect(nodes[0]).toEqual(
expect.objectContaining({
type: "package",
objectName: "DBX_UI_PKG",
valid: false,
xuguPackageBodyAvailable: true,
xuguPackageBodyValid: false,
}),
);
});
it("keeps the package body as metadata for the expandable Xugu package node", () => {
const objects: ObjectInfo[] = [
{ name: "DBX_UI_PKG", object_type: "PACKAGE", schema: "APP", valid: true },
{ name: "DBX_UI_PKG", object_type: "PACKAGE_BODY", schema: "APP", valid: true },
];
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "APP", objects, databaseType: "xugu" });
const packageGroup = groups.find((node) => node.type === "group-packages");
expect(packageGroup?.objectCount).toBe(1);
expect(packageGroup?.children).toEqual([expect.objectContaining({ type: "package", objectName: "DBX_UI_PKG" })]);
});
it("keeps a body-only Xugu metadata response visible as a package", () => {
const nodes = buildSimpleObjectTreeNodes({
...context,
schema: "APP",
objects: [{ name: "BODY_ONLY_PKG", object_type: "PACKAGE_BODY", schema: "APP", valid: true }],
databaseType: "xugu",
});
expect(nodes).toEqual([
expect.objectContaining({
type: "package",
objectName: "BODY_ONLY_PKG",
xuguPackageBodyAvailable: true,
xuguPackageBodyValid: true,
}),
]);
});
it("does not advertise a body child for a specification-only Xugu package", () => {
const nodes = buildSimpleObjectTreeNodes({
...context,
schema: "APP",
objects: [{ name: "SPEC_ONLY_PKG", object_type: "PACKAGE", schema: "APP", valid: true }],
databaseType: "xugu",
});
expect(nodes[0]).toEqual(expect.objectContaining({ type: "package", xuguPackageBodyAvailable: undefined }));
});
it("does not merge package and package body for other databases", () => {
const objects: ObjectInfo[] = [
{ name: "DBX_UI_PKG", object_type: "PACKAGE", schema: "APP" },
{ name: "DBX_UI_PKG", object_type: "PACKAGE_BODY", schema: "APP" },
];
const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "APP", objects, databaseType: "oracle" });
expect(nodes.map((node) => node.type)).toEqual(["package", "package-body"]);
});
it("keeps Xugu trigger/type nodes distinct and preserves an invalid status", () => {
const objects: ObjectInfo[] = [
{ name: "TRG_AUDIT", object_type: "TRIGGER", schema: "APP", valid: false },
{ name: "ADDRESS_T", object_type: "TYPE", schema: "APP", valid: true, xugu_type_members_expandable: true },
{ name: "ADDRESS_LIST", object_type: "TYPE", schema: "APP", valid: true, xugu_type_members_expandable: false },
{ name: "ADDRESS_T", object_type: "TYPE_BODY", schema: "APP", valid: true },
];
const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "APP", objects });
expect(nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "trigger", objectName: "TRG_AUDIT", valid: false }),
expect.objectContaining({ type: "type", objectName: "ADDRESS_T", valid: true, xuguTypeMembersExpandable: true }),
expect.objectContaining({ type: "type", objectName: "ADDRESS_LIST", valid: true, xuguTypeMembersExpandable: false }),
expect.objectContaining({ type: "type-body", objectName: "ADDRESS_T", valid: true }),
]),
);
});
it("preserves Xugu trigger details for the schema trigger group", () => {
const trigger = {
name: "TRG_AUDIT",
event: "INSERT OR UPDATE",
timing: "BEFORE",
level: "FOR EACH ROW",
enabled: false,
valid: false,
condition: "NEW_VALUE >= 0",
language: "PL/SQL",
comment: "row audit trigger",
created_at: "2026-08-10 09:30:00",
};
const groups = buildGroupedObjectTreeNodes({
...context,
schema: "APP",
objects: [{ name: trigger.name, object_type: "TRIGGER", schema: "APP", valid: false, comment: trigger.comment, trigger }],
});
const triggerGroup = groups.find((node) => node.type === "group-triggers");
expect(triggerGroup?.children).toEqual([
expect.objectContaining({
type: "trigger",
objectName: trigger.name,
valid: false,
comment: trigger.comment,
meta: trigger,
}),
]);
});
it("groups MySQL scheduled events under a dedicated Events group", () => {
const objects: ObjectInfo[] = [{ name: "event_daily_middle_db_sync", object_type: "EVENT", schema: "shop" }];
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "shop", objects, databaseType: "mysql" });
const eventGroup = groups.find((node) => node.type === "group-events");
expect(eventGroup).toEqual(expect.objectContaining({ objectCount: 1, label: "tree.events" }));
expect(eventGroup?.children).toEqual([expect.objectContaining({ type: "event", objectName: "event_daily_middle_db_sync" })]);
});
it("keeps table-scoped Kingbase triggers distinct and source-addressable", () => {
const objects: ObjectInfo[] = [
{ name: "audit_before", object_type: "TRIGGER", schema: "public", parent_schema: "public", parent_name: "items" },
{ name: "audit_before", object_type: "TRIGGER", schema: "public", parent_schema: "public", parent_name: "orders" },
];
const simple = buildSimpleObjectTreeNodes({ ...context, schema: "public", objects, databaseType: "kingbase" });
expect(simple.map((node) => ({ label: node.label, tableName: node.tableName }))).toEqual([
{ label: "audit_before (items)", tableName: "items" },
{ label: "audit_before (orders)", tableName: "orders" },
]);
expect(new Set(simple.map((node) => node.id)).size).toBe(2);
const grouped = buildGroupedObjectTreeNodes({ ...context, schema: "public", objects, databaseType: "kingbase" });
const triggers = grouped.find((node) => node.type === "group-triggers")?.children ?? [];
expect(triggers.map((node) => ({ label: node.label, objectName: node.objectName, tableName: node.tableName }))).toEqual([
{ label: "audit_before (items)", objectName: "audit_before", tableName: "items" },
{ label: "audit_before (orders)", objectName: "audit_before", tableName: "orders" },
]);
expect(new Set(triggers.map((node) => node.id)).size).toBe(2);
});
it("groups Xugu private synonyms as source objects", () => {
const objects: ObjectInfo[] = [{ name: "SYN_SHOP_USERS", object_type: "SYNONYM", schema: "SYSDBA", valid: true }];
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "SYSDBA", objects });
const synonymGroup = groups.find((node) => node.type === "group-synonyms");
expect(synonymGroup).toEqual(expect.objectContaining({ objectCount: 1, label: "tree.synonyms" }));
expect(synonymGroup?.children).toEqual([expect.objectContaining({ type: "synonym", objectName: "SYN_SHOP_USERS", valid: true })]);
});
it("groups user-defined types with an object count", () => {
const objects: ObjectInfo[] = [
{ name: "status", object_type: "TYPE", schema: "app", comment: "order status" },
{ name: "email", object_type: "TYPE", schema: "app" },
];
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "app", objects });
const typeGroup = groups.find((node) => node.type === "group-types");
expect(typeGroup).toEqual(expect.objectContaining({ objectCount: 2, label: "tree.types" }));
expect(typeGroup?.children?.map((node) => node.type)).toEqual(["type", "type"]);
expect(typeGroup?.children?.map((node) => node.objectName)).toEqual(["email", "status"]);
expect(typeGroup?.children?.[0]?.comment).toBeUndefined();
expect(typeGroup?.children?.[1]?.comment).toBe("order status");
});
});
describe("PostgreSQL table hierarchy", () => {
it("matches partition parents case-insensitively", () => {
const nodes = buildTableTreeNodes({
...context,
schema: "public",
tables: [
{ name: "Orders", table_type: "PARTITIONED TABLE", comment: null },
{ name: "orders_2026", table_type: "TABLE", comment: null, parent_schema: "PUBLIC", parent_name: "orders" },
],
});
expect(nodes.map((node) => node.label)).toEqual(["Orders"]);
expect(tablePartitionGroups(nodes[0])[0].children?.map((node) => node.label)).toEqual(["orders_2026"]);
});
it("prefers the exact partition parent when folded names are ambiguous", () => {
const nodes = buildTableTreeNodes({
...context,
schema: "public",
tables: [
{ name: "Orders", table_type: "PARTITIONED TABLE", comment: null },
{ name: "orders", table_type: "PARTITIONED TABLE", comment: null },
{ name: "Orders_2026", table_type: "TABLE", comment: null, parent_schema: "public", parent_name: "Orders" },
],
});
const upperParent = nodes.find((node) => node.label === "Orders");
const lowerParent = nodes.find((node) => node.label === "orders");
expect(tablePartitionGroups(upperParent!)[0].children?.map((node) => node.label)).toEqual(["Orders_2026"]);
expect(tablePartitionGroups(lowerParent!)).toHaveLength(0);
});
it("prefers the exact partition parent when merging later pages", () => {
const firstPage = buildTableTreeNodes({
...context,
schema: "public",
tables: [
{ name: "Orders", table_type: "PARTITIONED TABLE", comment: null },
{ name: "orders", table_type: "PARTITIONED TABLE", comment: null },
],
});
const secondPage = buildTableTreeNodes({
...context,
schema: "public",
tables: [{ name: "Orders_2026", table_type: "TABLE", comment: null, parent_schema: "public", parent_name: "Orders" }],
});
const merged = mergeTableTreePageChildren(firstPage, secondPage, context.connectionId, context.database);
const upperParent = merged.find((node) => node.label === "Orders");
const lowerParent = merged.find((node) => node.label === "orders");
expect(tablePartitionGroups(upperParent!)[0].children?.map((node) => node.label)).toEqual(["Orders_2026"]);
expect(tablePartitionGroups(lowerParent!)).toHaveLength(0);
});
it("keeps schema pagination visible at the table-group root when a page ends inside nested partitions", () => {
const nodes = buildTableTreeNodes({
...context,
schema: "public",
tables: [
{ name: "orders", table_type: "BASE TABLE", comment: null },
{ name: "orders_2026", table_type: "BASE TABLE", comment: null, parent_schema: "public", parent_name: "orders" },
{ name: "orders_2026_01", table_type: "BASE TABLE", comment: null, parent_schema: "public", parent_name: "orders_2026" },
],
});
const loadMore: TreeNode = {
id: "load-more:1000",
label: "tree.loadMore",
type: "load-more",
connectionId: context.connectionId,
database: context.database,
loadMore: { parentId: "connection:db:__tables", offset: 1000, pageSize: 1000 },
};
const withLoadMore = appendTableTreeLoadMoreNode(nodes, loadMore, { schema: "public", name: "orders_2026" });
expect(withLoadMore.map((node) => node.label)).toEqual(["orders", "tree.loadMore"]);
const yearPartition = tablePartitionGroups(withLoadMore[0])[0].children?.[0];
expect(yearPartition?.label).toBe("orders_2026");
expect(tablePartitionGroups(yearPartition!)[0].children?.map((node) => node.label)).toEqual(["orders_2026_01"]);
});
});
describe("TDengine table hierarchy", () => {
it("groups child tables under their supertable and keeps ordinary tables flat", () => {
const tables: TableInfo[] = [
{ name: "meters", table_type: "STABLE", comment: null },
{ name: "device_b", table_type: "TABLE", comment: null, parent_name: "meters" },
{ name: "standalone", table_type: "TABLE", comment: null },
{ name: "device_a", table_type: "TABLE", comment: null, parent_name: "meters" },
];
const nodes = buildTableTreeNodes({ ...context, tables });
expect(nodes.map((node) => node.label)).toEqual(["meters", "standalone"]);
expect(nodes[0].children).toHaveLength(1);
expect(nodes[0].children?.[0]).toMatchObject({
type: "group-partitions",
label: "tree.childTables",
objectCount: 2,
isExpanded: true,
});
expect(nodes[0].children?.[0].children?.map((node) => node.label)).toEqual(["device_a", "device_b"]);
});
it("attaches child tables loaded on later pages to the existing supertable", () => {
const firstPage = buildTableTreeNodes({
...context,
tables: [{ name: "meters", table_type: "STABLE", comment: null }],
});
const secondPage = buildTableTreeNodes({
...context,
tables: [{ name: "device_a", table_type: "TABLE", comment: null, parent_name: "meters" }],
});
const merged = mergeTableTreePageChildren(firstPage, secondPage, context.connectionId, context.database);
expect(merged).toHaveLength(1);
expect(merged[0].children?.[0]).toMatchObject({
type: "group-partitions",
label: "tree.childTables",
objectCount: 1,
isExpanded: true,
});
expect(merged[0].children?.[0].children?.[0].label).toBe("device_a");
});
it("keeps pagination inside the child table group across pages", () => {
const firstPage = buildTableTreeNodes({
...context,
tables: [
{ name: "meters", table_type: "STABLE", comment: null },
{ name: "device_a", table_type: "TABLE", comment: null, parent_name: "meters" },
],
});
const firstLoadMore: TreeNode = {
id: "load-more:1",
label: "tree.loadMore",
type: "load-more",
connectionId: context.connectionId,
database: context.database,
loadMore: { parentId: "connection:db:__tables", offset: 2, pageSize: 2 },
};
appendTableTreeLoadMoreNode(firstPage, firstLoadMore, { name: "meters" });
expect(firstPage.map((node) => node.type)).toEqual(["table"]);
expect(tablePartitionGroups(firstPage[0])[0].children?.map((node) => node.type)).toEqual(["table", "load-more"]);
const secondPage = buildTableTreeNodes({
...context,
tables: [{ name: "device_b", table_type: "TABLE", comment: null, parent_name: "meters" }],
});
const withoutLoadMore = withoutTableTreeLoadMoreNodes(firstPage);
const merged = mergeTableTreePageChildren(withoutLoadMore, secondPage, context.connectionId, context.database);
const secondLoadMore = { ...firstLoadMore, id: "load-more:2", loadMore: { ...firstLoadMore.loadMore!, offset: 4 } };
appendTableTreeLoadMoreNode(merged, secondLoadMore, { name: "meters" });
expect(merged).toHaveLength(1);
expect(tablePartitionGroups(merged[0])[0]).toMatchObject({ objectCount: 2 });
expect(tablePartitionGroups(merged[0])[0].children?.map((node) => node.label)).toEqual(["device_a", "device_b", "tree.loadMore"]);
});
it("keeps the partition label for non-TDengine parent tables", () => {
const nodes = buildTableTreeNodes({
...context,
schema: "public",
tables: [
{ name: "orders", table_type: "PARTITIONED TABLE", comment: null },
{ name: "orders_2026", table_type: "TABLE", comment: null, parent_schema: "public", parent_name: "orders" },
],
});
expect(nodes[0].children?.[0]).toMatchObject({ label: "tree.partitions", isExpanded: false });
});
it("nests second-level partitions under Kingbase-style lowercase parent names", () => {
// KingbaseES returns lowercase relation names and a direct `parent_name`;
// a partition that is itself a partitioned parent must nest recursively.
const nodes = buildTableTreeNodes({
...context,
schema: "partition_demo",
tables: [
{ name: "catalog_nested", table_type: "BASE TABLE", comment: null },
{ name: "catalog_nested_2024", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested" },
{ name: "catalog_nested_2024_asia", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested_2024" },
{ name: "catalog_nested_2024_eu", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested_2024" },
{ name: "catalog_nested_2025", table_type: "BASE TABLE", comment: null, parent_schema: "partition_demo", parent_name: "catalog_nested" },
],
});
expect(nodes.map((node) => node.label)).toEqual(["catalog_nested"]);
const level1 = tablePartitionGroups(nodes[0])[0].children ?? [];
expect(level1.map((node) => node.label)).toEqual(["catalog_nested_2024", "catalog_nested_2025"]);
const yearPartition = level1.find((node) => node.label === "catalog_nested_2024");
expect(tablePartitionGroups(yearPartition!)[0].children?.map((node) => node.label)).toEqual(["catalog_nested_2024_asia", "catalog_nested_2024_eu"]);
});
});