diff --git a/apps/desktop/src/components/objects/ObjectSourceDialog.vue b/apps/desktop/src/components/objects/ObjectSourceDialog.vue index 7171ae4d9..f0bdb097d 100644 --- a/apps/desktop/src/components/objects/ObjectSourceDialog.vue +++ b/apps/desktop/src/components/objects/ObjectSourceDialog.vue @@ -22,6 +22,7 @@ const props = withDefaults( database: string; schema?: string; name: string; + relationName?: string; signature?: string; objectType: ObjectSourceKind; databaseType?: DatabaseType; @@ -59,7 +60,7 @@ const canEdit = computed(() => sourceEditable.value && props.objectType !== "SEQ const title = computed(() => `${editing.value ? t("contextMenu.editView") : t("contextMenu.viewSource")} - ${props.name}`); watch( - () => [props.open, props.connectionId, props.database, props.schema, props.name, props.signature, props.objectType, props.initialEditing] as const, + () => [props.open, props.connectionId, props.database, props.schema, props.name, props.relationName, props.signature, props.objectType, props.initialEditing] as const, () => { if (props.open) void loadSource(); }, @@ -79,7 +80,7 @@ async function loadSource(nextEditing = props.initialEditing && canEdit.value) { try { if (!props.databaseType) throw new Error("Connection type is unavailable."); const schema = props.schema || props.database; - const result = await api.getObjectSource(props.connectionId, props.database, schema, props.name, props.objectType, props.signature); + const result = await api.getObjectSource(props.connectionId, props.database, schema, props.name, props.objectType, props.signature, props.relationName); const editableAllowed = result.editable !== false; const editable = await buildEditableObjectSource({ databaseType: props.databaseType, diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 2ff7d0a7e..7367fabd3 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -1552,6 +1552,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); :database="sidebarObjectSourceTarget.node.database!" :schema="sidebarObjectSourceTarget.node.schema" :name="sidebarObjectSourceTarget.node.objectName || sidebarObjectSourceTarget.node.label" + :relation-name="sidebarObjectSourceTarget.node.tableName" :signature="sidebarObjectSourceTarget.node.signature" :object-type="sidebarObjectSourceType" :database-type="sidebarObjectSourceDatabaseType" diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index de38c70e1..4fbbddb76 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -681,10 +681,10 @@ function runRowClickAction(clickDetail: number) { if (!shouldRunTreeNodeRowAction(action, clickDetail)) return; if (action === "open-data") { scheduleOpenData(node); + } else if (action === "open-source") { + openObjectSourceDialog(false); } else if (isDocumentBrowserTreeNode(node.type)) { openMongoTreeData(node); - } else if (node.type === "procedure" || node.type === "function" || node.type === "trigger" || node.type === "sequence" || node.type === "package" || node.type === "package-body" || node.type === "type" || node.type === "type-body") { - openObjectSourceDialog(false); } else if (action === "toggle") { toggle(); } diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts index 9f80d6e16..403cf53b2 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts @@ -1,7 +1,7 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; -import type { ConnectionConfig, ObjectInfo, TreeNode } from "@/types/database"; +import type { ConnectionConfig, ObjectInfo, TreeNode, TriggerInfo } from "@/types/database"; function installLocalStorage() { const data = new Map(); @@ -92,7 +92,13 @@ describe("sidebar object-group routing", () => { it("keeps table-level trigger groups on listTriggers", async () => { const listObjects = vi.fn<() => Promise>().mockResolvedValue([]); - const listTriggers = vi.fn<() => Promise>().mockResolvedValue([]); + const listTriggers = vi.fn<() => Promise>().mockResolvedValue([ + { + name: "trg_orders_audit", + timing: "AFTER", + event: "UPDATE", + }, + ]); const { connection, store } = await createStore({ listObjects, listTriggers }); const tableTriggerGroup: TreeNode = { ...objectGroup("group-triggers", `${connection.id}:app:app:orders:__triggers`), @@ -105,7 +111,18 @@ describe("sidebar object-group routing", () => { expect(listTriggers).toHaveBeenCalledWith(connection.id, "app", "app", "orders", undefined); expect(listObjects).not.toHaveBeenCalled(); - expect(storedTriggerGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] }); + expect(storedTriggerGroup).toMatchObject({ + isExpanded: true, + isLoading: false, + children: [ + { + label: "trg_orders_audit (AFTER UPDATE)", + objectName: "trg_orders_audit", + tableName: "orders", + type: "trigger", + }, + ], + }); }); it("propagates rejected schema-level metadata while clearing the loading state", async () => { diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index a69ec5744..fe9861548 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -656,8 +656,8 @@ export async function completionAssistantSearch(request: CompletionAssistantRequ return post("/api/schema/completion-assistant", request); } -export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string): Promise { - return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature })}`); +export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string, relationName?: string): Promise { + return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature, relation_name: relationName })}`); } export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index b096ec934..d87f904b1 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -832,8 +832,8 @@ export async function completionAssistantSearch(request: CompletionAssistantRequ return invoke("completion_assistant_search", { request }); } -export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string): Promise { - return invoke("get_object_source", { connectionId, database, schema, name, objectType, signature }); +export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string, relationName?: string): Promise { + return invoke("get_object_source", { connectionId, database, schema, name, objectType, signature, relationName }); } export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise { diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index c86b6f1fb..107dbc302 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -1,7 +1,7 @@ import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database"; import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; -export type TreeNodeRowAction = "open-data" | "toggle" | "none"; +export type TreeNodeRowAction = "open-data" | "open-source" | "toggle" | "none"; export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-saved-sql" | "toggle" | "none"; export type SidebarSelectionCopyAction = "copy-name" | "none"; export type SidebarActivation = "single" | "double"; @@ -36,6 +36,7 @@ export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean { export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single"): TreeNodeRowAction { if (activation === "double") return "none"; if (dataNodeTypes.has(type)) return "open-data"; + if (sourceNodeTypes.has(type)) return "open-source"; if (toggleLeafNodeTypes.has(type)) return "toggle"; if (canExpand) return "toggle"; return "none"; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index bef088bd4..df29947fa 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -4013,6 +4013,7 @@ export const useConnectionStore = defineStore("connection", () => { triggers.map((tr) => ({ id: `${parentId}:${tr.name}`, label: `${tr.name} (${tr.timing} ${tr.event})`, + objectName: tr.name, type: "trigger" as const, connectionId, database, diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 51ba09024..d49c494e7 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -1851,6 +1851,7 @@ pub async fn export_database_sql_core( view_name, crate::db::ObjectSourceKind::View, None, + None, ) .await { @@ -1901,6 +1902,7 @@ pub async fn export_database_sql_core( proc_name, crate::db::ObjectSourceKind::Procedure, procedure.signature.as_deref(), + None, ) .await { @@ -1955,6 +1957,7 @@ pub async fn export_database_sql_core( func_name, crate::db::ObjectSourceKind::Function, function.signature.as_deref(), + None, ) .await { diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 8f446c76d..8f2e22c65 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -5373,9 +5373,17 @@ pub async fn get_table_ddl_core( return Err("DDL is not supported for SQL Server linked server tables".to_string()); } if matches!(object_type, Some(db::ObjectSourceKind::View)) { - let source = - get_object_source_core(state, connection_id, database, schema, table, db::ObjectSourceKind::View, None) - .await?; + let source = get_object_source_core( + state, + connection_id, + database, + schema, + table, + db::ObjectSourceKind::View, + None, + None, + ) + .await?; let database_type = connection_config(state, connection_id).await.map(|config| config.db_type); return Ok(crate::object_source_sql::build_view_ddl_sql(crate::object_source_sql::BuildViewDdlInput { database_type, @@ -5393,6 +5401,7 @@ pub async fn get_table_ddl_core( table, db::ObjectSourceKind::MaterializedView, None, + None, ) .await?; return Ok(source.source); @@ -5675,6 +5684,24 @@ pub fn postgres_object_source_sql( postgres_object_source_sql_inner(schema, name, kind, signature, true, false) } +fn postgres_trigger_object_source_sql(schema: &str, name: &str, relation_name: Option<&str>) -> String { + let relation_filter = relation_name + .filter(|value| !value.trim().is_empty()) + .map(|value| format!(" AND c.relname = {}", sql_string(value))) + .unwrap_or_default(); + format!( + "SELECT pg_catalog.pg_get_triggerdef(t.oid, true) \ + FROM pg_catalog.pg_trigger t \ + JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = {} AND t.tgname = {} AND NOT t.tgisinternal{} \ + ORDER BY t.oid LIMIT 1", + sql_string(schema), + sql_string(name), + relation_filter + ) +} + fn opengauss_object_source_sql( schema: &str, name: &str, @@ -5929,9 +5956,19 @@ pub async fn get_object_source_core( name: &str, object_type: db::ObjectSourceKind, signature: Option<&str>, + relation_name: Option<&str>, ) -> Result { retry_metadata_connection(state, connection_id, Some(database), || { - get_object_source_once(state, connection_id, database, schema, name, object_type.clone(), signature) + get_object_source_once( + state, + connection_id, + database, + schema, + name, + object_type.clone(), + signature, + relation_name, + ) }) .await } @@ -5944,6 +5981,7 @@ async fn get_object_source_once( name: &str, object_type: db::ObjectSourceKind, signature: Option<&str>, + relation_name: Option<&str>, ) -> Result { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; let db_config = connection_config(state, connection_id).await; @@ -6015,7 +6053,16 @@ async fn get_object_source_once( } PoolKind::Postgres(pool) => { let unwrap_opengauss_record = db_config.as_ref().is_some_and(is_opengauss_family_config); - postgres_object_source(pool, schema, name, &object_type, signature, unwrap_opengauss_record).await? + postgres_object_source( + pool, + schema, + name, + &object_type, + signature, + relation_name, + unwrap_opengauss_record, + ) + .await? } PoolKind::Sqlite(pool) => first_string_cell( db::sqlite::execute_query(pool, &sqlite_object_source_sql(schema, name, &object_type)).await?, @@ -6062,12 +6109,32 @@ async fn get_object_source_once( } }; + let editable = if matches!(object_type, db::ObjectSourceKind::Trigger) + && db_config.as_ref().is_some_and(|config| { + matches!( + config.db_type, + DatabaseType::Postgres + | DatabaseType::Redshift + | DatabaseType::Gaussdb + | DatabaseType::Kwdb + | DatabaseType::OpenGauss + | DatabaseType::Questdb + | DatabaseType::Kingbase + | DatabaseType::Highgo + | DatabaseType::Vastbase + ) + }) { + Some(false) + } else { + None + }; + Ok(db::ObjectSource { name: name.to_string(), object_type, schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, source, - editable: None, + editable, }) } @@ -6370,9 +6437,12 @@ async fn postgres_object_source( name: &str, object_type: &db::ObjectSourceKind, signature: Option<&str>, + relation_name: Option<&str>, unwrap_opengauss_record: bool, ) -> Result { - let sql = if unwrap_opengauss_record { + let sql = if matches!(object_type, db::ObjectSourceKind::Trigger) { + postgres_trigger_object_source_sql(schema, name, relation_name) + } else if unwrap_opengauss_record { opengauss_object_source_sql(schema, name, object_type, signature) } else { postgres_object_source_sql(schema, name, object_type, signature) @@ -6475,6 +6545,17 @@ mod object_source_tests { ); } + #[test] + fn builds_postgres_object_source_sql_for_table_trigger() { + let sql = postgres_trigger_object_source_sql("audit", "trg_orders_update", Some("orders")); + + assert!(sql.contains("pg_get_triggerdef(t.oid, true)")); + assert!(sql.contains("n.nspname = 'audit'")); + assert!(sql.contains("c.relname = 'orders'")); + assert!(sql.contains("t.tgname = 'trg_orders_update'")); + assert!(sql.contains("NOT t.tgisinternal")); + } + #[test] fn builds_postgres_object_source_sql_without_relispopulated_for_legacy_catalogs() { let sql = postgres_object_source_sql_without_relispopulated( diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index cb3dc0cd9..76ec17d98 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -20,6 +20,7 @@ pub struct SchemaQuery { pub offset: Option, pub object_type: Option, pub signature: Option, + pub relation_name: Option, pub object_types: Option, pub apply_visible_filter: Option, pub client_session_id: Option, @@ -302,6 +303,7 @@ pub async fn get_object_source( name, object_type, q.signature.as_deref(), + q.relation_name.as_deref(), ) .await .map_err(AppError::from)?; diff --git a/packages/app-tests/treeNodeClick.test.ts b/packages/app-tests/treeNodeClick.test.ts index a8adb9fd6..28564be6b 100644 --- a/packages/app-tests/treeNodeClick.test.ts +++ b/packages/app-tests/treeNodeClick.test.ts @@ -7,6 +7,12 @@ test("table and view rows open data without toggling structure groups", () => { assert.equal(treeNodeRowAction("view", true), "open-data"); }); +test("single click navigation mode opens source-capable rows", () => { + assert.equal(treeNodeRowAction("procedure", false), "open-source"); + assert.equal(treeNodeRowAction("trigger", false), "open-source"); + assert.equal(treeNodeRowAction("sequence", false), "open-source"); +}); + test("double click navigation mode selects rows on single click", () => { assert.equal(treeNodeRowAction("table", true, "double"), "none"); assert.equal(treeNodeRowAction("view", true, "double"), "none"); @@ -65,6 +71,8 @@ test("double-click follow-up clicks do not run row actions", () => { assert.equal(shouldRunTreeNodeRowAction("toggle", 3), false); assert.equal(shouldRunTreeNodeRowAction("open-data", 1), true); assert.equal(shouldRunTreeNodeRowAction("open-data", 2), false); + assert.equal(shouldRunTreeNodeRowAction("open-source", 1), true); + assert.equal(shouldRunTreeNodeRowAction("open-source", 2), false); assert.equal(shouldRunTreeNodeRowAction("none", 1), false); }); @@ -77,6 +85,7 @@ 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("trigger"), "TRIGGER"); assert.equal(objectSourceKindForTreeNode("sequence"), "SEQUENCE"); assert.equal(objectSourceKindForTreeNode("package"), "PACKAGE"); assert.equal(objectSourceKindForTreeNode("package-body"), "PACKAGE_BODY"); diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index e3700d9ad..58b27ab48 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -280,6 +280,7 @@ pub async fn get_object_source( name: String, object_type: db::ObjectSourceKind, signature: Option, + relation_name: Option, ) -> Result { dbx_core::schema::get_object_source_core( &state, @@ -289,6 +290,7 @@ pub async fn get_object_source( &name, object_type, signature.as_deref(), + relation_name.as_deref(), ) .await }