feat(xugu): expose object type members in schema tree

This commit is contained in:
Elias authored and GitHub committed 2026-08-11 02:12:22 +08:00
1 parent d7e93453c7
commit 1b37e97486
30 files changed
+1364 -63

No files matched your search

@@ -71,6 +71,7 @@ import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTr
import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint";
@@ -610,6 +611,12 @@ async function toggle() {
return;
}
if (isXuguTypeMemberContainer(node, connectionStore.getConfig(node.connectionId || "")?.db_type)) {
await connectionStore.loadXuguTypeMembers(node);
emitNodeToggled(node, wasExpanded);
return;
}
if (node.isExpanded) {
node.isExpanded = false;
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
@@ -3405,6 +3412,7 @@ const canExpand = computed(() =>
canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: activeNode.value.xuguTypeMembersExpandable === true,
}),
);
@@ -558,7 +558,7 @@ const canExpand = computed(() =>
canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: activeNode.value.children !== undefined,
explicitContainer: (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
}),
);
@@ -34,6 +34,12 @@ describe("sidebar tree item layout", () => {
expect(canTreeNodeShowExpander({ type: "procedure", childCount: 0, explicitContainer: true })).toBe(false);
});
it("allows only an explicitly marked Xugu type specification to expand", () => {
expect(canTreeNodeShowExpander({ type: "type", childCount: 0, explicitContainer: true })).toBe(true);
expect(canTreeNodeShowExpander({ type: "type", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "type-body", childCount: 0, explicitContainer: true })).toBe(false);
});
it("aligns comments to the longest sibling name without crossing parent groups", () => {
const widths = alignedSidebarCommentLabelWidths([
{ id: "tables", depth: 1, alignable: false, hasComment: false, labelWidth: 0 },
@@ -23,6 +23,11 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("package", false)).toBe("open-source");
});
it("toggles an expandable type node before opening its source", () => {
expect(treeNodeRowAction("type", true)).toBe("toggle");
expect(treeNodeRowAction("type", false)).toBe("open-source");
});
it("routes package members to their owning package body", () => {
expect(
objectSourceTargetForTreeNode({
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
import type { TreeNode } from "@/types/database";
const typeNode: TreeNode = {
id: "connection:database:schema:type:OrderType",
label: "OrderType",
objectName: "OrderType",
type: "type",
connectionId: "connection",
database: "database",
schema: "AppSchema",
isExpanded: false,
xuguTypeMembersExpandable: true,
};
describe("Xugu object type members", () => {
it("keeps the member loader exclusive to Xugu type specifications", () => {
expect(isXuguTypeMemberContainer(typeNode, "xugu")).toBe(true);
expect(isXuguTypeMemberContainer(typeNode, "oracle")).toBe(false);
expect(isXuguTypeMemberContainer({ ...typeNode, type: "type-body" }, "xugu")).toBe(false);
expect(isXuguTypeMemberContainer({ ...typeNode, xuguTypeMembersExpandable: false }, "xugu")).toBe(false);
expect(isXuguTypeMemberContainer({ ...typeNode, database: undefined }, "xugu")).toBe(false);
});
it("renders attributes and callable members without treating them as top-level routines", () => {
const nodes = buildXuguTypeMemberNodes(typeNode, [
{ name: "itemId", kind: "column", data_type: "INTEGER" },
{ name: "total", kind: "function", signature: "quantity INTEGER, price NUMERIC(12,2)", data_type: "NUMERIC(18,2)" },
{ name: "rename", kind: "procedure", signature: "OUT result VARCHAR(40)" },
{ name: "itemId", kind: "column", data_type: "INTEGER" },
]);
expect(nodes.map((node) => ({ type: node.type, label: node.label }))).toEqual([
{ type: "type-attribute", label: "itemId (INTEGER)" },
{ type: "type-method", label: "FUNCTION total(quantity INTEGER, price NUMERIC(12,2)) → NUMERIC(18,2)" },
{ type: "type-method", label: "PROCEDURE rename(OUT result VARCHAR(40))" },
]);
expect(nodes.every((node) => node.parentType === "type" && node.parentName === "OrderType" && node.children === undefined)).toBe(true);
});
});
+8 -2
View File
@@ -99,14 +99,20 @@ describe("programmable database objects", () => {
it("keeps Xugu trigger/type nodes distinct and preserves an invalid status", () => {
const objects: ObjectInfo[] = [
{ name: "TRG_AUDIT", object_type: "TRIGGER", schema: "APP", valid: false },
{ name: "ADDRESS_T", object_type: "TYPE", schema: "APP", valid: true },
{ name: "ADDRESS_T", object_type: "TYPE", schema: "APP", valid: true, xugu_type_members_expandable: true },
{ name: "ADDRESS_LIST", object_type: "TYPE", schema: "APP", valid: true, xugu_type_members_expandable: false },
{ name: "ADDRESS_T", object_type: "TYPE_BODY", schema: "APP", valid: true },
];
const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "APP", objects });
expect(nodes).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "trigger", objectName: "TRG_AUDIT", valid: false }), expect.objectContaining({ type: "type", objectName: "ADDRESS_T", valid: true }), expect.objectContaining({ type: "type-body", objectName: "ADDRESS_T", valid: true })]),
expect.arrayContaining([
expect.objectContaining({ type: "trigger", objectName: "TRG_AUDIT", valid: false }),
expect.objectContaining({ type: "type", objectName: "ADDRESS_T", valid: true, xuguTypeMembersExpandable: true }),
expect.objectContaining({ type: "type", objectName: "ADDRESS_LIST", valid: true, xuguTypeMembersExpandable: false }),
expect.objectContaining({ type: "type-body", objectName: "ADDRESS_T", valid: true }),
]),
);
});
@@ -156,7 +156,7 @@ export function canTreeNodeExpand(type: TreeNodeType): boolean {
}
export function canTreeNodeShowExpander({ type, childCount, explicitContainer = false }: { type: TreeNodeType; childCount?: number; explicitContainer?: boolean }): boolean {
if (!canTreeNodeExpand(type) && !(type === "package" && explicitContainer)) return false;
if (!canTreeNodeExpand(type) && !((type === "package" || type === "type") && explicitContainer)) return false;
if (childCount === 0 && emptyContainerTypes.has(type)) return false;
return true;
}
@@ -92,7 +92,7 @@ export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activa
if (!shouldActivateTreeNodeOnSingleClick(type, activation)) return "none";
if (type === "extension") return "open-extension-details";
if (dataNodeTypes.has(type)) return "open-data";
if (type === "package" && canExpand) return "toggle";
if ((type === "package" || type === "type") && canExpand) return "toggle";
if (sourceNodeTypes.has(type)) return "open-source";
if (toggleLeafNodeTypes.has(type)) return "toggle";
if (canExpand) return "toggle";
@@ -0,0 +1,57 @@
import type { CompletionAssistantCandidate, TreeNode } from "@/types/database";
export function isXuguTypeMemberContainer(node: TreeNode, databaseType?: string): boolean {
return databaseType === "xugu" && node.type === "type" && node.xuguTypeMembersExpandable === true && !!node.connectionId && !!node.database;
}
export function buildXuguTypeMemberNodes(typeNode: TreeNode, candidates: readonly CompletionAssistantCandidate[]): TreeNode[] {
const parentName = typeNode.objectName || typeNode.label;
const parentSchema = typeNode.schema;
const seen = new Set<string>();
const attributes: TreeNode[] = [];
const methods: TreeNode[] = [];
for (const candidate of candidates) {
const name = candidate.name.trim();
if (!name || (candidate.kind !== "column" && candidate.kind !== "procedure" && candidate.kind !== "function")) continue;
const signature = candidate.signature?.trim() || "";
const dataType = candidate.data_type?.trim() || "";
const key = `${candidate.kind}\0${name}\0${signature}\0${dataType}`;
if (seen.has(key)) continue;
seen.add(key);
const base = {
connectionId: typeNode.connectionId,
database: typeNode.database,
schema: parentSchema,
parentName,
parentSchema,
parentType: "type" as const,
isExpanded: false,
children: undefined,
};
if (candidate.kind === "column") {
attributes.push({
...base,
id: `${typeNode.id}:attribute:${name}`,
label: dataType ? `${name} (${dataType})` : name,
type: "type-attribute",
objectName: name,
});
continue;
}
const call = `${name}(${signature})`;
const label = candidate.kind === "function" ? `FUNCTION ${call}${dataType ? ` → ${dataType}` : ""}` : `PROCEDURE ${call}`;
methods.push({
...base,
id: `${typeNode.id}:method:${candidate.kind}:${name}:${signature}`,
label,
type: "type-method",
objectName: name,
signature: signature || undefined,
});
}
return [...attributes, ...methods];
}
+2
View File
@@ -587,6 +587,7 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch
signature: signature || undefined,
comment: obj.comment,
valid: obj.valid ?? undefined,
xuguTypeMembersExpandable: objectType === "TYPE" && obj.xugu_type_members_expandable === true,
connectionId,
database,
schema: childSchema,
@@ -760,6 +761,7 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc
signature: signature || undefined,
comment: obj.comment,
valid: obj.valid ?? undefined,
xuguTypeMembersExpandable: objectType === "TYPE" && obj.xugu_type_members_expandable === true,
connectionId,
database,
schema: childSchema,
@@ -193,6 +193,7 @@ describe("connectionStore metadata loading", () => {
search_in_definitions: false,
parent_schema: "app_schema",
parent_name: "business_api",
parent_type: "package",
match_mode: "prefix",
});
expect(packageNode.isExpanded).toBe(true);
+62 -2
View File
@@ -110,6 +110,7 @@ import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel }
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { buildPackageMemberNodes, markPackageNodesExpandable } from "@/lib/sidebar/packageMembers";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
@@ -1871,7 +1872,8 @@ export const useConnectionStore = defineStore("connection", () => {
schema: options.effectiveSchema,
objects: supplementalObjects,
});
if (supportsPackageMemberExpansion(effectiveDatabaseTypeForConnection(getConfig(options.connectionId)))) {
const databaseType = effectiveDatabaseTypeForConnection(getConfig(options.connectionId));
if (supportsPackageMemberExpansion(databaseType)) {
supplementalChildren = markPackageNodesExpandable(supplementalChildren);
}
if (supplementalChildren.length === 0) return;
@@ -5608,6 +5610,7 @@ export const useConnectionStore = defineStore("connection", () => {
search_in_definitions: !!request.search_in_definitions,
parent_schema: request.parent_schema ?? "",
parent_name: request.parent_name ?? "",
parent_type: request.parent_type ?? "",
match_mode: request.match_mode ?? "prefix",
});
}
@@ -5621,7 +5624,8 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadPackageMembers(node: TreeNode, options?: LoadTreeOptions): Promise<void> {
if (node.type !== "package" || !node.connectionId || !node.database) return;
if (!supportsPackageMemberExpansion(effectiveDatabaseTypeForConnection(getConfig(node.connectionId)))) return;
const databaseType = effectiveDatabaseTypeForConnection(getConfig(node.connectionId));
if (!supportsPackageMemberExpansion(databaseType)) return;
const connectionId = node.connectionId;
const database = node.database;
const schema = node.schema;
@@ -5654,6 +5658,7 @@ export const useConnectionStore = defineStore("connection", () => {
search_in_definitions: false,
parent_schema: schema ?? null,
parent_name: packageName,
...(databaseType === "xugu" ? { parent_type: "package" as const } : {}),
match_mode: "prefix",
});
const targetNode = treeNodeLoadTarget(load);
@@ -5671,6 +5676,60 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadXuguTypeMembers(node: TreeNode): Promise<void> {
if (!isXuguTypeMemberContainer(node, getConfig(node.connectionId || "")?.db_type)) return;
const connectionId = node.connectionId;
const database = node.database;
if (!connectionId || !database) return;
if (node.isExpanded) {
node.isExpanded = false;
if (!sidebarSearchQuery.value) releaseCollapsedTreeNodeChildren(node.id);
return;
}
if (node.children && node.children.length > 0) {
node.isExpanded = true;
return;
}
const schema = node.schema || "";
const parentName = node.objectName || node.label;
node.isLoading = true;
try {
const [attributes, methods] = await Promise.all([
completionAssistantSearch({
connection_id: connectionId,
database,
schema,
object_kinds: ["column"],
mask: "",
max_results: 500,
global_search: false,
parent_schema: schema,
parent_name: parentName,
parent_type: "type",
match_mode: "prefix",
}),
completionAssistantSearch({
connection_id: connectionId,
database,
schema,
object_kinds: ["routine"],
mask: "",
max_results: 500,
global_search: false,
parent_schema: schema,
parent_name: parentName,
parent_type: "type",
match_mode: "prefix",
}),
]);
node.children = buildXuguTypeMemberNodes(node, [...attributes.candidates, ...methods.candidates]);
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]);
const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle"]);
@@ -7283,6 +7342,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadTableForLocate,
loadObjectGroupChildren,
loadPackageMembers,
loadXuguTypeMembers,
loadMoreObjectGroupChildren,
loadAllObjectGroupChildren,
loadTableGroups,
+7 -1
View File
@@ -105,6 +105,7 @@ export interface CompletionAssistantRequest {
search_in_definitions?: boolean;
parent_schema?: string | null;
parent_name?: string | null;
parent_type?: "package" | "type" | null;
match_mode?: CompletionAssistantMatchMode | null;
}
@@ -433,6 +434,7 @@ export interface ObjectInfo {
updated_at?: string | null;
parent_schema?: string | null;
parent_name?: string | null;
xugu_type_members_expandable?: boolean | null;
}
export interface ObjectStatistics {
@@ -829,6 +831,8 @@ export type TreeNodeType =
| "table-search-control"
| "load-more"
| "column"
| "type-attribute"
| "type-method"
| "index"
| "fkey"
| "trigger"
@@ -890,10 +894,12 @@ export interface TreeNode {
tableName?: string;
objectName?: string;
signature?: string;
/** Owning package/type for nested routine members. */
/** Owning programmable object for a nested metadata member. */
parentName?: string;
parentSchema?: string;
parentType?: TreeNodeType;
/** Set only for XuguDB object types whose members can be loaded lazily. */
xuguTypeMembersExpandable?: boolean;
tableType?: string;
comment?: string | null;
valid?: boolean | null;