feat(xugu): add object dependency inspection
This commit is contained in:
1 parent
dc7b905220
commit
c0c97ef0e4
13 files changed
+252
No files matched your search
@@ -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")})`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3336,6 +3336,7 @@ export default withEnglishFallback({
|
||||
dropEventSuccess: "Evento eliminato {name}",
|
||||
editObject: "Modifica oggetto",
|
||||
createEvent: "Nuovo evento",
|
||||
viewDependencies: "Visualizza dipendenze",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Database Visibili",
|
||||
|
||||
@@ -3362,6 +3362,7 @@ export default withEnglishFallback({
|
||||
dropEventSuccess: "イベントが削除されました {name}",
|
||||
editObject: "オブジェクトを編集",
|
||||
createEvent: "イベントを作成",
|
||||
viewDependencies: "依存関係を表示",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "表示するデータベース",
|
||||
|
||||
@@ -3215,6 +3215,7 @@ export default withEnglishFallback({
|
||||
eventSave: "Save",
|
||||
refreshDdlOnOpen: "열 때마다 새로 고침",
|
||||
refreshDdlOnOpenHint: "켜면 열 때마다 데이터베이스에서 DDL을 다시 불러오고, 끄면 캐시된 데이터를 우선 사용합니다",
|
||||
viewDependencies: "의존 관계 보기",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "표시할 데이터베이스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3137,6 +3137,7 @@ export default withEnglishFallback({
|
||||
dropTableSuccess: "表「{name}」已删除",
|
||||
editView: "编辑视图",
|
||||
viewSource: "查看源码",
|
||||
viewDependencies: "查看依赖关系",
|
||||
viewObject: "查看",
|
||||
changeOpenMode: "修改打开方式",
|
||||
viewDetails: "查看详情",
|
||||
|
||||
@@ -3087,6 +3087,7 @@ export default withEnglishFallback({
|
||||
dropTableSuccess: "資料表「{name}」已刪除",
|
||||
editView: "編輯檢視",
|
||||
viewSource: "檢視原始碼",
|
||||
viewDependencies: "檢視相依關係",
|
||||
viewDetails: "檢視詳細資料",
|
||||
viewDdl: "檢視 DDL",
|
||||
viewDdlLoading: "正在讀取 DDL...",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canViewDatabaseObjectDependencies, databaseDependencyProviderFor } from "@/lib/database/databaseObjectDependencies";
|
||||
|
||||
const node = (overrides: Record<string, unknown> = {}) => ({
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<TreeNode, "type" | "schema" | "objectName" | "label" | "parentType" | "parentName">;
|
||||
|
||||
/**
|
||||
* 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<Record<DatabaseType, DatabaseDependencyProvider>> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<XuguDependencyObjectType, number> = {
|
||||
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("'", "''")}'`;
|
||||
}
|
||||
Reference in new issue
Block a user