feat(xugu): organize package members and preserve body access

This commit is contained in:
Elias authored and GitHub committed 2026-08-12 10:59:37 +08:00
1 parent e8fae013bd
commit 275b4d5831
9 files changed
+412 -49

No files matched your search

@@ -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;
@@ -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"],
]);
});
});
@@ -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",
+70
View File
@@ -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 },
+57 -22
View File
@@ -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<string>();
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;
}
+86 -5
View File
@@ -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<string, ObjectInfo>();
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<string>();
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<string>();
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<string, ObjectInfo[]>();
const seen = new Set<string>();
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,
@@ -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()
+16 -6
View File
@@ -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,
+7
View File
@@ -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;