feat(xugu): group object type members
This commit is contained in:
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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2818,6 +2818,8 @@ export default withEnglishFallback({
|
||||
damengJobAdmin: "エージェントジョブ",
|
||||
defaultDatabase: "デフォルトDB",
|
||||
columns: "列",
|
||||
attributes: "属性",
|
||||
methods: "メソッド",
|
||||
indexes: "インデックス",
|
||||
foreignKeys: "外部キー",
|
||||
triggers: "トリガー",
|
||||
|
||||
@@ -2704,6 +2704,8 @@ export default withEnglishFallback({
|
||||
linkedServers: "연결된 서버",
|
||||
defaultDatabase: "기본 DB",
|
||||
columns: "컬럼",
|
||||
attributes: "속성",
|
||||
methods: "메서드",
|
||||
indexes: "인덱스",
|
||||
foreignKeys: "외래 키",
|
||||
triggers: "트리거",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2778,6 +2778,8 @@ export default withEnglishFallback({
|
||||
linkedServers: "链接服务器",
|
||||
defaultDatabase: "默认库",
|
||||
columns: "字段",
|
||||
attributes: "属性",
|
||||
methods: "方法",
|
||||
indexes: "索引",
|
||||
foreignKeys: "外键",
|
||||
triggers: "触发器",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -899,6 +899,8 @@ export type TreeNodeType =
|
||||
| "column"
|
||||
| "type-attribute"
|
||||
| "type-method"
|
||||
| "type-attributes"
|
||||
| "type-methods"
|
||||
| "index"
|
||||
| "fkey"
|
||||
| "trigger"
|
||||
|
||||
Reference in new issue
Block a user