feat(xugu): group object type members

This commit is contained in:
Elias authored and GitHub committed 2026-08-11 21:34:50 +08:00
1 parent c72b429899
commit 801a96a003
18 files changed
+273 -36

No files matched your search

@@ -614,6 +614,12 @@ async function toggle() {
return;
}
if (node.type === "type-attributes" || node.type === "type-methods") {
node.isExpanded = !node.isExpanded;
emitNodeToggled(node, wasExpanded);
return;
}
if (node.type === "group-extensions" && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
@@ -229,6 +229,14 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
} else {
return { icon: Columns3, colorClass: "text-muted-foreground" };
}
case "type-attribute":
return { icon: Columns3, colorClass: "text-muted-foreground" };
case "type-method":
return { icon: Braces, colorClass: "text-amber-500" };
case "type-attributes":
return { icon: ListTree, colorClass: "text-green-400" };
case "type-methods":
return { icon: Braces, colorClass: "text-amber-500" };
case "group-columns":
return { icon: ListTree, colorClass: "text-green-400" };
case "group-indexes":
@@ -1247,7 +1255,9 @@ function onKeydown(event: KeyboardEvent) {
node.type === 'group-synonyms' ||
node.type === 'group-packages' ||
node.type === 'group-types' ||
node.type === 'group-partitions') &&
node.type === 'group-partitions' ||
node.type === 'type-attributes' ||
node.type === 'type-methods') &&
node.objectCount != null
"
class="text-muted-foreground text-[10px] shrink-0"
+2
View File
@@ -2853,6 +2853,8 @@ export default {
linkedServers: "Linked Servers",
defaultDatabase: "Default DB",
columns: "Columns",
attributes: "Attributes",
methods: "Methods",
indexes: "Indexes",
foreignKeys: "Foreign Keys",
triggers: "Triggers",
+2
View File
@@ -2794,6 +2794,8 @@ export default withEnglishFallback({
linkedServers: "Servidores vinculados",
defaultDatabase: "Base predeterminada",
columns: "Columnas",
attributes: "Atributos",
methods: "Métodos",
indexes: "Índices",
foreignKeys: "Claves foráneas",
triggers: "Disparadores",
+2
View File
@@ -2792,6 +2792,8 @@ export default withEnglishFallback({
linkedServers: "Server Collegati",
defaultDatabase: "DB Predefinito",
columns: "Colonne",
attributes: "Attributi",
methods: "Metodi",
indexes: "Indici",
foreignKeys: "Chiavi Esterne",
triggers: "Trigger",
+2
View File
@@ -2818,6 +2818,8 @@ export default withEnglishFallback({
damengJobAdmin: "エージェントジョブ",
defaultDatabase: "デフォルトDB",
columns: "列",
attributes: "属性",
methods: "メソッド",
indexes: "インデックス",
foreignKeys: "外部キー",
triggers: "トリガー",
+2
View File
@@ -2704,6 +2704,8 @@ export default withEnglishFallback({
linkedServers: "연결된 서버",
defaultDatabase: "기본 DB",
columns: "컬럼",
attributes: "속성",
methods: "메서드",
indexes: "인덱스",
foreignKeys: "외래 키",
triggers: "트리거",
+2
View File
@@ -2795,6 +2795,8 @@ export default withEnglishFallback({
materializedViews: "Visualizações Materializadas",
defaultDatabase: "Banco padrão",
columns: "Colunas",
attributes: "Atributos",
methods: "Métodos",
indexes: "Índices",
foreignKeys: "Chaves estrangeiras",
triggers: "Triggers",
+2
View File
@@ -2778,6 +2778,8 @@ export default withEnglishFallback({
linkedServers: "链接服务器",
defaultDatabase: "默认库",
columns: "字段",
attributes: "属性",
methods: "方法",
indexes: "索引",
foreignKeys: "外键",
triggers: "触发器",
+2
View File
@@ -2794,6 +2794,8 @@ export default withEnglishFallback({
materializedViews: "具體化檢視",
defaultDatabase: "預設庫",
columns: "欄位",
attributes: "屬性",
methods: "方法",
indexes: "索引",
foreignKeys: "外鍵",
triggers: "觸發器",
@@ -31,11 +31,32 @@ describe("Xugu object type members", () => {
{ name: "itemId", kind: "column", data_type: "INTEGER" },
]);
expect(nodes.map((node) => ({ type: node.type, label: node.label }))).toEqual([
{ type: "type-attribute", label: "itemId (INTEGER)" },
expect(nodes.map((node) => ({ type: node.type, label: node.label, objectCount: node.objectCount }))).toEqual([
{ type: "type-attributes", label: "tree.attributes", objectCount: 1 },
{ type: "type-methods", label: "tree.methods", objectCount: 2 },
]);
expect(nodes.every((node) => node.parentType === "type" && node.parentName === "OrderType")).toBe(true);
expect(nodes[0].children?.map((node) => ({ type: node.type, label: node.label }))).toEqual([{ type: "type-attribute", label: "itemId (INTEGER)" }]);
expect(nodes[1].children?.map((node) => ({ type: node.type, label: node.label }))).toEqual([
{ 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);
expect(nodes.flatMap((node) => node.children ?? []).every((node) => node.parentType === "type" && node.parentName === "OrderType" && node.children === undefined)).toBe(true);
});
it("omits the methods group when a type only has attributes", () => {
const nodes = buildXuguTypeMemberNodes(typeNode, [{ name: "itemId", kind: "column", data_type: "INTEGER" }]);
expect(nodes.map((node) => ({ type: node.type, objectCount: node.objectCount }))).toEqual([{ type: "type-attributes", objectCount: 1 }]);
});
it("omits the attributes group when a type only has methods", () => {
const nodes = buildXuguTypeMemberNodes(typeNode, [{ name: "rename", kind: "procedure", signature: "newName VARCHAR(40)" }]);
expect(nodes.map((node) => ({ type: node.type, objectCount: node.objectCount }))).toEqual([{ type: "type-methods", objectCount: 1 }]);
});
it("returns no groups when a type has no members", () => {
expect(buildXuguTypeMemberNodes(typeNode, [])).toEqual([]);
});
});
@@ -12,6 +12,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"package-body",
"type-body",
"type-member",
"type-attribute",
"type-method",
"object-browser",
"redis-db",
"mq-tenant",
@@ -19,6 +19,8 @@ const treeGroupNodeTypes = new Set<TreeNodeType>([
"group-types",
"group-partitions",
"group-extensions",
"type-attributes",
"type-methods",
]);
export function isTreeGroupNodeType(type: TreeNodeType): boolean {
@@ -46,6 +46,14 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
return { icon: Eye, colorClass: "text-indigo-500" };
case "column":
return { icon: Columns3, colorClass: (node.meta as ColumnInfo | undefined)?.is_primary_key ? "text-orange-400" : "text-muted-foreground" };
case "type-attribute":
return { icon: Columns3, colorClass: "text-muted-foreground" };
case "type-method":
return { icon: Braces, colorClass: "text-amber-500" };
case "type-attributes":
return { icon: ListTree, colorClass: "text-green-400" };
case "type-methods":
return { icon: Braces, colorClass: "text-amber-500" };
case "group-columns":
return { icon: ListTree, colorClass: "text-green-400" };
case "group-indexes":
@@ -1,10 +1,15 @@
import type { CompletionAssistantCandidate, TreeNode } from "@/types/database";
export type XuguTypeMemberGroupLabels = {
attributes: string;
methods: string;
};
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[] {
export function buildXuguTypeMemberNodes(typeNode: TreeNode, candidates: readonly CompletionAssistantCandidate[], labels: XuguTypeMemberGroupLabels = { attributes: "tree.attributes", methods: "tree.methods" }): TreeNode[] {
const parentName = typeNode.objectName || typeNode.label;
const parentSchema = typeNode.schema;
const seen = new Set<string>();
@@ -53,5 +58,38 @@ export function buildXuguTypeMemberNodes(typeNode: TreeNode, candidates: readonl
});
}
return [...attributes, ...methods];
const groupBase = {
connectionId: typeNode.connectionId,
database: typeNode.database,
schema: parentSchema,
parentName,
parentSchema,
parentType: "type" as const,
isExpanded: false,
};
const groups: TreeNode[] = [];
if (attributes.length > 0) {
groups.push({
...groupBase,
id: `${typeNode.id}:attributes`,
label: labels.attributes,
type: "type-attributes",
objectName: parentName,
objectCount: attributes.length,
children: attributes,
});
}
if (methods.length > 0) {
groups.push({
...groupBase,
id: `${typeNode.id}:methods`,
label: labels.methods,
type: "type-methods",
objectName: parentName,
objectCount: methods.length,
children: methods,
});
}
return groups;
}
@@ -1,5 +1,6 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
import type { ConnectionConfig, ObjectInfo, TableInfo, TreeNode } from "@/types/database";
function installLocalStorage() {
@@ -201,6 +202,113 @@ describe("connectionStore metadata loading", () => {
expect(packageNode.children?.every((child) => child.parentName === "business_api")).toBe(true);
}, 15000);
it("loads Xugu object type members through the scoped completion endpoint", async () => {
const completionAssistantSearch = vi.fn(async (request: { object_kinds?: string[] }) => ({
candidates: request.object_kinds?.includes("column")
? [
{ name: "street", kind: "column", data_type: "VARCHAR(120)" },
{ name: "created_at", kind: "column", data_type: "DATETIME" },
]
: [{ name: "format", kind: "function", signature: "p_locale IN VARCHAR", data_type: "VARCHAR" }],
incomplete: false,
fallback_used: false,
}));
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), completionAssistantSearch }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = xuguConnection();
const typeNode: TreeNode = {
id: "xugu-1:app_db:app_schema:type:address_t",
label: "ADDRESS_T",
type: "type",
objectName: "ADDRESS_T",
connectionId: connection.id,
database: "app_db",
schema: "app_schema",
xuguTypeMembersExpandable: true,
children: [],
isExpanded: false,
};
store.connections = [connection];
store.connectedIds = new Set([connection.id]);
store.treeNodes = [typeNode];
await store.loadXuguTypeMembers(typeNode);
expect(completionAssistantSearch).toHaveBeenCalledTimes(2);
expect(completionAssistantSearch).toHaveBeenCalledWith(
expect.objectContaining({
connection_id: connection.id,
database: "app_db",
schema: "app_schema",
object_kinds: ["column"],
parent_schema: "app_schema",
parent_name: "ADDRESS_T",
parent_type: "type",
}),
);
expect(completionAssistantSearch).toHaveBeenCalledWith(
expect.objectContaining({
connection_id: connection.id,
database: "app_db",
schema: "app_schema",
object_kinds: ["routine"],
parent_schema: "app_schema",
parent_name: "ADDRESS_T",
parent_type: "type",
}),
);
expect(typeNode.isExpanded).toBe(true);
expect(typeNode.children?.map((child) => ({ label: child.label, objectCount: child.objectCount }))).toEqual([
{ label: "tree.attributes", objectCount: 2 },
{ label: "tree.methods", objectCount: 1 },
]);
expect(typeNode.children?.[0].children?.map((child) => child.label)).toEqual(["street (VARCHAR(120))", "created_at (DATETIME)"]);
expect(typeNode.children?.[1].children?.map((child) => child.label)).toEqual(["FUNCTION format(p_locale IN VARCHAR) → VARCHAR"]);
expect(typeNode.children?.every((child) => child.parentName === "ADDRESS_T")).toBe(true);
}, 15000);
it("hides the Xugu type expander after loading no members", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [], incomplete: false, fallback_used: false });
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), completionAssistantSearch }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = xuguConnection();
const typeNode: TreeNode = {
id: "xugu-1:app_db:app_schema:type:empty_t",
label: "EMPTY_T",
type: "type",
objectName: "EMPTY_T",
connectionId: connection.id,
database: "app_db",
schema: "app_schema",
xuguTypeMembersExpandable: true,
children: [],
isExpanded: false,
};
store.connections = [connection];
store.connectedIds = new Set([connection.id]);
store.treeNodes = [typeNode];
await store.loadXuguTypeMembers(typeNode);
expect(completionAssistantSearch).toHaveBeenCalledTimes(2);
expect(typeNode.children).toEqual([]);
expect(typeNode.isExpanded).toBe(false);
expect(typeNode.xuguTypeMembersExpandable).toBe(false);
expect(
canTreeNodeShowExpander({
type: typeNode.type,
childCount: typeNode.children?.length ?? 0,
explicitContainer: typeNode.xuguTypeMembersExpandable === true,
}),
).toBe(false);
}, 15000);
it("loads Oracle package overloads through the shared package member path", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [
+54 -30
View File
@@ -5728,40 +5728,64 @@ export const useConnectionStore = defineStore("connection", () => {
const schema = node.schema || "";
const parentName = node.objectName || node.label;
node.isLoading = true;
let load = beginTreeNodeLoad(node);
try {
const [attributes, methods] = await Promise.all([
completionAssistantSearch({
connection_id: connectionId,
await runTreeMetadataLoad(
{
kind: "xugu-type-members",
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;
nodeKind: node.type,
extra: { typeName: parentName },
},
async () => {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
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",
}),
]);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const children = buildXuguTypeMemberNodes(targetNode, [...attributes.candidates, ...methods.candidates], {
attributes: "tree.attributes",
methods: "tree.methods",
});
setChildren(targetNode, children);
targetNode.isExpanded = children.length > 0;
if (children.length === 0) targetNode.xuguTypeMembersExpandable = false;
},
);
} catch (error) {
recordMetadataLoadError(connectionId, error, load);
throw error;
} finally {
node.isLoading = false;
finishTreeNodeLoad(load);
}
}
+2
View File
@@ -899,6 +899,8 @@ export type TreeNodeType =
| "column"
| "type-attribute"
| "type-method"
| "type-attributes"
| "type-methods"
| "index"
| "fkey"
| "trigger"