From 2826bb5fc516a3183637aa24bbdac8e511f7a1b1 Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:34:23 +0800 Subject: [PATCH] fix(firebird): fix table trigger loading and source viewing --- .../com/dbx/agent/firebird/FirebirdAgent.java | 19 ++++- .../agent/firebird/FirebirdObjectSource.java | 64 ++++++++++++++--- .../dbx/agent/firebird/FirebirdAgentTest.java | 37 ++++++++++ .../firebird/FirebirdObjectSourceTest.java | 29 ++++++++ .../sidebar/SidebarTreeRuntimeHost.vue | 4 ++ ...untimeHost.tableTriggerContextMenu.spec.ts | 69 +++++++++++++++++++ 6 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.tableTriggerContextMenu.spec.ts diff --git a/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdAgent.java b/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdAgent.java index 25cd64cb8d..dd095f292a 100644 --- a/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdAgent.java +++ b/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdAgent.java @@ -16,6 +16,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Types; import java.util.ArrayList; +import java.util.Collections; import java.util.Iterator; import java.util.Comparator; import java.util.List; @@ -53,11 +54,20 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent { @Override public List listTriggers(String schema, String table) { + if (table == null || table.isBlank()) { + return Collections.emptyList(); + } + String normalizedTable = table.trim(); + if (normalizedTable.startsWith("\"") && normalizedTable.endsWith("\"") && normalizedTable.length() > 1) { + normalizedTable = normalizedTable.substring(1, normalizedTable.length() - 1); + } + final String searchTable = normalizedTable; return unchecked(() -> { List triggers = new ArrayList<>(); try (PreparedStatement statement = requireConnection().prepareStatement( - "SELECT TRIM(RDB$TRIGGER_NAME), RDB$TRIGGER_TYPE FROM RDB$TRIGGERS WHERE RDB$RELATION_NAME = ? AND COALESCE(RDB$SYSTEM_FLAG,0)=0 ORDER BY RDB$TRIGGER_NAME")) { - statement.setString(1, table); + "SELECT TRIM(RDB$TRIGGER_NAME), RDB$TRIGGER_TYPE FROM RDB$TRIGGERS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND COALESCE(RDB$SYSTEM_FLAG,0)=0 ORDER BY RDB$TRIGGER_NAME")) { + statement.setString(1, searchTable); + statement.setString(2, searchTable); try (ResultSet result = statement.executeQuery()) { while (result.next()) { int type = result.getInt(2); @@ -78,6 +88,11 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent { case 25, 26 -> "INSERT OR DELETE"; case 27, 28 -> "UPDATE OR DELETE"; case 113, 114 -> "INSERT OR UPDATE OR DELETE"; + case 8192 -> "CONNECT"; + case 8193 -> "DISCONNECT"; + case 8194 -> "TRANSACTION START"; + case 8195 -> "TRANSACTION COMMIT"; + case 8196 -> "TRANSACTION ROLLBACK"; default -> Integer.toString(type); }; } diff --git a/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdObjectSource.java b/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdObjectSource.java index 94e4bffeb3..9a3a6a3563 100644 --- a/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdObjectSource.java +++ b/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdObjectSource.java @@ -11,31 +11,36 @@ import java.util.function.UnaryOperator; final class FirebirdObjectSource { static ObjectSource read(Connection connection, String schema, String name, String type, UnaryOperator decode) throws SQLException { + String targetName = name != null ? name.trim() : ""; + if (targetName.startsWith("\"") && targetName.endsWith("\"") && targetName.length() > 1) { + targetName = targetName.substring(1, targetName.length() - 1); + } String sql = switch (type) { case "FUNCTION" -> "SELECT RDB$FUNCTION_SOURCE, RDB$MODULE_NAME, RDB$ENTRYPOINT, RDB$ENGINE_NAME FROM RDB$FUNCTIONS WHERE CASE WHEN RDB$PACKAGE_NAME IS NULL THEN TRIM(RDB$FUNCTION_NAME) ELSE TRIM(RDB$PACKAGE_NAME) || '.' || TRIM(RDB$FUNCTION_NAME) END = ?"; - case "TRIGGER" -> "SELECT RDB$TRIGGER_SOURCE, RDB$RELATION_NAME, RDB$TRIGGER_TYPE, RDB$TRIGGER_SEQUENCE, RDB$TRIGGER_INACTIVE FROM RDB$TRIGGERS WHERE RDB$TRIGGER_NAME = ?"; - case "PACKAGE", "PACKAGE_BODY" -> "SELECT RDB$PACKAGE_HEADER_SOURCE, RDB$PACKAGE_BODY_SOURCE FROM RDB$PACKAGES WHERE RDB$PACKAGE_NAME = ?"; + case "TRIGGER" -> "SELECT RDB$TRIGGER_SOURCE, TRIM(RDB$RELATION_NAME), RDB$TRIGGER_TYPE, RDB$TRIGGER_SEQUENCE, RDB$TRIGGER_INACTIVE FROM RDB$TRIGGERS WHERE (TRIM(RDB$TRIGGER_NAME) = ? OR TRIM(RDB$TRIGGER_NAME) = UPPER(?))"; + case "PACKAGE", "PACKAGE_BODY" -> "SELECT RDB$PACKAGE_HEADER_SOURCE, RDB$PACKAGE_BODY_SOURCE FROM RDB$PACKAGES WHERE (TRIM(RDB$PACKAGE_NAME) = ? OR TRIM(RDB$PACKAGE_NAME) = UPPER(?))"; case "SEQUENCE" -> "SELECT RDB$GENERATOR_NAME, RDB$INITIAL_VALUE, RDB$GENERATOR_INCREMENT FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?"; - case "VIEW" -> "SELECT RDB$VIEW_SOURCE FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ? AND RDB$VIEW_BLR IS NOT NULL"; + case "VIEW" -> "SELECT RDB$VIEW_SOURCE FROM RDB$RELATIONS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND RDB$VIEW_BLR IS NOT NULL"; default -> throw new IllegalArgumentException("Unsupported Firebird object type: " + type); }; boolean legacy = connection.getMetaData().getDatabaseMajorVersion() < 3; if (legacy && "FUNCTION".equals(type)) { - sql = "SELECT CAST(NULL AS VARCHAR(1)), RDB$MODULE_NAME, RDB$ENTRYPOINT, CAST(NULL AS VARCHAR(1)) FROM RDB$FUNCTIONS WHERE RDB$FUNCTION_NAME = ?"; + sql = "SELECT CAST(NULL AS VARCHAR(1)), RDB$MODULE_NAME, RDB$ENTRYPOINT, CAST(NULL AS VARCHAR(1)) FROM RDB$FUNCTIONS WHERE (TRIM(RDB$FUNCTION_NAME) = ? OR TRIM(RDB$FUNCTION_NAME) = UPPER(?))"; } else if (legacy && "SEQUENCE".equals(type)) { sql = "SELECT RDB$GENERATOR_NAME, 0, 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?"; } String source = ""; try (PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, name); + statement.setString(1, targetName); + if (sql.contains("UPPER(?)")) { + statement.setString(2, targetName); + } try (ResultSet result = statement.executeQuery()) { if (result.next()) { - String quoted = "\"" + name.replace("\"", "\"\"") + "\""; + String quoted = "\"" + targetName.replace("\"", "\"\"") + "\""; source = switch (type) { case "FUNCTION" -> functionSource(result, decode); - case "TRIGGER" -> "-- Trigger: " + name + "\n-- Relation: " + result.getString(2) - + "\n-- Type: " + result.getInt(3) + ", position: " + result.getInt(4) - + ", inactive: " + result.getInt(5) + "\n" + text(decode.apply(result.getString(1))); + case "TRIGGER" -> triggerSource(result, targetName, decode); case "PACKAGE", "PACKAGE_BODY" -> packageSource(result, quoted, decode); case "SEQUENCE" -> legacy ? "-- Generator: " + quoted : "CREATE SEQUENCE " + quoted + " START WITH " + result.getLong(2) @@ -46,7 +51,46 @@ final class FirebirdObjectSource { } } } - return new ObjectSource(name, type, schema, source, false); + return new ObjectSource(targetName, type, schema, source, false); + } + + private static String triggerSource(ResultSet result, String name, UnaryOperator decode) throws SQLException { + String relation = result.getString(2); + int type = result.getInt(3); + int position = result.getInt(4); + int inactive = result.getInt(5); + String body = text(decode.apply(result.getString(1))); + String rel = relation != null ? relation.trim() : ""; + String trimmedBody = body.stripLeading(); + if ((trimmedBody.regionMatches(true, 0, "CREATE", 0, 6) && (trimmedBody.length() == 6 || Character.isWhitespace(trimmedBody.charAt(6)))) + || (trimmedBody.regionMatches(true, 0, "ALTER", 0, 5) && (trimmedBody.length() == 5 || Character.isWhitespace(trimmedBody.charAt(5))))) { + return body; + } + String quoted = "\"" + name.replace("\"", "\"\"") + "\""; + StringBuilder ddl = new StringBuilder("CREATE OR ALTER TRIGGER ").append(quoted); + if (!rel.isEmpty()) { + ddl.append(" FOR \"").append(rel.replace("\"", "\"\"")).append("\""); + } + ddl.append(inactive == 1 ? " INACTIVE\n" : " ACTIVE\n"); + if (type >= 8192) { + ddl.append("ON ").append(FirebirdAgent.triggerEvent(type)); + } else { + String timing = (type % 2 == 1) ? "BEFORE" : "AFTER"; + String event = FirebirdAgent.triggerEvent(type); + ddl.append(timing).append(" ").append(event); + } + if (position > 0) { + ddl.append(" POSITION ").append(position); + } + ddl.append("\n"); + if (trimmedBody.regionMatches(true, 0, "AS", 0, 2) && (trimmedBody.length() == 2 || Character.isWhitespace(trimmedBody.charAt(2)))) { + ddl.append(trimmedBody); + } else if (!trimmedBody.isEmpty()) { + ddl.append("AS\n").append(trimmedBody); + } else { + ddl.append("AS\nBEGIN\nEND"); + } + return ddl.toString().stripTrailing(); } private static String functionSource(ResultSet result, UnaryOperator decode) throws SQLException { diff --git a/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdAgentTest.java b/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdAgentTest.java index 0a23172af5..e63b315f8f 100644 --- a/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdAgentTest.java +++ b/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdAgentTest.java @@ -1,6 +1,7 @@ package com.dbx.agent.firebird; import com.dbx.agent.ObjectSource; +import com.dbx.agent.TriggerInfo; import com.dbx.agent.test.TestSupport; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; @@ -339,6 +340,42 @@ class FirebirdAgentTest { Assertions.assertEquals("Unsupported object type: UNKNOWN", error.getMessage()); } + @Test + void listTriggersNormalizesTableNameAndMapsTriggerTypes() { + List sql = new ArrayList<>(); + List parameters = new ArrayList<>(); + FirebirdAgent agent = agentWithRows( + sql, + parameters, + row( + "1", "TR_ORDERS_BI", + "2", 1 + ), + row( + "1", "TR_ORDERS_AD", + "2", 6 + ) + ); + + List triggers = agent.listTriggers(null, "\"orders\""); + Assertions.assertEquals(2, triggers.size()); + Assertions.assertEquals("TR_ORDERS_BI", triggers.get(0).getName()); + Assertions.assertEquals("INSERT", triggers.get(0).getEvent()); + Assertions.assertEquals("BEFORE", triggers.get(0).getTiming()); + Assertions.assertEquals("TR_ORDERS_AD", triggers.get(1).getName()); + Assertions.assertEquals("DELETE", triggers.get(1).getEvent()); + Assertions.assertEquals("AFTER", triggers.get(1).getTiming()); + Assertions.assertEquals(List.of("1=orders", "2=orders"), parameters); + Assertions.assertTrue(sql.get(0).contains("TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)")); + } + + @Test + void listTriggersReturnsEmptyForNullOrBlankTable() { + FirebirdAgent agent = new FirebirdAgent(); + Assertions.assertTrue(agent.listTriggers(null, null).isEmpty()); + Assertions.assertTrue(agent.listTriggers(null, " ").isEmpty()); + } + @SafeVarargs private static FirebirdAgent agentWithRows( List sql, diff --git a/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdObjectSourceTest.java b/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdObjectSourceTest.java index 47466d7d1a..a9374016d5 100644 --- a/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdObjectSourceTest.java +++ b/agents/drivers/firebird/src/test/java/com/dbx/agent/firebird/FirebirdObjectSourceTest.java @@ -41,6 +41,34 @@ class FirebirdObjectSourceTest { assertTrue(source.getSource().contains("草稿")); } + @Test + void tableTriggerSourceBuildsStandardCreateOrAlterTriggerDdl() throws Exception { + List calls = new ArrayList<>(); + ObjectSource source = FirebirdObjectSource.read( + connection(calls, "AS\nBEGIN\n NEW.ID = 1;\nEND", "ORDERS", 1, 0, 0), + "", "TR_ORDERS_BI", "TRIGGER", value -> value + ); + assertEquals( + "CREATE OR ALTER TRIGGER \"TR_ORDERS_BI\" FOR \"ORDERS\" ACTIVE\nBEFORE INSERT\nAS\nBEGIN\n NEW.ID = 1;\nEND", + source.getSource() + ); + assertEquals("TR_ORDERS_BI", source.getName()); + assertEquals("TRIGGER", source.getObject_type()); + } + + @Test + void databaseTriggerSourceFormatsEventAndTiming() throws Exception { + List calls = new ArrayList<>(); + ObjectSource source = FirebirdObjectSource.read( + connection(calls, "BEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND", null, 8192, 1, 1), + "", "TR_CONN", "TRIGGER", value -> value + ); + assertEquals( + "CREATE OR ALTER TRIGGER \"TR_CONN\" INACTIVE\nON CONNECT POSITION 1\nAS\nBEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND", + source.getSource() + ); + } + private static Connection connection(List calls, Object... values) { ResultSet result = (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class[] {ResultSet.class}, new java.lang.reflect.InvocationHandler() { @@ -49,6 +77,7 @@ class FirebirdObjectSourceTest { return switch (method.getName()) { case "next" -> { boolean value = first; first = false; yield value; } case "getString" -> { Object value = values[(Integer) args[0] - 1]; yield value == null ? null : value.toString(); } + case "getInt" -> ((Number) values[(Integer) args[0] - 1]).intValue(); case "getLong" -> ((Number) values[(Integer) args[0] - 1]).longValue(); case "close" -> null; default -> throw new UnsupportedOperationException(method.getName()); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 0c0964538f..e05d5c94a5 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -6825,6 +6825,10 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (node.type === "index" || node.type === "fkey" || (node.type === "trigger" && !!node.tableName)) { items.push(node.type === "trigger" ? copyNameMenuItem() : { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + if (node.type === "trigger") { + items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); + items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 }); + } 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 }); } diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.tableTriggerContextMenu.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.tableTriggerContextMenu.spec.ts new file mode 100644 index 0000000000..9390e573f8 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.tableTriggerContextMenu.spec.ts @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; +import type { DatabaseType, TreeNode } from "@/types/database"; + +vi.mock("@/lib/backend/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listPlugins: vi.fn().mockResolvedValue([]) }; +}); + +import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue"; +import { useConnectionStore } from "@/stores/connectionStore"; + +const node = (type: TreeNode["type"], label: string, tableName?: string): TreeNode => ({ + id: `test-conn:db:${type}:${label}`, + type, + label, + objectName: label, + tableName, + connectionId: "test-conn", + database: "testdb", +}); +const mountedApps: App[] = []; +const labels = (items: ContextMenuItem[]): string[] => items.flatMap((item) => [...(item.label ? [item.label] : []), ...(item.children ? labels(item.children) : [])]); +const tr = (key: string) => i18n.global.t(key); + +async function mountHost(dbType: DatabaseType = "firebird") { + const pinia = createPinia(); + setActivePinia(pinia); + useConnectionStore().connections = [{ id: "test-conn", name: "TestDB", db_type: dbType, driver_profile: dbType, host: "localhost", port: 3050, username: "sysdba", password: "masterkey" }]; + const host = ref | null>(null); + const root = defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: node("connection", "TestDB"), depth: 0 }) }); + const container = document.createElement("div"); + document.body.appendChild(container); + const app = createApp(root); + app.use(pinia); + app.use(i18n); + app.mount(container); + mountedApps.push(app); + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + return host.value as { buildContextMenu(node: TreeNode): ContextMenuItem[] }; +} + +describe("Table trigger context menu", () => { + afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + }); + + it("exposes view source and change open mode for table triggers", async () => { + const host = await mountHost("firebird"); + const items = labels(host.buildContextMenu(node("trigger", "TR_ORDERS_BI", "ORDERS"))); + expect(items).toContain(tr("contextMenu.viewSource")); + expect(items).toContain(tr("contextMenu.changeOpenMode")); + expect(items).toContain(tr("contextMenu.copyName")); + }); + + it("exposes view source for database-level triggers", async () => { + const host = await mountHost("firebird"); + const items = labels(host.buildContextMenu(node("trigger", "TR_CONN"))); + expect(items).toContain(tr("contextMenu.viewSource")); + expect(items).toContain(tr("contextMenu.changeOpenMode")); + }); +});