From c0c97ef0e43d666e63d770e565ce969e086843d8 Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:06:55 +0800 Subject: [PATCH] feat(xugu): add object dependency inspection --- .../sidebar/SidebarTreeRuntimeHost.vue | 32 ++++++ apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../databaseObjectDependencies.spec.ts | 32 ++++++ .../database/xuguObjectDependencies.spec.ts | 47 ++++++++ .../database/databaseObjectDependencies.ts | 33 ++++++ .../lib/database/xuguObjectDependencies.ts | 100 ++++++++++++++++++ 13 files changed, 252 insertions(+) create mode 100644 apps/desktop/src/lib/__tests__/database/databaseObjectDependencies.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/database/xuguObjectDependencies.spec.ts create mode 100644 apps/desktop/src/lib/database/databaseObjectDependencies.ts create mode 100644 apps/desktop/src/lib/database/xuguObjectDependencies.ts diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 7a2e3da330..1baf58d59d 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -85,6 +85,7 @@ import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting" import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; import { isXuguSyntheticTreeNode } from "@/lib/sidebar/xuguPublicSynonyms"; import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/database/xuguSchedulerJobSql"; +import { canViewDatabaseObjectDependencies, databaseDependencyProviderFor } from "@/lib/database/databaseObjectDependencies"; import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions"; import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates"; import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates"; @@ -2492,6 +2493,25 @@ function openProcedureExecution() { emit("open-procedure", node); } +async function openDatabaseObjectDependencies() { + const node = activeNode.value; + const provider = databaseDependencyProviderFor(currentDatabaseType()); + if (!provider || !node.connectionId || !node.database || !provider.supports(node)) return; + const sql = provider.buildQuery(node); + if (!sql) return; + + try { + await connectionStore.ensureConnected(node.connectionId); + connectionStore.activeConnectionId = node.connectionId; + const objectName = node.objectName || node.label; + const tabId = queryStore.createTab(node.connectionId, node.database, `${t("contextMenu.viewDependencies")} - ${objectName}`, "query", node.schema, undefined, node.catalog, { forceNew: true }); + queryStore.updateSql(tabId, sql); + await queryStore.executeTabSql(tabId, sql); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + async function compileXuguObject() { const node = activeNode.value; if (currentDatabaseType() !== "xugu" || !node.connectionId || !node.database) return; @@ -5789,6 +5809,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { icon: FileCode, }); } + if (canViewDatabaseObjectDependencies(currentDatabaseType(), node)) { + items.push({ label: t("contextMenu.viewDependencies"), action: openDatabaseObjectDependencies, icon: Network }); + } if (node.type === "view" || node.type === "materialized_view") { items.push({ label: t("contextMenu.editView"), action: () => openObjectSourceDialog(true), icon: Pencil }); items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); @@ -5930,6 +5953,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); } + if (canViewDatabaseObjectDependencies(currentDatabaseType(), node)) { + items.push({ label: t("contextMenu.viewDependencies"), action: openDatabaseObjectDependencies, icon: Network }); + } if (node.type === "index" && canOpenStructureEditor.value) { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.editIndex"), action: openStructureEditor, icon: PencilRuler }); @@ -5966,6 +5992,9 @@ 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 (!isPackageMember && canViewDatabaseObjectDependencies(currentDatabaseType(), node)) { + items.push({ label: t("contextMenu.viewDependencies"), action: openDatabaseObjectDependencies, icon: Network }); + } if (currentDatabaseType() === "mysql") { items.push(copyNameMenuItem()); } @@ -6031,6 +6060,9 @@ 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 (canViewDatabaseObjectDependencies(currentDatabaseType(), node)) { + items.push({ label: t("contextMenu.viewDependencies"), action: openDatabaseObjectDependencies, icon: Network }); + } if (node.type === "package" && currentDatabaseType() === "xugu" && node.xuguPackageBodyAvailable === true) { items.push({ label: `${t("contextMenu.viewSource")} (${t("objects.packageBody")})`, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 636c65d233..726d97e9c7 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3214,6 +3214,7 @@ export default { dropTableSuccess: 'Table "{name}" dropped', editView: "Edit View", viewSource: "View Source", + viewDependencies: "View Dependencies", viewObject: "View", changeOpenMode: "Change open mode", viewDetails: "View Details", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 45700eedf8..a3283e3e69 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3338,6 +3338,7 @@ export default withEnglishFallback({ dropEventSuccess: "Evento eliminado {name}", editObject: "Editar objeto", createEvent: "Crear evento", + viewDependencies: "Ver dependencias", }, visibleDatabases: { title: "Bases de datos visibles", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index f6d064f0f7..70156b3336 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3336,6 +3336,7 @@ export default withEnglishFallback({ dropEventSuccess: "Evento eliminato {name}", editObject: "Modifica oggetto", createEvent: "Nuovo evento", + viewDependencies: "Visualizza dipendenze", }, visibleDatabases: { title: "Database Visibili", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 9bfd8e473b..164559d3bc 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3362,6 +3362,7 @@ export default withEnglishFallback({ dropEventSuccess: "イベントが削除されました {name}", editObject: "オブジェクトを編集", createEvent: "イベントを作成", + viewDependencies: "依存関係を表示", }, visibleDatabases: { title: "表示するデータベース", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index b40a4f26d6..2ae03df8dd 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3215,6 +3215,7 @@ export default withEnglishFallback({ eventSave: "Save", refreshDdlOnOpen: "열 때마다 새로 고침", refreshDdlOnOpenHint: "켜면 열 때마다 데이터베이스에서 DDL을 다시 불러오고, 끄면 캐시된 데이터를 우선 사용합니다", + viewDependencies: "의존 관계 보기", }, visibleDatabases: { title: "표시할 데이터베이스", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9fffb4eea4..29c94b2713 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3338,6 +3338,7 @@ export default withEnglishFallback({ dropEventSuccess: "Evento excluído {name}", editObject: "Editar objeto", createEvent: "Criar evento", + viewDependencies: "Ver dependências", }, visibleDatabases: { title: "Bancos de dados visíveis", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 860ce27218..1b3bfd511c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3137,6 +3137,7 @@ export default withEnglishFallback({ dropTableSuccess: "表「{name}」已删除", editView: "编辑视图", viewSource: "查看源码", + viewDependencies: "查看依赖关系", viewObject: "查看", changeOpenMode: "修改打开方式", viewDetails: "查看详情", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 327ce809db..6e9910904c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3087,6 +3087,7 @@ export default withEnglishFallback({ dropTableSuccess: "資料表「{name}」已刪除", editView: "編輯檢視", viewSource: "檢視原始碼", + viewDependencies: "檢視相依關係", viewDetails: "檢視詳細資料", viewDdl: "檢視 DDL", viewDdlLoading: "正在讀取 DDL...", diff --git a/apps/desktop/src/lib/__tests__/database/databaseObjectDependencies.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseObjectDependencies.spec.ts new file mode 100644 index 0000000000..e11edcbfce --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/databaseObjectDependencies.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { canViewDatabaseObjectDependencies, databaseDependencyProviderFor } from "@/lib/database/databaseObjectDependencies"; + +const node = (overrides: Record = {}) => ({ + type: "table" as const, + schema: "APP", + objectName: "ORDERS", + label: "ORDERS", + ...overrides, +}); + +describe("database dependency providers", () => { + it("resolves only providers registered for the requested database", () => { + expect(databaseDependencyProviderFor("xugu")?.databaseType).toBe("xugu"); + expect(databaseDependencyProviderFor("postgres")).toBeNull(); + expect(databaseDependencyProviderFor(undefined)).toBeNull(); + }); + + it("keeps the UI capability check database-agnostic", () => { + expect(canViewDatabaseObjectDependencies("xugu", node())).toBe(true); + expect(canViewDatabaseObjectDependencies("postgres", node())).toBe(false); + }); + + it("does not expose package members or incomplete nodes", () => { + const provider = databaseDependencyProviderFor("xugu"); + expect(provider).not.toBeNull(); + expect(provider!.supports(node({ type: "procedure", parentType: "package", parentName: "PKG" }))).toBe(false); + expect(provider!.supports(node({ type: "package-body" }))).toBe(false); + expect(provider!.buildQuery(node({ schema: undefined }))).toBeNull(); + expect(provider!.buildQuery(node({ type: "sequence" }))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/database/xuguObjectDependencies.spec.ts b/apps/desktop/src/lib/__tests__/database/xuguObjectDependencies.spec.ts new file mode 100644 index 0000000000..1bd643437c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/xuguObjectDependencies.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { xuguDependencyObjectTypeForTreeNode, xuguObjectDependenciesSql } from "@/lib/database/xuguObjectDependencies"; + +describe("xuguObjectDependenciesSql", () => { + it("reads both directions from the accessible dependency dictionary", () => { + const sql = xuguObjectDependenciesSql({ schema: "APP", objectName: "ORDERS", objectType: "table" }); + + expect(sql).toContain("JOIN ALL_DEPENDS"); + expect(sql).toContain("WHERE o.DB_ID = CURRENT_DB_ID"); + expect(sql).toContain("'DEPENDS_ON'"); + expect(sql).toContain("'REFERENCED_BY'"); + expect(sql).toContain("AND o.OBJ_TYPE = 5"); + expect(sql).toContain("d.OWNER_ID2 = t.SCHEMA_ID AND d.OBJ_ID2 = t.OBJ_ID AND d.OBJ_TYPE2 = t.OBJ_TYPE"); + expect(sql).toContain("SELECT 'DEPENDS_ON' AS DIRECTION, d.OWNER_ID1 AS OWNER_ID, d.OBJ_ID1 AS OBJ_ID, d.OBJ_TYPE1 AS OBJ_TYPE"); + expect(sql).toContain("d.OWNER_ID1 = t.SCHEMA_ID AND d.OBJ_ID1 = t.OBJ_ID AND d.OBJ_TYPE1 = t.OBJ_TYPE"); + expect(sql).toContain("SELECT 'REFERENCED_BY' AS DIRECTION, d.OWNER_ID2 AS OWNER_ID, d.OBJ_ID2 AS OBJ_ID, d.OBJ_TYPE2 AS OBJ_TYPE"); + expect(sql).toContain("o.SCHEMA_ID = r.OWNER_ID AND o.OBJ_ID = r.OBJ_ID AND o.OBJ_TYPE = r.OBJ_TYPE"); + expect(sql).not.toContain("t.USER_ID"); + expect(sql).not.toContain("o.USER_ID = r.OWNER_ID"); + expect(sql).toContain("ORDER BY r.DIRECTION, s.SCHEMA_NAME, o.OBJ_NAME"); + }); + + it("uses the Xugu routine object type and escapes dictionary lookup literals", () => { + const sql = xuguObjectDependenciesSql({ schema: "O'REILLY", objectName: "RUN'JOB", objectType: "function" }); + + expect(sql).toContain("UPPER('O''REILLY')"); + expect(sql).toContain("UPPER('RUN''JOB')"); + expect(sql).toContain("AND o.OBJ_TYPE = 7"); + }); +}); + +describe("xuguDependencyObjectTypeForTreeNode", () => { + it.each([ + ["table", "table"], + ["view", "view"], + ["procedure", "procedure"], + ["function", "function"], + ["trigger", "trigger"], + ["package", "package"], + ] as const)("maps %s to a supported dependency object", (nodeType, expected) => { + expect(xuguDependencyObjectTypeForTreeNode(nodeType)).toBe(expected); + }); + + it.each(["materialized_view", "package-body", "type", "sequence", "synonym"] as const)("does not expose unsupported %s objects", (nodeType) => { + expect(xuguDependencyObjectTypeForTreeNode(nodeType)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/database/databaseObjectDependencies.ts b/apps/desktop/src/lib/database/databaseObjectDependencies.ts new file mode 100644 index 0000000000..8c84486fa0 --- /dev/null +++ b/apps/desktop/src/lib/database/databaseObjectDependencies.ts @@ -0,0 +1,33 @@ +import type { DatabaseType, TreeNode } from "@/types/database"; +import { xuguDependencyProvider } from "@/lib/database/xuguObjectDependencies"; + +/** + * The subset of a sidebar node needed by a database dependency provider. + * Keeping this contract small lets providers remain independent of Vue state + * and makes it possible to test them without constructing a full tree node. + */ +export type DependencyTreeNode = Pick; + +/** + * Database-specific dependency inspection contract. + * + * Providers own catalog SQL and object-type mappings. The sidebar only asks + * whether a node is supported and executes the returned read-only query. + */ +export interface DatabaseDependencyProvider { + readonly databaseType: DatabaseType; + supports(node: DependencyTreeNode): boolean; + buildQuery(node: DependencyTreeNode): string | null; +} + +const PROVIDERS: Partial> = { + xugu: xuguDependencyProvider, +}; + +export function databaseDependencyProviderFor(databaseType?: DatabaseType): DatabaseDependencyProvider | null { + return databaseType ? (PROVIDERS[databaseType] ?? null) : null; +} + +export function canViewDatabaseObjectDependencies(databaseType: DatabaseType | undefined, node: DependencyTreeNode): boolean { + return databaseDependencyProviderFor(databaseType)?.supports(node) ?? false; +} diff --git a/apps/desktop/src/lib/database/xuguObjectDependencies.ts b/apps/desktop/src/lib/database/xuguObjectDependencies.ts new file mode 100644 index 0000000000..157611215c --- /dev/null +++ b/apps/desktop/src/lib/database/xuguObjectDependencies.ts @@ -0,0 +1,100 @@ +import type { TreeNodeType } from "@/types/database"; +import type { DatabaseDependencyProvider, DependencyTreeNode } from "@/lib/database/databaseObjectDependencies"; + +export type XuguDependencyObjectType = "table" | "view" | "procedure" | "function" | "trigger" | "package"; + +const OBJECT_TYPE_CODES: Record = { + table: 5, + procedure: 7, + function: 7, + view: 9, + trigger: 11, + package: 18, +}; + +/** + * Returns the XuguDB dictionary object kind supported by the dependency + * inspector. Package members and package bodies intentionally return null: + * ALL_DEPENDS records dependencies for their owning schema object instead. + */ +export function xuguDependencyObjectTypeForTreeNode(type: TreeNodeType): XuguDependencyObjectType | null { + switch (type) { + case "table": + case "view": + case "procedure": + case "function": + case "trigger": + case "package": + return type; + default: + return null; + } +} + +/** + * Builds a read-only query over XuguDB's accessible dependency dictionary. + * + * ALL_* views deliberately preserve the server's permission boundary: a + * regular user sees only dependencies of objects it is allowed to inspect, + * while administrative connections can see the broader catalog. Both + * directions are returned so callers can assess an object's prerequisites and + * the impact of changing it. + */ +export function xuguObjectDependenciesSql(options: { schema: string; objectName: string; objectType: XuguDependencyObjectType }): string { + const schema = quoteXuguString(options.schema); + const objectName = quoteXuguString(options.objectName); + const objectType = OBJECT_TYPE_CODES[options.objectType]; + return `WITH target AS ( + SELECT o.DB_ID, o.SCHEMA_ID, o.OBJ_ID, o.OBJ_TYPE + FROM ALL_OBJECTS o + JOIN ALL_SCHEMAS s ON s.DB_ID = o.DB_ID AND s.SCHEMA_ID = o.SCHEMA_ID + WHERE o.DB_ID = CURRENT_DB_ID + AND UPPER(s.SCHEMA_NAME) = UPPER(${schema}) + AND UPPER(o.OBJ_NAME) = UPPER(${objectName}) + AND o.OBJ_TYPE = ${objectType} +), dependency_rows AS ( + SELECT 'DEPENDS_ON' AS DIRECTION, d.OWNER_ID1 AS OWNER_ID, d.OBJ_ID1 AS OBJ_ID, d.OBJ_TYPE1 AS OBJ_TYPE, d.DB_ID + FROM target t + JOIN ALL_DEPENDS d ON d.DB_ID = t.DB_ID AND d.OWNER_ID2 = t.SCHEMA_ID AND d.OBJ_ID2 = t.OBJ_ID AND d.OBJ_TYPE2 = t.OBJ_TYPE + UNION ALL + SELECT 'REFERENCED_BY' AS DIRECTION, d.OWNER_ID2 AS OWNER_ID, d.OBJ_ID2 AS OBJ_ID, d.OBJ_TYPE2 AS OBJ_TYPE, d.DB_ID + FROM target t + JOIN ALL_DEPENDS d ON d.DB_ID = t.DB_ID AND d.OWNER_ID1 = t.SCHEMA_ID AND d.OBJ_ID1 = t.OBJ_ID AND d.OBJ_TYPE1 = t.OBJ_TYPE +) +SELECT r.DIRECTION, + s.SCHEMA_NAME, + o.OBJ_NAME AS OBJECT_NAME, + CASE o.OBJ_TYPE + WHEN 5 THEN 'TABLE' + WHEN 7 THEN 'PROCEDURE_OR_FUNCTION' + WHEN 9 THEN 'VIEW' + WHEN 11 THEN 'TRIGGER' + WHEN 18 THEN 'PACKAGE' + ELSE 'OBJECT_' || o.OBJ_TYPE + END AS OBJECT_TYPE +FROM dependency_rows r +JOIN ALL_OBJECTS o ON o.DB_ID = r.DB_ID AND o.SCHEMA_ID = r.OWNER_ID AND o.OBJ_ID = r.OBJ_ID AND o.OBJ_TYPE = r.OBJ_TYPE +JOIN ALL_SCHEMAS s ON s.DB_ID = o.DB_ID AND s.SCHEMA_ID = o.SCHEMA_ID +ORDER BY r.DIRECTION, s.SCHEMA_NAME, o.OBJ_NAME;`; +} + +/** XuguDB implementation of the shared dependency-inspection contract. */ +export const xuguDependencyProvider: DatabaseDependencyProvider = { + databaseType: "xugu", + supports(node: DependencyTreeNode): boolean { + // Package members and package bodies do not have independent ALL_DEPENDS + // records in XuguDB; exposing the action for them would be misleading. + if (node.parentType === "package" || node.type === "package-body") return false; + return xuguDependencyObjectTypeForTreeNode(node.type) !== null; + }, + buildQuery(node: DependencyTreeNode): string | null { + const objectType = xuguDependencyObjectTypeForTreeNode(node.type); + const objectName = node.objectName || node.label; + if (!objectType || !node.schema || !objectName) return null; + return xuguObjectDependenciesSql({ schema: node.schema, objectName, objectType }); + }, +}; + +function quoteXuguString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +}