fix(firebird): fix table trigger loading and source viewing

This commit is contained in:
lxk955
2026-10-11 13:34:23 +08:00
committed by GitHub
parent 63adea29c1
commit 2826bb5fc5
6 changed files with 210 additions and 12 deletions
@@ -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<TriggerInfo> 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<TriggerInfo> 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);
};
}
@@ -11,31 +11,36 @@ import java.util.function.UnaryOperator;
final class FirebirdObjectSource {
static ObjectSource read(Connection connection, String schema, String name, String type,
UnaryOperator<String> 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<String> 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<String> decode) throws SQLException {
@@ -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<String> sql = new ArrayList<>();
List<String> parameters = new ArrayList<>();
FirebirdAgent agent = agentWithRows(
sql,
parameters,
row(
"1", "TR_ORDERS_BI",
"2", 1
),
row(
"1", "TR_ORDERS_AD",
"2", 6
)
);
List<TriggerInfo> 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<String> sql,
@@ -41,6 +41,34 @@ class FirebirdObjectSourceTest {
assertTrue(source.getSource().contains("草稿"));
}
@Test
void tableTriggerSourceBuildsStandardCreateOrAlterTriggerDdl() throws Exception {
List<String> 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<String> 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<String> 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());
@@ -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 });
}
@@ -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<typeof import("@/lib/backend/api")>();
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<Element>[] = [];
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<InstanceType<typeof SidebarTreeRuntimeHost> | 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"));
});
});