mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(firebird): fix table trigger loading and source viewing
This commit is contained in:
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
+54
-10
@@ -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,
|
||||
|
||||
+29
@@ -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 });
|
||||
}
|
||||
|
||||
apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.tableTriggerContextMenu.spec.ts
Vendored
+69
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user