From 7eca75b325ff01308b6de42cf089220f1028a050 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 1 Jun 2026 18:43:20 +0800 Subject: [PATCH] feat: support Oracle package sources --- .../src/components/objects/ObjectBrowser.vue | 34 +++- .../src/components/sidebar/TreeItem.vue | 28 ++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + apps/desktop/src/lib/objectBrowserRows.ts | 4 +- apps/desktop/src/lib/sidebarTreeItemLayout.ts | 2 + apps/desktop/src/lib/tableTree.ts | 60 +++++-- apps/desktop/src/lib/treeNodeClick.ts | 4 +- apps/desktop/src/stores/connectionStore.ts | 1 + apps/desktop/src/types/database.ts | 7 +- crates/dbx-core/src/db/ob_oracle.rs | 8 + crates/dbx-core/src/object_source_sql.rs | 20 +++ crates/dbx-core/src/schema.rs | 161 ++++++++++++++++-- crates/dbx-core/src/transfer.rs | 1 + crates/dbx-core/src/types.rs | 2 + packages/app-tests/objectBrowserRows.test.ts | 20 +++ packages/app-tests/tableTree.test.ts | 23 +++ packages/app-tests/treeNodeClick.test.ts | 2 + .../main/java/app/dbx/jdbc/DbxJdbcPlugin.java | 62 +++++++ .../java/app/dbx/jdbc/DbxJdbcPluginTest.java | 10 ++ 22 files changed, 429 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index b9d7fa18fe..f450506811 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -24,6 +24,7 @@ import { PencilLine, PencilRuler, Play, + Package, RefreshCw, Scissors, Search, @@ -89,7 +90,7 @@ import { type ObjectBrowserSortKey, } from "@/lib/objectBrowserRows"; -type ObjectFilter = "all" | "tables" | "views" | "procedures" | "functions"; +type ObjectFilter = "all" | "tables" | "views" | "procedures" | "functions" | "packages"; const props = defineProps<{ connection: ConnectionConfig; @@ -158,6 +159,9 @@ const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length); const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length); const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length); +const packageCount = computed( + () => rows.value.filter((row) => row.type === "PACKAGE" || row.type === "PACKAGE_BODY").length, +); const canOpenStructureEditor = computed(() => supportsTableStructureEditing(props.connection.db_type)); const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(props.connection.db_type)); const canOpenTableImport = computed(() => !!props.database && supportsTableImport(props.connection.db_type)); @@ -186,6 +190,7 @@ const objectFilters = computed(() => ["views", viewCount.value], ["procedures", procedureCount.value], ["functions", functionCount.value], + ["packages", packageCount.value], ] as Array<[ObjectFilter, number]> ) .filter(([filter, count]) => filter === "all" || count > 0) @@ -230,6 +235,7 @@ function iconFor(row: ObjectBrowserRow) { if (row.type === "VIEW") return Eye; if (row.type === "PROCEDURE") return ScrollText; if (row.type === "FUNCTION") return Braces; + if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") return Package; return Table2; } @@ -237,6 +243,8 @@ function typeLabel(type: ObjectBrowserRow["type"]) { if (type === "VIEW") return t("objects.view"); if (type === "PROCEDURE") return t("objects.procedure"); if (type === "FUNCTION") return t("objects.function"); + if (type === "PACKAGE") return t("objects.package"); + if (type === "PACKAGE_BODY") return t("objects.packageBody"); return t("objects.table"); } @@ -259,6 +267,7 @@ function rowMatchesObjectFilter(row: ObjectBrowserRow) { if (objectFilter.value === "views") return row.type === "VIEW"; if (objectFilter.value === "procedures") return row.type === "PROCEDURE"; if (objectFilter.value === "functions") return row.type === "FUNCTION"; + if (objectFilter.value === "packages") return row.type === "PACKAGE" || row.type === "PACKAGE_BODY"; return true; } @@ -298,6 +307,7 @@ function iconClass(type: ObjectBrowserRow["type"]) { if (type === "VIEW") return "text-purple-500"; if (type === "PROCEDURE") return "text-blue-500"; if (type === "FUNCTION") return "text-amber-500"; + if (type === "PACKAGE" || type === "PACKAGE_BODY") return "text-cyan-500"; return "text-green-500"; } @@ -314,7 +324,13 @@ function togglePartitionParent(row: ObjectBrowserRow) { } function canOpenSource(row: ObjectBrowserRow) { - return row.type === "VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION"; + return ( + row.type === "VIEW" || + row.type === "PROCEDURE" || + row.type === "FUNCTION" || + row.type === "PACKAGE" || + row.type === "PACKAGE_BODY" + ); } function canRename(row: ObjectBrowserRow) { @@ -1063,6 +1079,7 @@ function filterCount(filter: ObjectFilter) { if (filter === "views") return viewCount.value; if (filter === "procedures") return procedureCount.value; if (filter === "functions") return functionCount.value; + if (filter === "packages") return packageCount.value; return rows.value.length; } @@ -1076,7 +1093,9 @@ function filterLabel(filter: ObjectFilter) { ? "objects.procedures" : filter === "functions" ? "objects.functions" - : "objects.all"; + : filter === "packages" + ? "objects.packages" + : "objects.all"; return `${t(key)} ${filterCount(filter)}`; } @@ -1224,9 +1243,18 @@ function getProcFuncMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { ]; } +function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + return [ + { label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 }, + { label: "", separator: true }, + { label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy }, + ]; +} + function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { if (item.type === "TABLE") return getTableMenuItems(item); if (item.type === "VIEW") return getViewMenuItems(item); + if (item.type === "PACKAGE" || item.type === "PACKAGE_BODY") return getPackageMenuItems(item); return getProcFuncMenuItems(item); } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index ec68bd6a87..67364e60d1 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -45,6 +45,7 @@ import { Braces, Code2, ListFilter, + Package, } from "lucide-vue-next"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -223,6 +224,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: ScrollText, colorClass: "text-blue-500" }; case "function": return { icon: Braces, colorClass: "text-amber-500" }; + case "package": + return { icon: Package, colorClass: "text-cyan-500" }; + case "package-body": + return { icon: FileCode, colorClass: "text-cyan-400" }; case "group-tables": return { icon: Table, colorClass: "text-green-500" }; case "group-views": @@ -231,6 +236,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: ScrollText, colorClass: "text-blue-500" }; case "group-functions": return { icon: Braces, colorClass: "text-amber-500" }; + case "group-packages": + return { icon: Package, colorClass: "text-cyan-500" }; case "group-partitions": return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" }; default: @@ -247,6 +254,7 @@ const groupTypes: Set = new Set([ "group-views", "group-procedures", "group-functions", + "group-packages", "group-partitions", "saved-sql-root", "saved-sql-folder", @@ -307,6 +315,7 @@ async function toggle() { node.type === "group-views" || node.type === "group-procedures" || node.type === "group-functions" || + node.type === "group-packages" || node.type === "group-partitions" ) { node.isExpanded = !node.isExpanded; @@ -400,7 +409,12 @@ function runRowClickAction() { const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation); if (action === "open-data") { openData(); - } else if (node.type === "procedure" || node.type === "function") { + } else if ( + node.type === "procedure" || + node.type === "function" || + node.type === "package" || + node.type === "package-body" + ) { void viewObjectSource(); } else if (node.type === "saved-sql-file") { openSavedSqlFile(); @@ -1628,6 +1642,8 @@ const hasTypeMenu = computed(() => { t === "column" || t === "procedure" || t === "function" || + t === "package" || + t === "package-body" || t === "saved-sql-root" || t === "saved-sql-folder" || t === "saved-sql-file" || @@ -2224,7 +2240,7 @@ function treeItemMenuItems(): ContextMenuItem[] { return items; } - // 8. Procedure / Function + // 8. Procedure / Function / Package if (node.type === "procedure" || node.type === "function") { if (node.type === "procedure") { items.push({ label: t("contextMenu.executeProcedure"), action: openProcedureExecution, icon: Play }); @@ -2243,6 +2259,13 @@ function treeItemMenuItems(): ContextMenuItem[] { return items; } + if (node.type === "package" || node.type === "package-body") { + items.push({ label: t("contextMenu.viewSource"), action: viewObjectSource, icon: Code2 }); + items.push({ label: "", separator: true }); + items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy }); + return items; + } + // 9. Group Labels (saved-sql-root, saved-sql-folder, group-columns, etc.) if (isGroupLabel(node)) { if (node.type === "saved-sql-root") { @@ -2369,6 +2392,7 @@ function treeItemMenuItems(): ContextMenuItem[] { node.type === 'group-views' || node.type === 'group-procedures' || node.type === 'group-functions' || + node.type === 'group-packages' || node.type === 'group-partitions') && node.objectCount != null " diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ae0a554cc7..01e710640e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1017,6 +1017,7 @@ export default { views: "Views", procedures: "Procedures", functions: "Functions", + packages: "Packages", partitions: "Partitions", objectBrowser: "Browse in Object Browser ({count})", }, @@ -1026,11 +1027,14 @@ export default { views: "Views", procedures: "Procedures", functions: "Functions", + packages: "Packages", table: "Table", partitions: "{count} partitions", view: "View", procedure: "Procedure", function: "Function", + package: "Package", + packageBody: "Package Body", name: "Name", type: "Type", createdAt: "Created", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 76e4cb04c3..a8733b0c8c 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -911,6 +911,7 @@ export default { views: "Vistas", procedures: "Procedimientos", functions: "Funciones", + packages: "Paquetes", partitions: "Particiones", objectBrowser: "Explorar en el navegador de objetos ({count})", }, @@ -920,11 +921,14 @@ export default { views: "Vistas", procedures: "Procedimientos", functions: "Funciones", + packages: "Paquetes", table: "Tabla", partitions: "{count} particiones", view: "Vista", procedure: "Procedimiento", function: "Función", + package: "Paquete", + packageBody: "Cuerpo del paquete", name: "Nombre", type: "Tipo", createdAt: "Creado", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index c9505cee50..1e9c617964 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -993,6 +993,7 @@ export default { views: "视图", procedures: "存储过程", functions: "函数", + packages: "包", partitions: "分区", objectBrowser: "在对象浏览器中查看 ({count})", }, @@ -1002,11 +1003,14 @@ export default { views: "视图", procedures: "存储过程", functions: "函数", + packages: "包", table: "表", partitions: "{count} 个分区", view: "视图", procedure: "存储过程", function: "函数", + package: "包", + packageBody: "包体", name: "名称", type: "类型", createdAt: "创建时间", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 333e819e34..4f1342c464 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -993,6 +993,7 @@ export default { views: "檢視", procedures: "預存程序", functions: "函式", + packages: "套件", partitions: "分割區", objectBrowser: "在物件瀏覽器中檢視 ({count})", }, @@ -1002,11 +1003,14 @@ export default { views: "檢視", procedures: "預存程序", functions: "函式", + packages: "套件", table: "資料表", partitions: "{count} 個分割區", view: "檢視", procedure: "預存程序", function: "函式", + package: "套件", + packageBody: "套件主體", name: "名稱", type: "類型", createdAt: "建立時間", diff --git a/apps/desktop/src/lib/objectBrowserRows.ts b/apps/desktop/src/lib/objectBrowserRows.ts index a5ad68df4d..9615691698 100644 --- a/apps/desktop/src/lib/objectBrowserRows.ts +++ b/apps/desktop/src/lib/objectBrowserRows.ts @@ -6,7 +6,7 @@ export type ObjectBrowserRow = { id: string; name: string; schema?: string; - type: "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION"; + type: "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" | "PACKAGE" | "PACKAGE_BODY"; comment?: string | null; created_at?: string | null; updated_at?: string | null; @@ -21,6 +21,8 @@ export type ObjectBrowserSortDirection = "asc" | "desc"; export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] { const value = type.toUpperCase(); + if (value.includes("PACKAGE BODY") || value.includes("PACKAGE_BODY")) return "PACKAGE_BODY"; + if (value.includes("PACKAGE")) return "PACKAGE"; if (value.includes("VIEW")) return "VIEW"; if (value.includes("PROC")) return "PROCEDURE"; if (value.includes("FUNC")) return "FUNCTION"; diff --git a/apps/desktop/src/lib/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebarTreeItemLayout.ts index c25b08d1cc..40addb9e15 100644 --- a/apps/desktop/src/lib/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebarTreeItemLayout.ts @@ -7,6 +7,8 @@ const leafTypes: Set = new Set([ "trigger", "procedure", "function", + "package", + "package-body", "object-browser", "redis-db", "mongo-collection", diff --git a/apps/desktop/src/lib/tableTree.ts b/apps/desktop/src/lib/tableTree.ts index 48fae155af..90be61d49c 100644 --- a/apps/desktop/src/lib/tableTree.ts +++ b/apps/desktop/src/lib/tableTree.ts @@ -45,7 +45,7 @@ export function buildTableTreeNodes({ type TableTreeEntry = { key: string; - objectType: "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION"; + objectType: DatabaseObjectTreeKind; schema?: string; parentSchema?: string; parentName?: string; @@ -70,7 +70,7 @@ function makeTableTreeEntry({ schema?: string; includeSchemaInId?: boolean; name: string; - objectType: "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION"; + objectType: DatabaseObjectTreeKind; comment?: string | null; parentSchema?: string | null; parentName?: string | null; @@ -267,6 +267,8 @@ export function hasTablePartitionGroups(node: TreeNode): boolean { return partitionGroupChildren(node).length > 0; } +type DatabaseObjectTreeKind = "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" | "PACKAGE" | "PACKAGE_BODY"; + function buildObjectTreeEntries({ nodeId, connectionId, @@ -323,7 +325,8 @@ export function buildSimpleObjectTreeNodes({ for (const obj of objects) { const objectType = normalizeObjectType(obj.object_type); - if (objectType !== "TABLE" && objectType !== "VIEW") continue; + if (objectType !== "TABLE" && objectType !== "VIEW" && objectType !== "PACKAGE" && objectType !== "PACKAGE_BODY") + continue; const name = normalizeDatabaseObjectName(obj.name); if (!name) continue; @@ -346,8 +349,20 @@ export function buildSimpleObjectTreeNodes({ }); if (objectType === "TABLE") { tableEntries.push(entry); - } else { + } else if (objectType === "VIEW") { viewNodes.push(entry.node); + } else { + viewNodes.push({ + id: `${nodeId}:${childSchema ? `${childSchema}:` : ""}${name}:${objectType}`, + label: name, + type: objectType === "PACKAGE_BODY" ? "package-body" : "package", + comment: obj.comment, + connectionId, + database, + schema: childSchema, + isExpanded: false, + children: undefined, + }); } } @@ -357,8 +372,10 @@ export function buildSimpleObjectTreeNodes({ ]; } -function normalizeObjectType(type: string): "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" { +function normalizeObjectType(type: string): DatabaseObjectTreeKind { const v = type.toUpperCase(); + if (v.includes("PACKAGE BODY") || v.includes("PACKAGE_BODY")) return "PACKAGE_BODY"; + if (v.includes("PACKAGE")) return "PACKAGE"; if (v.includes("VIEW")) return "VIEW"; if (v.includes("PROC")) return "PROCEDURE"; if (v.includes("FUNC")) return "FUNCTION"; @@ -368,26 +385,33 @@ function normalizeObjectType(type: string): "TABLE" | "VIEW" | "PROCEDURE" | "FU const groupDefs: Array<{ key: string; label: string; - objectType: string; + objectTypes: DatabaseObjectTreeKind[]; nodeType: TreeNodeType; - childType: TreeNodeType; + childType: TreeNodeType | ((objectType: DatabaseObjectTreeKind) => TreeNodeType); }> = [ - { key: "__tables", label: "tree.tables", objectType: "TABLE", nodeType: "group-tables", childType: "table" }, - { key: "__views", label: "tree.views", objectType: "VIEW", nodeType: "group-views", childType: "view" }, + { key: "__tables", label: "tree.tables", objectTypes: ["TABLE"], nodeType: "group-tables", childType: "table" }, + { key: "__views", label: "tree.views", objectTypes: ["VIEW"], nodeType: "group-views", childType: "view" }, { key: "__procedures", label: "tree.procedures", - objectType: "PROCEDURE", + objectTypes: ["PROCEDURE"], nodeType: "group-procedures", childType: "procedure", }, { key: "__functions", label: "tree.functions", - objectType: "FUNCTION", + objectTypes: ["FUNCTION"], nodeType: "group-functions", childType: "function", }, + { + key: "__packages", + label: "tree.packages", + objectTypes: ["PACKAGE", "PACKAGE_BODY"], + nodeType: "group-packages", + childType: (objectType) => (objectType === "PACKAGE_BODY" ? "package-body" : "package"), + }, ]; const objectGroupNodeTypes = new Set([ @@ -395,6 +419,7 @@ const objectGroupNodeTypes = new Set([ "group-views", "group-procedures", "group-functions", + "group-packages", ]); export function objectGroupRefreshParentId(node: TreeNode): string | null { @@ -434,9 +459,9 @@ export function buildGroupedObjectTreeNodes({ const groups: TreeNode[] = []; for (const def of groupDefs) { - const items = buckets.get(def.objectType); + const items = def.objectTypes.flatMap((objectType) => buckets.get(objectType) ?? []); if (!items?.length) continue; - const isExpandable = def.childType === "table" || def.childType === "view"; + const isExpandable = def.nodeType === "group-tables" || def.nodeType === "group-views"; const children = isExpandable ? buildObjectTreeEntries({ nodeId: `${nodeId}:${def.key}`, @@ -444,14 +469,17 @@ export function buildGroupedObjectTreeNodes({ database, schema, objects: items, - objectType: def.objectType as "TABLE" | "VIEW", + objectType: def.objectTypes[0] as "TABLE" | "VIEW", }) : sortDatabaseObjectsByPrefixPriority(items, (obj) => obj.name).map((obj) => { const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema; + const objectType = normalizeObjectType(obj.object_type); + const childType = typeof def.childType === "function" ? def.childType(objectType) : def.childType; + const objectTypeSuffix = objectType === "PACKAGE" || objectType === "PACKAGE_BODY" ? `:${objectType}` : ""; return { - id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}`, + id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}${objectTypeSuffix}`, label: obj.name, - type: def.childType, + type: childType, comment: obj.comment, connectionId, database, diff --git a/apps/desktop/src/lib/treeNodeClick.ts b/apps/desktop/src/lib/treeNodeClick.ts index a1887deca9..c070c83ac3 100644 --- a/apps/desktop/src/lib/treeNodeClick.ts +++ b/apps/desktop/src/lib/treeNodeClick.ts @@ -16,12 +16,14 @@ export type SidebarActivation = "single" | "double"; const dataNodeTypes = new Set(["table", "view"]); const toggleLeafNodeTypes = new Set(["redis-db", "mongo-collection"]); const objectBrowserNodeTypes = new Set(["database", "schema", "object-browser"]); -const sourceNodeTypes = new Set(["procedure", "function"]); +const sourceNodeTypes = new Set(["procedure", "function", "package", "package-body"]); export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKind | null { if (type === "view") return "VIEW"; if (type === "procedure") return "PROCEDURE"; if (type === "function") return "FUNCTION"; + if (type === "package") return "PACKAGE"; + if (type === "package-body") return "PACKAGE_BODY"; return null; } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 05379eeded..14e3b0d125 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1321,6 +1321,7 @@ export const useConnectionStore = defineStore("connection", () => { node.type === "group-views" || node.type === "group-procedures" || node.type === "group-functions" || + node.type === "group-packages" || node.type === "group-partitions" ) { node.isExpanded = true; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 726b11de35..729e6eadfc 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -172,7 +172,7 @@ export interface TableInfo { parent_name?: string | null; } -export type DatabaseObjectType = "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION"; +export type DatabaseObjectType = "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" | "PACKAGE" | "PACKAGE_BODY"; export interface ObjectInfo { name: string; @@ -185,7 +185,7 @@ export interface ObjectInfo { parent_name?: string | null; } -export type ObjectSourceKind = "VIEW" | "PROCEDURE" | "FUNCTION"; +export type ObjectSourceKind = "VIEW" | "PROCEDURE" | "FUNCTION" | "PACKAGE" | "PACKAGE_BODY"; export interface ObjectSource { name: string; @@ -275,6 +275,8 @@ export type TreeNodeType = | "view" | "procedure" | "function" + | "package" + | "package-body" | "group-columns" | "group-indexes" | "group-fkeys" @@ -283,6 +285,7 @@ export type TreeNodeType = | "group-views" | "group-procedures" | "group-functions" + | "group-packages" | "group-partitions" | "object-browser" | "saved-sql-root" diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index b1366000d0..ebd3fd89a8 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -79,6 +79,11 @@ fn list_objects_sql(schema: &str) -> String { SELECT OBJECT_NAME, OBJECT_TYPE, CASE WHEN OBJECT_TYPE = 'PROCEDURE' THEN 2 ELSE 3 END AS SORT_ORDER \ FROM ALL_PROCEDURES \ WHERE OWNER = {s} AND OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION') AND PROCEDURE_NAME IS NULL \ + UNION ALL \ + SELECT OBJECT_NAME, CASE OBJECT_TYPE WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY' ELSE OBJECT_TYPE END AS OBJECT_TYPE, \ + CASE WHEN OBJECT_TYPE = 'PACKAGE' THEN 4 ELSE 5 END AS SORT_ORDER \ + FROM ALL_OBJECTS \ + WHERE OWNER = {s} AND OBJECT_TYPE IN ('PACKAGE', 'PACKAGE BODY') \ ORDER BY SORT_ORDER, OBJECT_NAME", s = quote_value(schema), ) @@ -277,5 +282,8 @@ mod tests { assert!(sql.contains("ALL_PROCEDURES")); assert!(sql.contains("'PROCEDURE'")); assert!(sql.contains("'FUNCTION'")); + assert!(sql.contains("ALL_OBJECTS")); + assert!(sql.contains("'PACKAGE'")); + assert!(sql.contains("'PACKAGE BODY'")); } } diff --git a/crates/dbx-core/src/object_source_sql.rs b/crates/dbx-core/src/object_source_sql.rs index a1868bbf9a..4c36a69cdb 100644 --- a/crates/dbx-core/src/object_source_sql.rs +++ b/crates/dbx-core/src/object_source_sql.rs @@ -231,6 +231,8 @@ fn object_type_keyword(object_type: &ObjectSourceKind) -> &'static str { ObjectSourceKind::View => "VIEW", ObjectSourceKind::Procedure => "PROCEDURE", ObjectSourceKind::Function => "FUNCTION", + ObjectSourceKind::Package => "PACKAGE", + ObjectSourceKind::PackageBody => "PACKAGE BODY", } } @@ -376,6 +378,10 @@ fn parse_object_source_kind(value: &str) -> Option { Some(ObjectSourceKind::Procedure) } else if value.eq_ignore_ascii_case("FUNCTION") { Some(ObjectSourceKind::Function) + } else if value.eq_ignore_ascii_case("PACKAGE") { + Some(ObjectSourceKind::Package) + } else if value.eq_ignore_ascii_case("PACKAGE BODY") || value.eq_ignore_ascii_case("PACKAGE_BODY") { + Some(ObjectSourceKind::PackageBody) } else { None } @@ -479,6 +485,20 @@ mod tests { assert_eq!(sql, "CREATE VIEW `reporting`.`active_users` AS\nSELECT id FROM users;"); } + #[test] + fn oracle_package_source_saves_as_single_create_or_replace_statement() { + let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput { + database_type: DatabaseType::Oracle, + object_type: ObjectSourceKind::PackageBody, + schema: Some("HR".to_string()), + name: "PAYROLL".to_string(), + source: "CREATE OR REPLACE PACKAGE BODY PAYROLL AS\nEND PAYROLL;".to_string(), + }) + .unwrap(); + + assert_eq!(sql, "CREATE OR REPLACE PACKAGE BODY PAYROLL AS\nEND PAYROLL;"); + } + #[test] fn postgres_procedure_rename_adds_drop_cleanup() { let statements = build_executable_object_source_statements(input( diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index ba330881d6..9c0c4fb252 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1,7 +1,9 @@ use crate::connection::{AppState, MysqlMode, PoolKind}; use crate::db; use crate::models::connection::{ConnectionConfig, DatabaseType}; +use crate::query::{agent_execute_query_params, QueryExecutionOptions}; use std::future::Future; +use std::sync::Arc; macro_rules! extract_pool { ($connections:expr, $key:expr, $variant:ident) => { @@ -524,6 +526,7 @@ async fn list_objects_once( schema: &str, ) -> Result, String> { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + let db_config = connection_config(state, connection_id).await; { let connections = state.connections.read().await; @@ -561,7 +564,15 @@ async fn list_objects_once( .await; } try_sqlserver!(connections, &pool_key, list_objects, schema); - try_agent!(connections, &pool_key, list_objects, database, schema); + if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { + let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle); + drop(connections); + if is_oracle { + return oracle_agent_list_objects(client, database, schema).await; + } + let mut client = client.lock().await; + return client.list_objects(database, schema).await; + } } let connections = state.connections.read().await; @@ -864,7 +875,10 @@ fn mysql_ident(value: &str) -> String { fn sqlite_object_type(kind: &db::ObjectSourceKind) -> &'static str { match kind { db::ObjectSourceKind::View => "view", - db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function => "routine", + db::ObjectSourceKind::Procedure + | db::ObjectSourceKind::Function + | db::ObjectSourceKind::Package + | db::ObjectSourceKind::PackageBody => "routine", } } @@ -873,6 +887,7 @@ fn sqlserver_object_type_filter(kind: &db::ObjectSourceKind) -> &'static str { db::ObjectSourceKind::View => "'V'", db::ObjectSourceKind::Procedure => "'P'", db::ObjectSourceKind::Function => "'FN','IF','TF','FS','FT'", + db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody => "''", } } @@ -914,6 +929,7 @@ pub fn postgres_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSou prokind ) } + db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody => "SELECT NULL WHERE FALSE".to_string(), } } @@ -922,13 +938,19 @@ pub fn oracle_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourc db::ObjectSourceKind::View => "VIEW", db::ObjectSourceKind::Procedure => "PROCEDURE", db::ObjectSourceKind::Function => "FUNCTION", + db::ObjectSourceKind::Package => "PACKAGE", + db::ObjectSourceKind::PackageBody => "PACKAGE_BODY", }; - format!( - "SELECT DBMS_METADATA.GET_DDL({}, {}, {}) FROM DUAL", - sql_string(object_type), - sql_string(name), - sql_string(schema) - ) + if schema.trim().is_empty() { + format!("SELECT DBMS_METADATA.GET_DDL({}, {}) FROM DUAL", sql_string(object_type), sql_string(name)) + } else { + format!( + "SELECT DBMS_METADATA.GET_DDL({}, {}, {}) FROM DUAL", + sql_string(object_type), + sql_string(name), + sql_string(schema) + ) + } } pub fn sqlite_object_source_sql(name: &str, kind: &db::ObjectSourceKind) -> String { @@ -944,6 +966,7 @@ pub fn mysql_object_source_sql(name: &str, kind: &db::ObjectSourceKind) -> Strin db::ObjectSourceKind::View => format!("SHOW CREATE VIEW {}", mysql_ident(name)), db::ObjectSourceKind::Procedure => format!("SHOW CREATE PROCEDURE {}", mysql_ident(name)), db::ObjectSourceKind::Function => format!("SHOW CREATE FUNCTION {}", mysql_ident(name)), + db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody => String::new(), } } @@ -998,8 +1021,27 @@ pub async fn get_object_source_core( object_type: db::ObjectSourceKind, ) -> Result { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + let db_config = connection_config(state, connection_id).await; let source = { let connections = state.connections.read().await; + if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(&pool_key) { + let config = config.clone(); + let session = session.clone(); + drop(connections); + let result: db::ObjectSource = session + .invoke( + "getObjectSource", + serde_json::json!({ + "connection": config.as_ref(), + "database": database, + "schema": schema, + "name": name, + "object_type": &object_type, + }), + ) + .await?; + return Ok(result); + } if let Some(client) = extract_pool!(&connections, &pool_key, SqlServer) { drop(connections); let mut client = client.lock().await; @@ -1009,9 +1051,15 @@ pub async fn get_object_source_core( )? } else if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { drop(connections); - let mut client = client.lock().await; - let result: db::ObjectSource = client.get_object_source(database, schema, name, &object_type).await?; - return Ok(result); + if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle) + && matches!(object_type, db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody) + { + oracle_agent_object_source(client, database, schema, name, &object_type).await? + } else { + let mut client = client.lock().await; + let result: db::ObjectSource = client.get_object_source(database, schema, name, &object_type).await?; + return Ok(result); + } } else { match connections.get(&pool_key).ok_or("Pool not found")? { PoolKind::Mysql(pool, _) => mysql_object_source(pool, name, &object_type).await?, @@ -1041,6 +1089,79 @@ pub async fn get_object_source_core( }) } +fn oracle_owner_filter(schema: &str) -> String { + let schema = schema.trim(); + if schema.is_empty() { + "USER".to_string() + } else { + sql_string(&schema.to_uppercase()) + } +} + +pub fn oracle_list_objects_sql(schema: &str) -> String { + format!( + "SELECT object_name, CASE object_type WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY' ELSE object_type END AS object_type, owner \ + FROM all_objects \ + WHERE owner = {} AND object_type IN ('TABLE', 'VIEW', 'PROCEDURE', 'FUNCTION', 'PACKAGE', 'PACKAGE BODY') \ + ORDER BY CASE object_type WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 WHEN 'FUNCTION' THEN 3 WHEN 'PACKAGE' THEN 4 ELSE 5 END, object_name", + oracle_owner_filter(schema) + ) +} + +async fn oracle_agent_list_objects( + client: Arc>, + database: &str, + schema: &str, +) -> Result, String> { + let sql = oracle_list_objects_sql(schema); + let params = agent_execute_query_params( + &sql, + if database.is_empty() { None } else { Some(database) }, + if schema.is_empty() { None } else { Some(schema) }, + QueryExecutionOptions { max_rows: Some(10_000), ..Default::default() }, + ); + let mut client = client.lock().await; + let result: db::QueryResult = client.execute_query(params).await?; + Ok(result + .rows + .into_iter() + .filter_map(|row| { + let name = row.first()?.as_str()?.to_string(); + let object_type = row.get(1)?.as_str()?.to_string(); + let schema = row.get(2).and_then(|value| value.as_str()).map(str::to_string); + Some(db::ObjectInfo { + name, + object_type, + schema, + comment: None, + created_at: None, + updated_at: None, + parent_schema: None, + parent_name: None, + }) + }) + .collect()) +} + +async fn oracle_agent_object_source( + client: Arc>, + database: &str, + schema: &str, + name: &str, + object_type: &db::ObjectSourceKind, +) -> Result { + let sql = oracle_object_source_sql(schema, name, object_type); + let params = agent_execute_query_params( + &sql, + if database.is_empty() { None } else { Some(database) }, + if schema.is_empty() { None } else { Some(schema) }, + QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, + ); + let mut client = client.lock().await; + let result: db::QueryResult = client.execute_query(params).await?; + first_string_cell(result) +} + async fn postgres_object_source( pool: &deadpool_postgres::Pool, schema: &str, @@ -1111,6 +1232,24 @@ mod object_source_tests { oracle_object_source_sql("HR", "ACTIVE_USERS", &ObjectSourceKind::View), "SELECT DBMS_METADATA.GET_DDL('VIEW', 'ACTIVE_USERS', 'HR') FROM DUAL" ); + assert_eq!( + oracle_object_source_sql("HR", "PAYROLL", &ObjectSourceKind::PackageBody), + "SELECT DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'PAYROLL', 'HR') FROM DUAL" + ); + assert_eq!( + oracle_object_source_sql("", "PAYROLL", &ObjectSourceKind::Package), + "SELECT DBMS_METADATA.GET_DDL('PACKAGE', 'PAYROLL') FROM DUAL" + ); + } + + #[test] + fn builds_oracle_list_objects_sql_with_packages() { + let sql = oracle_list_objects_sql("hr"); + + assert!(sql.contains("'PACKAGE'")); + assert!(sql.contains("'PACKAGE BODY'")); + assert!(sql.contains("CASE object_type WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY'")); + assert!(sql.contains("owner = 'HR'")); } } diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 1758362d11..6a7f32ec55 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -2259,6 +2259,7 @@ where rewrite_postgres_routine_schema(&object.source, &request.target_schema) .unwrap_or_else(|| object.source.clone()) } + db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody => object.source.clone(), }; let statements = build_executable_object_source_statements(EditableObjectSourceSqlInput { database_type: DatabaseType::Postgres, diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 8574ac9be7..63bdb8df03 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -32,6 +32,8 @@ pub enum ObjectSourceKind { View, Procedure, Function, + Package, + PackageBody, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/packages/app-tests/objectBrowserRows.test.ts b/packages/app-tests/objectBrowserRows.test.ts index 9a0611bd80..b745c2d8fe 100644 --- a/packages/app-tests/objectBrowserRows.test.ts +++ b/packages/app-tests/objectBrowserRows.test.ts @@ -25,6 +25,26 @@ test("builds unique row ids for overloaded routines with the same visible name", ); }); +test("object browser rows normalize Oracle package body objects", () => { + const rows = buildObjectBrowserRows({ + objects: [ + { name: "PAYROLL", object_type: "PACKAGE", schema: "HR" }, + { name: "PAYROLL", object_type: "PACKAGE BODY", schema: "HR" }, + ], + database: "orcl", + fallbackSchema: "HR", + needsSchema: true, + }); + + assert.deepEqual( + rows.map((row) => ({ id: row.id, type: row.type })), + [ + { id: "HR:PAYROLL:PACKAGE:0", type: "PACKAGE" }, + { id: "HR:PAYROLL:PACKAGE_BODY:0", type: "PACKAGE_BODY" }, + ], + ); +}); + test("object browser search matches names, types, and comments but not schema names", () => { const rows = buildObjectBrowserRows({ objects: [ diff --git a/packages/app-tests/tableTree.test.ts b/packages/app-tests/tableTree.test.ts index d4c2ed359b..9a511f8a58 100644 --- a/packages/app-tests/tableTree.test.ts +++ b/packages/app-tests/tableTree.test.ts @@ -127,3 +127,26 @@ test("buildGroupedObjectTreeNodes applies prefix-priority sorting inside object ["staff", "staff_his", "chat_staff", "chat_staff_his"], ); }); + +test("buildGroupedObjectTreeNodes groups Oracle packages and package bodies", () => { + const groups = buildGroupedObjectTreeNodes({ + nodeId: "conn:app:HR", + connectionId: "conn", + database: "app", + schema: "HR", + objects: [ + { name: "PAYROLL", object_type: "PACKAGE", schema: "HR" }, + { name: "PAYROLL", object_type: "PACKAGE_BODY", schema: "HR" }, + ], + }); + + const packageGroup = groups.find((node) => node.type === "group-packages"); + assert.equal(packageGroup?.label, "tree.packages"); + assert.deepEqual( + packageGroup?.children?.map((node) => ({ label: node.label, type: node.type, id: node.id })), + [ + { label: "PAYROLL", type: "package", id: "conn:app:HR:__packages:HR:PAYROLL:PACKAGE" }, + { label: "PAYROLL", type: "package-body", id: "conn:app:HR:__packages:HR:PAYROLL:PACKAGE_BODY" }, + ], + ); +}); diff --git a/packages/app-tests/treeNodeClick.test.ts b/packages/app-tests/treeNodeClick.test.ts index 87bf5cad79..0775a727d2 100644 --- a/packages/app-tests/treeNodeClick.test.ts +++ b/packages/app-tests/treeNodeClick.test.ts @@ -53,6 +53,8 @@ test("maps source-capable sidebar nodes to object source kinds", () => { assert.equal(objectSourceKindForTreeNode("view"), "VIEW"); assert.equal(objectSourceKindForTreeNode("procedure"), "PROCEDURE"); assert.equal(objectSourceKindForTreeNode("function"), "FUNCTION"); + assert.equal(objectSourceKindForTreeNode("package"), "PACKAGE"); + assert.equal(objectSourceKindForTreeNode("package-body"), "PACKAGE_BODY"); assert.equal(objectSourceKindForTreeNode("table"), null); }); diff --git a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java index c169fe222e..abc3714076 100644 --- a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java +++ b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java @@ -130,6 +130,13 @@ public final class DbxJdbcPlugin { optionalText(params, "database"), optionalText(params, "schema") ); + case "getObjectSource", "get_object_source" -> getObjectSource( + connection, + optionalText(params, "database"), + optionalText(params, "schema"), + requireText(params, "name"), + requireText(params, "object_type") + ); case "getColumns" -> getColumns( connection, optionalText(params, "database"), @@ -669,9 +676,64 @@ public final class DbxJdbcPlugin { } } } + String packageSql = + "SELECT object_name AS name, CASE object_type WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY' ELSE object_type END AS object_type " + + "FROM all_objects WHERE owner = ? AND object_type IN ('PACKAGE', 'PACKAGE BODY') ORDER BY object_type, object_name"; + try (PreparedStatement ps = conn.prepareStatement(packageSql)) { + ps.setString(1, owner); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + ObjectNode item = MAPPER.createObjectNode(); + item.put("name", rs.getString("name")); + item.put("object_type", rs.getString("object_type")); + putNullable(item, "schema", schemaLabel); + item.putNull("comment"); + result.add(item); + } + } + } return result; } + private static JsonNode getObjectSource(JsonNode connection, String database, String schema, String name, String objectType) + throws SQLException { + Connection conn = openConnection(connection); + if (!driverQuirks(connection).useOracleMetadata()) { + throw new SQLException("Object source is not supported by this JDBC driver"); + } + String owner = oracleEffectiveSchema(conn, schema); + String metadataType = oracleMetadataObjectType(objectType); + String sql = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, metadataType); + ps.setString(2, name); + ps.setString(3, owner); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new SQLException("Object source not found"); + } + ObjectNode item = MAPPER.createObjectNode(); + item.put("name", name); + item.put("object_type", objectType); + putNullable(item, "schema", owner); + putNullable(item, "source", rs.getString(1)); + return item; + } + } + } + + private static String oracleMetadataObjectType(String objectType) { + String normalized = objectType == null ? "" : objectType.trim().toUpperCase().replace(' ', '_'); + return switch (normalized) { + case "VIEW" -> "VIEW"; + case "PROCEDURE" -> "PROCEDURE"; + case "FUNCTION" -> "FUNCTION"; + case "PACKAGE" -> "PACKAGE"; + case "PACKAGE_BODY" -> "PACKAGE_BODY"; + default -> normalized; + }; + } + private static JsonNode oracleGetColumns(Connection conn, String owner, String table) throws SQLException { ArrayNode result = MAPPER.createArrayNode(); Set pks = oraclePrimaryKeys(conn, owner, table); diff --git a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java index 6d60fd2588..1c3635ca91 100644 --- a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java +++ b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java @@ -208,6 +208,16 @@ final class DbxJdbcPluginTest { assertEquals(true, response.path("result").path(0).path("is_primary_key").asBoolean()); } + @Test + void oracleMetadataObjectTypeAcceptsPackageBodyAliases() throws Exception { + Method method = DbxJdbcPlugin.class.getDeclaredMethod("oracleMetadataObjectType", String.class); + method.setAccessible(true); + + assertEquals("PACKAGE_BODY", method.invoke(null, "PACKAGE BODY")); + assertEquals("PACKAGE_BODY", method.invoke(null, "PACKAGE_BODY")); + assertEquals("PACKAGE", method.invoke(null, "PACKAGE")); + } + private static void createPeopleTable() throws Exception { request("executeQuery", """ {