feat(xugu): expose object type members in schema tree
This commit is contained in:
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
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user