From 275b4d5831baa1519bdb749a53e9c17997403231 Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:59:37 +0800 Subject: [PATCH] feat(xugu): organize package members and preserve body access --- .../sidebar/SidebarTreeRuntimeHost.vue | 19 ++- .../__tests__/sidebar/packageMembers.spec.ts | 44 +++++-- .../__tests__/sidebar/treeNodeClick.spec.ts | 17 +++ .../src/lib/__tests__/table/tableTree.spec.ts | 70 +++++++++++ .../desktop/src/lib/sidebar/packageMembers.ts | 79 ++++++++---- apps/desktop/src/lib/table/tableTree.ts | 91 +++++++++++++- .../connectionStore.metadataLoading.spec.ts | 112 +++++++++++++++++- apps/desktop/src/stores/connectionStore.ts | 22 +++- apps/desktop/src/types/database.ts | 7 ++ 9 files changed, 412 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index cfd1e01156..1c6b84e512 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1744,15 +1744,17 @@ async function refreshDropTableChildObjectPreviewSql() { dropTableChildObjectPreviewSql.value = options ? await buildDropTableChildObjectSql(options).catch(() => "") : ""; } -function openObjectSourceDialog(initialEditing: boolean) { +function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false) { const node = activeNode.value; if (!node.connectionId || !node.database) return; + if (viewPackageBody && (node.type !== "package" || node.xuguPackageBodyAvailable !== true || currentDatabaseType() !== "xugu")) return; + const sourceNode: TreeNode = viewPackageBody ? { ...node, type: "package-body" } : node; // TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family // connections list user-defined types without a CREATE TYPE getter this cycle. - if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(currentDatabaseType())) return; + if ((sourceNode.type === "type" || sourceNode.type === "type-body") && !supportsTypeObjectSource(currentDatabaseType())) return; const connectionId = node.connectionId; const database = node.database; - const sourceTarget = objectSourceTargetForTreeNode(node); + const sourceTarget = objectSourceTargetForTreeNode(sourceNode); if (!sourceTarget) return; const openMode = settingsStore.editorSettings.routineSourceOpenMode; if (openMode === "query-tab") { @@ -1775,7 +1777,7 @@ function openObjectSourceDialog(initialEditing: boolean) { name: objectName, objectType: sourceTarget.objectType as any, databaseType, - signature: node.signature, + signature: sourceNode.signature, }); const tabId = queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true }); const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(resolvedType); @@ -1797,7 +1799,7 @@ function openObjectSourceDialog(initialEditing: boolean) { .ensureConnected(connectionId) .then(() => { connectionStore.activeConnectionId = connectionId; - emit("open-object-source", node, initialEditing); + emit("open-object-source", sourceNode, initialEditing); }) .catch((e: any) => { toast(e?.message || String(e), 5000); @@ -4776,6 +4778,13 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); } items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); + if (node.type === "package" && currentDatabaseType() === "xugu" && node.xuguPackageBodyAvailable === true) { + items.push({ + label: `${t("contextMenu.viewSource")} (${t("objects.packageBody")})`, + action: () => openObjectSourceDialog(false, true), + icon: Code2, + }); + } items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 }); return true; diff --git a/apps/desktop/src/lib/__tests__/sidebar/packageMembers.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/packageMembers.spec.ts index 6249852160..d2a718532c 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/packageMembers.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/packageMembers.spec.ts @@ -32,15 +32,25 @@ describe("package member tree", () => { { name: "lookup", kind: "function", signature: null, data_type: "VARCHAR" }, { name: "ignored_table", kind: "table" }, ]; - const result = buildPackageMemberNodes(packageNode(), candidates); + const result = buildPackageMemberNodes(packageNode(), candidates, "xugu"); - expect(result.map((node) => [node.type, node.label])).toEqual([ - ["procedure", "calculate(p_value IN INT)"], - ["procedure", "calculate(p_value IN VARCHAR)"], - ["function", "lookup"], + expect(result.map((node) => [node.type, node.label, node.objectCount])).toEqual([ + ["group-procedures", "tree.procedures", 2], + ["group-functions", "tree.functions", 1], ]); - expect(result.every((node) => node.parentName === "business_api" && node.parentSchema === "app_schema" && node.parentType === "package" && node.valid === false)).toBe(true); - expect(new Set(result.map((node) => node.id)).size).toBe(3); + expect(result[0]?.children?.map((node) => node.label)).toEqual(["calculate(p_value IN INT)", "calculate(p_value IN VARCHAR)"]); + expect(result[1]?.children?.map((node) => node.label)).toEqual(["lookup"]); + expect(result.flatMap((node) => node.children ?? []).every((node) => node.parentName === "business_api" && node.parentSchema === "app_schema" && node.parentType === "package" && node.valid === false)).toBe(true); + expect(new Set(result.flatMap((node) => node.children ?? []).map((node) => node.id)).size).toBe(3); + }); + + it("keeps package body source metadata on the package without adding a duplicate body node", () => { + const node = { ...packageNode(), xuguPackageBodyAvailable: true, xuguPackageBodyValid: false }; + const result = buildPackageMemberNodes(node, [{ name: "calculate", kind: "procedure", signature: null }], "xugu"); + + expect(result.map((child) => [child.type, child.label])).toEqual([["group-procedures", "tree.procedures"]]); + expect(result[0]?.children).toHaveLength(1); + expect(result.some((child) => child.type === "package-body")).toBe(false); }); it("deduplicates exact duplicate metadata without merging case-distinct members", () => { @@ -50,8 +60,24 @@ describe("package member tree", () => { { name: "MIXEDCASE", kind: "function", signature: "p INT" }, { name: "", kind: "procedure" }, ]; - const result = buildPackageMemberNodes(packageNode(), candidates); + const result = buildPackageMemberNodes(packageNode(), candidates, "xugu"); - expect(result.map((node) => node.objectName)).toEqual(["MixedCase", "MIXEDCASE"]); + expect(result.flatMap((node) => node.children ?? []).map((node) => node.objectName)).toEqual(["MixedCase", "MIXEDCASE"]); + }); + + it("keeps non-Xugu package members flat", () => { + const result = buildPackageMemberNodes( + packageNode(), + [ + { name: "calculate", kind: "procedure", signature: null }, + { name: "lookup", kind: "function", signature: null }, + ], + "oracle", + ); + + expect(result.map((node) => [node.type, node.label])).toEqual([ + ["procedure", "calculate"], + ["function", "lookup"], + ]); }); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts index e393ec347d..ed7c8a6b9a 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts @@ -80,6 +80,23 @@ describe("treeNodeClick", () => { }); }); + it("routes the package body action to the owning package source", () => { + expect( + objectSourceTargetForTreeNode({ + id: "pkg:body", + label: "business_api", + type: "package-body", + objectName: "business_api", + schema: "app_schema", + }), + ).toEqual({ + name: "business_api", + schema: "app_schema", + objectType: "PACKAGE_BODY", + signature: undefined, + }); + }); + it("keeps standalone routine source routing unchanged", () => { expect(objectSourceTargetForTreeNode({ id: "proc", label: "standalone_proc", type: "procedure", schema: "app" })).toEqual({ name: "standalone_proc", diff --git a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts index e8eae8366e..4f4eb7e571 100644 --- a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts @@ -117,6 +117,76 @@ describe("PostgreSQL custom type metadata", () => { }); describe("programmable database objects", () => { + 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 }, diff --git a/apps/desktop/src/lib/sidebar/packageMembers.ts b/apps/desktop/src/lib/sidebar/packageMembers.ts index ec5f292afb..a46cd51d2c 100644 --- a/apps/desktop/src/lib/sidebar/packageMembers.ts +++ b/apps/desktop/src/lib/sidebar/packageMembers.ts @@ -1,14 +1,53 @@ -import type { CompletionAssistantCandidate, TreeNode } from "@/types/database"; +import type { CompletionAssistantCandidate, DatabaseType, TreeNode, TreeNodeType } from "@/types/database"; export function markPackageNodesExpandable(nodes: TreeNode[]): TreeNode[] { return nodes.map((node) => (node.type === "package" ? { ...node, children: node.children ?? [] } : node)); } -export function buildPackageMemberNodes(packageNode: TreeNode, candidates: readonly CompletionAssistantCandidate[]): TreeNode[] { - const parentName = packageNode.objectName || packageNode.label; - const parentSchema = packageNode.schema; +function packageMemberGroup(packageNode: TreeNode, type: "procedure" | "function", children: TreeNode[]): TreeNode { + const isProcedureGroup = type === "procedure"; + const groupType: TreeNodeType = isProcedureGroup ? "group-procedures" : "group-functions"; + return { + id: `${packageNode.id}:members:${type}s`, + label: isProcedureGroup ? "tree.procedures" : "tree.functions", + type: groupType, + objectName: packageNode.objectName || packageNode.label, + parentName: packageNode.objectName || packageNode.label, + parentSchema: packageNode.schema, + parentType: "package", + connectionId: packageNode.connectionId, + database: packageNode.database, + schema: packageNode.schema, + objectCount: children.length, + isExpanded: false, + children, + }; +} + +function packageMemberNode(packageNode: TreeNode, kind: "procedure" | "function", name: string, signature: string): TreeNode { + return { + id: `${packageNode.id}:member:${kind}:${name}:${signature}`, + label: signature ? `${name}(${signature})` : name, + type: kind, + objectName: name, + signature: signature || undefined, + parentName: packageNode.objectName || packageNode.label, + parentSchema: packageNode.schema, + parentType: "package", + valid: packageNode.valid, + connectionId: packageNode.connectionId, + database: packageNode.database, + schema: packageNode.schema, + isExpanded: false, + children: undefined, + }; +} + +export function buildPackageMemberNodes(packageNode: TreeNode, candidates: readonly CompletionAssistantCandidate[], databaseType?: DatabaseType): TreeNode[] { const seen = new Set(); - const children: TreeNode[] = []; + const members: TreeNode[] = []; + const procedures: TreeNode[] = []; + const functions: TreeNode[] = []; for (const candidate of candidates) { if (candidate.kind !== "procedure" && candidate.kind !== "function") continue; @@ -18,23 +57,19 @@ export function buildPackageMemberNodes(packageNode: TreeNode, candidates: reado const key = `${candidate.kind}\0${name}\0${signature}`; if (seen.has(key)) continue; seen.add(key); - children.push({ - id: `${packageNode.id}:member:${candidate.kind}:${name}:${signature}`, - label: signature ? `${name}(${signature})` : name, - type: candidate.kind, - objectName: name, - signature: signature || undefined, - parentName, - parentSchema, - parentType: "package", - valid: packageNode.valid, - connectionId: packageNode.connectionId, - database: packageNode.database, - schema: parentSchema, - isExpanded: false, - children: undefined, - }); + const member = packageMemberNode(packageNode, candidate.kind, name, signature); + members.push(member); + if (candidate.kind === "procedure") procedures.push(member); + else functions.push(member); } - return children; + // Xugu presents package specifications and bodies as one logical package. + // Keep the richer member folders scoped to Xugu so Oracle and other package + // providers retain their existing flat member tree. + if (databaseType !== "xugu") return members; + + const groups: TreeNode[] = []; + if (procedures.length > 0) groups.push(packageMemberGroup(packageNode, "procedure", procedures)); + if (functions.length > 0) groups.push(packageMemberGroup(packageNode, "function", functions)); + return groups; } diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index ea7baef8c0..3af234c500 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -1,4 +1,4 @@ -import type { ObjectInfo, TableInfo, TreeNode, TreeNodeType } from "@/types/database"; +import type { DatabaseType, ObjectInfo, TableInfo, TreeNode, TreeNodeType } from "@/types/database"; import { normalizeSidebarObjectKind, type SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities"; const databaseObjectNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); @@ -543,12 +543,89 @@ function buildObjectTreeEntries({ nodeId, connectionId, database, schema, object return buildPartitionTree(entries, connectionId, database); } -export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, schema, objects }: { nodeId: string; connectionId: string; database: string; schema?: string; objects: ObjectInfo[] }): TreeNode[] { +type XuguPackageObjectInfo = ObjectInfo & { + xugu_package_body_available?: boolean | null; + xugu_package_body_valid?: boolean | null; +}; + +function packageObjectIdentity(schema: string | undefined, name: string): string { + return `${schema || ""}\0${name}`; +} + +/** + * Xugu exposes a package specification and body as two rows in ALL_PACKAGES, + * while they are one logical package in the schema tree. Keep the two source + * kinds available through metadata on the specification node, but coalesce + * their visible tree entry only for Xugu connections. + */ +function coalesceXuguPackageObjects(objects: readonly ObjectInfo[], databaseType?: DatabaseType): ObjectInfo[] { + if (databaseType !== "xugu") return [...objects]; + + const packageBodies = new Map(); + for (const object of objects) { + if (normalizeObjectType(object.object_type) !== "PACKAGE_BODY") continue; + const schema = object.schema ? normalizeDatabaseObjectName(object.schema) : undefined; + const name = normalizeDatabaseObjectName(object.name); + if (name) packageBodies.set(packageObjectIdentity(schema, name), object); + } + + const result: ObjectInfo[] = []; + const emittedPackages = new Set(); + for (const object of objects) { + const type = normalizeObjectType(object.object_type); + const schema = object.schema ? normalizeDatabaseObjectName(object.schema) : undefined; + const name = normalizeDatabaseObjectName(object.name); + if (!name) continue; + + if (type === "PACKAGE_BODY") { + const key = packageObjectIdentity(schema, name); + if (emittedPackages.has(key)) continue; + // A restricted metadata response may contain only PACKAGE_BODY. Keep it + // visible as a package so the body source is not silently lost. + if (!objects.some((candidate) => normalizeObjectType(candidate.object_type) === "PACKAGE" && packageObjectIdentity(candidate.schema ? normalizeDatabaseObjectName(candidate.schema) : undefined, normalizeDatabaseObjectName(candidate.name)) === key)) { + result.push({ + ...object, + object_type: "PACKAGE", + schema, + name, + valid: object.valid, + xugu_package_body_available: true, + xugu_package_body_valid: object.valid, + }); + emittedPackages.add(key); + } + continue; + } + + if (type !== "PACKAGE") { + result.push({ ...object, schema, name }); + continue; + } + + const key = packageObjectIdentity(schema, name); + if (emittedPackages.has(key)) continue; + const body = packageBodies.get(key); + const bodyValid = body?.valid ?? null; + result.push({ + ...object, + schema, + name, + valid: object.valid === false || body?.valid === false ? false : (object.valid ?? body?.valid ?? null), + xugu_package_body_available: !!body, + xugu_package_body_valid: bodyValid, + } satisfies XuguPackageObjectInfo); + emittedPackages.add(key); + } + + return result; +} + +export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, schema, objects, databaseType }: { nodeId: string; connectionId: string; database: string; schema?: string; objects: ObjectInfo[]; databaseType?: DatabaseType }): TreeNode[] { const seen = new Set(); const tableEntries: TableTreeEntry[] = []; const objectNodes: TreeNode[] = []; - for (const obj of objects) { + for (const obj of coalesceXuguPackageObjects(objects, databaseType)) { const objectType = normalizeObjectType(obj.object_type); if (!["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"].includes(objectType)) { continue; @@ -591,6 +668,8 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch valid: obj.valid ?? undefined, meta: objectType === "TRIGGER" ? (obj.trigger ?? undefined) : undefined, xuguTypeMembersExpandable: objectType === "TYPE" && obj.xugu_type_members_expandable === true, + xuguPackageBodyAvailable: objectType === "PACKAGE" && obj.xugu_package_body_available === true ? true : undefined, + xuguPackageBodyValid: objectType === "PACKAGE" && obj.xugu_package_body_available === true ? (obj.xugu_package_body_valid ?? null) : undefined, connectionId, database, schema: childSchema, @@ -718,10 +797,10 @@ export function objectTypesForGroupNode(type: TreeNodeType): DatabaseObjectTreeK return groupDefs.find((def) => def.nodeType === type)?.objectTypes ?? null; } -export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, schema, objects }: { nodeId: string; connectionId: string; database: string; schema?: string; objects: ObjectInfo[] }): TreeNode[] { +export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, schema, objects, databaseType }: { nodeId: string; connectionId: string; database: string; schema?: string; objects: ObjectInfo[]; databaseType?: DatabaseType }): TreeNode[] { const buckets = new Map(); const seen = new Set(); - for (const obj of objects) { + for (const obj of coalesceXuguPackageObjects(objects, databaseType)) { const name = normalizeDatabaseObjectName(obj.name); if (!name) continue; const t = normalizeObjectType(obj.object_type); @@ -768,6 +847,8 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc valid: obj.valid ?? undefined, meta: objectType === "TRIGGER" ? (obj.trigger ?? undefined) : undefined, xuguTypeMembersExpandable: objectType === "TYPE" && obj.xugu_type_members_expandable === true, + xuguPackageBodyAvailable: objectType === "PACKAGE" && obj.xugu_package_body_available === true ? true : undefined, + xuguPackageBodyValid: objectType === "PACKAGE" && obj.xugu_package_body_available === true ? (obj.xugu_package_body_valid ?? null) : undefined, connectionId, database, schema: childSchema, diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index c72f75fa31..6aa2586575 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -198,8 +198,12 @@ describe("connectionStore metadata loading", () => { match_mode: "prefix", }); expect(packageNode.isExpanded).toBe(true); - expect(packageNode.children?.map((child) => child.label)).toEqual(["process_item(p_id IN INT)", "process_item(p_code IN VARCHAR)", "item_count"]); - expect(packageNode.children?.every((child) => child.parentName === "business_api")).toBe(true); + expect(packageNode.children?.map((child) => [child.type, child.label, child.objectCount])).toEqual([ + ["group-procedures", "tree.procedures", 2], + ["group-functions", "tree.functions", 1], + ]); + expect(packageNode.children?.flatMap((group) => group.children ?? []).map((child) => child.label)).toEqual(["process_item(p_id IN INT)", "process_item(p_code IN VARCHAR)", "item_count"]); + expect(packageNode.children?.flatMap((group) => group.children ?? []).every((child) => child.parentName === "business_api")).toBe(true); }, 15000); it("loads Xugu object type members through the scoped completion endpoint", async () => { @@ -1675,6 +1679,110 @@ describe("connectionStore metadata loading", () => { expect(storedProcedureGroup?.children?.map((node) => node.label)).toEqual(["p_0999"]); }); + it("merges Xugu package body metadata when it arrives on the next page", async () => { + const packageSpec: ObjectInfo = { + name: "business_api", + object_type: "PACKAGE", + schema: "app_schema", + valid: true, + comment: null, + created_at: null, + updated_at: null, + parent_schema: null, + parent_name: null, + }; + const packageBody: ObjectInfo = { + ...packageSpec, + object_type: "PACKAGE_BODY", + valid: false, + }; + const listObjects = vi.fn().mockResolvedValueOnce([packageSpec, packageBody]).mockResolvedValueOnce([packageBody]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listObjects, + 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 settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + settingsStore.desktopSettings.sidebar_table_page_size = 1; + + const connection = xuguConnection(); + const packageGroup: TreeNode = { + id: "xugu-1:app_db:app_schema:__packages", + label: "tree.packages", + type: "group-packages", + connectionId: connection.id, + database: "app_db", + schema: "app_schema", + isExpanded: false, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "xugu-1:app_db", + label: "app_db", + type: "database", + connectionId: connection.id, + database: "app_db", + isExpanded: true, + children: [ + { + id: "xugu-1:app_db:app_schema", + label: "app_schema", + type: "schema", + connectionId: connection.id, + database: "app_db", + schema: "app_schema", + isExpanded: true, + children: [packageGroup], + }, + ], + }, + ], + }, + ]; + + await store.loadObjectGroupChildren(packageGroup); + const packageNode = packageGroup.children?.[0]; + expect(packageNode).toEqual(expect.objectContaining({ type: "package", label: "business_api", xuguPackageBodyAvailable: undefined, valid: true })); + const packageNodeId = packageNode?.id; + + const loadMoreNode = packageGroup.children?.at(-1); + expect(loadMoreNode?.type).toBe("load-more"); + await store.loadMoreObjectGroupChildren(loadMoreNode!); + + expect(packageGroup.children).toHaveLength(1); + expect(packageGroup.children?.[0]?.id).toBe(packageNodeId); + expect(packageGroup.children?.[0]).toEqual( + expect.objectContaining({ + type: "package", + label: "business_api", + xuguPackageBodyAvailable: true, + xuguPackageBodyValid: false, + valid: false, + }), + ); + }); + it("reloads a collapsed database after a forced connection database refresh", async () => { const listDatabases = vi .fn() diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 1365c063f9..78953336e3 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1605,16 +1605,17 @@ export const useConnectionStore = defineStore("connection", () => { } function objectGroupChildrenFromObjects(options: { node: TreeNode; parentNodeId: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; objects: ObjectInfo[] }): TreeNode[] { + const databaseType = options.node.connectionId ? effectiveDatabaseTypeForConnection(getConfig(options.node.connectionId)) : undefined; const grouped = buildGroupedObjectTreeNodes({ nodeId: options.parentNodeId, connectionId: options.node.connectionId || "", database: options.node.database || "", schema: options.effectiveSchema, objects: options.objects.filter((object) => options.objectTypes.includes(normalizedObjectTreeKind(object.object_type))), + databaseType, }); const refreshedGroup = grouped.find((group) => group.type === options.node.type); const children = refreshedGroup?.children ?? []; - const databaseType = options.node.connectionId ? effectiveDatabaseTypeForConnection(getConfig(options.node.connectionId)) : undefined; return supportsPackageMemberExpansion(databaseType) ? markPackageNodesExpandable(children) : children; } @@ -1644,12 +1645,20 @@ export const useConnectionStore = defineStore("connection", () => { const tableChildren = pageChildren.filter((child) => child.type === "table"); const nonTableChildren = pageChildren.filter((child) => child.type !== "table"); let merged = tableChildren.length ? mergeTableTreePageChildren(currentChildren, tableChildren, connectionId, database) : [...currentChildren]; - const existing = new Set(merged.map(treeNodeObjectIdentity)); + const existing = new Map(merged.map((node) => [treeNodeObjectIdentity(node), node])); for (const child of nonTableChildren) { const key = treeNodeObjectIdentity(child); - if (existing.has(key)) continue; + const existingNode = existing.get(key); + if (existingNode) { + if (child.type === "package" && child.xuguPackageBodyAvailable === true) { + existingNode.xuguPackageBodyAvailable = true; + existingNode.xuguPackageBodyValid = child.xuguPackageBodyValid; + existingNode.valid = existingNode.valid === false || child.valid === false ? false : (existingNode.valid ?? child.valid ?? null); + } + continue; + } merged.push(child); - existing.add(key); + existing.set(key, child); } const config = parent.connectionId ? getConfig(parent.connectionId) : undefined; return sortSidebarTreeChildrenForParent( @@ -1872,14 +1881,15 @@ export const useConnectionStore = defineStore("connection", () => { ); const supplementalObjects = filterSimpleSidebarSupplementalObjects(objects); if (supplementalObjects.length === 0) return; + const databaseType = effectiveDatabaseTypeForConnection(getConfig(options.connectionId)); let supplementalChildren = buildSimpleObjectTreeNodes({ nodeId: options.nodeId, connectionId: options.connectionId, database: options.database, schema: options.effectiveSchema, objects: supplementalObjects, + databaseType, }); - const databaseType = effectiveDatabaseTypeForConnection(getConfig(options.connectionId)); if (supportsPackageMemberExpansion(databaseType)) { supplementalChildren = markPackageNodesExpandable(supplementalChildren); } @@ -5708,7 +5718,7 @@ export const useConnectionStore = defineStore("connection", () => { }); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren(targetNode, buildPackageMemberNodes(targetNode, response.candidates)); + setChildren(targetNode, buildPackageMemberNodes(targetNode, response.candidates, databaseType)); targetNode.isExpanded = true; }, options, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 73002b4c2a..4508d022f3 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -444,6 +444,9 @@ export interface ObjectInfo { parent_name?: string | null; trigger?: TriggerInfo | null; xugu_type_members_expandable?: boolean | null; + /** Xugu package metadata merged from the PACKAGE_BODY catalog row. */ + xugu_package_body_available?: boolean | null; + xugu_package_body_valid?: boolean | null; } export interface ObjectStatistics { @@ -976,6 +979,10 @@ export interface TreeNode { parentType?: TreeNodeType; /** Set only for XuguDB object types whose members can be loaded lazily. */ xuguTypeMembersExpandable?: boolean; + /** Set on a Xugu package specification when a package body exists. */ + xuguPackageBodyAvailable?: boolean; + /** Validity reported for the Xugu package body, independent of the spec. */ + xuguPackageBodyValid?: boolean | null; tableType?: string; comment?: string | null; valid?: boolean | null;