feat(xugu): organize package members and preserve body access
This commit is contained in:
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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+110
-2
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user