fix(sidebar): scope trigger refresh and support firebird trigger actions

This commit is contained in:
lxk955
2026-10-19 11:58:32 +08:00
committed by GitHub
parent cb4406c715
commit 0ff3461815
12 changed files with 307 additions and 13 deletions
@@ -118,7 +118,7 @@ import { isXuguSyntheticTreeNode } from "@/lib/sidebar/xuguPublicSynonyms";
import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/database/xuguSchedulerJobSql";
import { canViewDatabaseObjectDependencies, databaseDependencyProviderFor } from "@/lib/database/databaseObjectDependencies";
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
import { sidebarObjectTemplateForGroup } from "@/lib/sidebar/databaseObjectTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate, insertTemplatePrimaryKeyConflicts } from "@/lib/table/tableSqlTemplates";
import SqlTemplateInsertRowsDialog from "@/components/sidebar/SqlTemplateInsertRowsDialog.vue";
import { joinExportedDdls } from "@/lib/export/ddlExport";
@@ -964,7 +964,7 @@ async function toggle(requestId = beginNavigationRequest()) {
// Keep the click path aligned with every object-group definition. In
// particular, schema-level trigger/type groups have no tableName, so they
// must use the generic object loader rather than the table-trigger loader.
const databaseObjectGroup = !!objectTypesForGroupNode(node.type);
const databaseObjectGroup = !!objectTypesForGroupNode(node.type) && !node.tableName;
const needsConfiguredTablePageDrain = node.type === "group-tables" && connectionStore.getConfig(node.connectionId || "")?.sidebar_auto_load_all_tables === true && hasTableTreeLoadMore(node.children ?? []);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node) && !needsConfiguredTablePageDrain) {
node.isExpanded = !node.isExpanded;
@@ -5688,10 +5688,10 @@ async function confirmDeleteHBaseTable() {
}
}
function createMysqlObjectTemplate() {
function createSidebarObjectTemplate() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
const template = mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node);
const template = sidebarObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node);
if (!template) return;
connectionStore.activeConnectionId = node.connectionId;
const tabId = queryStore.createTab(node.connectionId, node.database, t(template.titleKey), "query", node.schema, undefined, node.catalog);
@@ -7752,11 +7752,11 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
appendTableVGroupContainerItems(node, items);
// 9. Group Labels (group-columns, group-tables, etc.)
if (isGroupLabel(node)) {
const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null;
const objectTemplate = node.connectionId ? sidebarObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null;
const hasMongoCreateIndexAction = node.type === "group-indexes" && canCreateMongoIndex.value;
const hasMongoDropAllIndexesAction = node.type === "group-indexes" && canDropAllMongoIndexes.value;
const canCreateGroupView = node.type === "group-views" && !!node.connectionId && !!node.database && !["neo4j", "nebula"].includes(currentDatabaseType() || "");
const hasGroupAction = (node.type === "group-tables" && canCreateTable.value) || canCreateGroupView || !!mysqlObjectTemplate || hasMongoCreateIndexAction || hasMongoDropAllIndexesAction;
const hasGroupAction = (node.type === "group-tables" && canCreateTable.value) || canCreateGroupView || !!objectTemplate || hasMongoCreateIndexAction || hasMongoDropAllIndexesAction;
const canLoadAllObjectGroup = node.type === "group-tables" || node.type === "group-dolt-system-tables" || node.type === "group-views" || node.type === "group-materialized-views";
if (node.type === "group-tables" && canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
@@ -7773,8 +7773,8 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
if (node.type === "group-events" && node.connectionId && node.database) {
items.push({ label: t("contextMenu.createEvent"), action: openMysqlEventCreateEditor, icon: Plus });
}
if (mysqlObjectTemplate) {
items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus });
if (objectTemplate) {
items.push({ label: t(objectTemplate.titleKey), action: createSidebarObjectTemplate, icon: Plus });
}
if (hasMongoCreateIndexAction) {
items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus });
@@ -85,6 +85,27 @@ describe("jdbc dialect inference", () => {
).toBe("iris");
});
it("detects Firebird JDBC connections", () => {
expect(
inferJdbcDialect({
db_type: "jdbc",
connection_string: "jdbc:firebirdsql://localhost:3050/data/test.fdb",
}),
).toBe("firebird");
expect(
inferJdbcDialect({
db_type: "jdbc",
jdbc_driver_class: "org.firebirdsql.jdbc.FBDriver",
}),
).toBe("firebird");
expect(
inferJdbcDialect({
db_type: "jdbc",
driver_profile: "firebird",
}),
).toBe("firebird");
});
it("uses JDBC driver profiles when inferring dialect", () => {
expect(
inferJdbcDialect({
@@ -17,6 +17,7 @@ describe("triggerToggleSql", () => {
expect(supportsTriggerToggle("gaussdb")).toBe(true);
expect(supportsTriggerToggle("kwdb")).toBe(true);
expect(supportsTriggerToggle("xugu")).toBe(true);
expect(supportsTriggerToggle("firebird")).toBe(true);
});
it("returns false for unsupported database types or undefined", () => {
@@ -147,6 +148,25 @@ describe("triggerToggleSql", () => {
}
});
it("generates Firebird trigger enable/disable SQL", () => {
expect(
buildTriggerToggleSql({
databaseType: "firebird",
tableName: "orders",
triggerName: "trg_orders_audit",
enabled: true,
}),
).toBe('ALTER TRIGGER "trg_orders_audit" ACTIVE;');
expect(
buildTriggerToggleSql({
databaseType: "firebird",
triggerName: 'trg_"orders"_audit',
enabled: false,
}),
).toBe('ALTER TRIGGER "trg_""orders""_audit" INACTIVE;');
});
it("returns null for PostgreSQL when tableName is missing", () => {
expect(
buildTriggerToggleSql({
@@ -129,6 +129,36 @@ describe("sidebar object-group routing", () => {
});
});
it("refreshes table trigger groups using listTriggers instead of loading database objects", async () => {
const listObjects = vi.fn<() => Promise<ObjectInfo[]>>().mockResolvedValue([]);
const listTriggers = vi.fn<() => Promise<TriggerInfo[]>>().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`),
tableName: "orders",
};
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tableTriggerGroup] }];
const storedTriggerGroup = store.treeNodes[0].children![0];
await store.refreshTreeNode(storedTriggerGroup);
expect(listTriggers).toHaveBeenCalledWith(connection.id, "app", "app", "orders", undefined);
expect(listObjects).not.toHaveBeenCalled();
expect(storedTriggerGroup.children).toHaveLength(1);
expect(storedTriggerGroup.children![0]).toMatchObject({
label: "trg_orders_audit (AFTER UPDATE)",
objectName: "trg_orders_audit",
tableName: "orders",
type: "trigger",
});
});
it("preserves SQL Server table trigger status metadata across refreshes", async () => {
const listObjects = vi.fn<() => Promise<ObjectInfo[]>>().mockResolvedValue([]);
const listTriggers = vi.fn<() => Promise<TriggerInfo[]>>();
@@ -43,6 +43,7 @@ const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> =
{ type: "sqlite", patterns: [/jdbc:sqlite:/i, /sqlite/i] },
{ type: "db2", patterns: [/jdbc:db2:/i, /\bdb2\b/i] },
{ type: "informix", patterns: [/jdbc:informix/i, /informix/i] },
{ type: "firebird", patterns: [/jdbc:firebird(?:sql)?:/i, /firebird/i, /org\.firebirdsql\./i] },
// CacheDB.jar (legacy Caché driver) carries none of the intersystems URL or
// class-name markers, so match the jar file name / driver label directly.
{ type: "iris", patterns: [/jdbc:(?:iris|cache):/i, /com\.intersystems\.jdbc\.(?:IRIS|Cache)Driver/i, /intersystems-jdbc/i, /\bcache(?:db)?\b/i] },
@@ -8,7 +8,7 @@ export interface TriggerToggleSqlOptions {
enabled: boolean;
}
const SUPPORTED_TRIGGER_TOGGLE_DATABASES = new Set<DatabaseType>(["sqlserver", "oracle", "oceanbase-oracle", "dameng", "postgres", "kingbase", "opengauss", "vastbase", "highgo", "uxdb", "gaussdb", "kwdb", "xugu"]);
const SUPPORTED_TRIGGER_TOGGLE_DATABASES = new Set<DatabaseType>(["sqlserver", "oracle", "oceanbase-oracle", "dameng", "postgres", "kingbase", "opengauss", "vastbase", "highgo", "uxdb", "gaussdb", "kwdb", "xugu", "firebird"]);
export function supportsTriggerToggle(databaseType?: DatabaseType): boolean {
if (!databaseType) return false;
@@ -64,6 +64,11 @@ export function buildTriggerToggleSql(options: TriggerToggleSqlOptions): string
return `ALTER TABLE ${quotedTable} ${stateKeyword} TRIGGER ${quotedTrigger};`;
}
case "firebird": {
const quotedTrigger = quoteDouble(cleanTrigger);
return `ALTER TRIGGER ${quotedTrigger} ${enabled ? "ACTIVE" : "INACTIVE"};`;
}
default:
return null;
}
@@ -0,0 +1,10 @@
import type { TreeNode } from "@/types/database";
import type { JdbcDialectConnection } from "@/lib/database/jdbcDialect";
import { mysqlObjectTemplateForGroup, type MysqlObjectTemplate } from "@/lib/sidebar/mysqlObjectTemplates";
import { firebirdObjectTemplateForGroup, type FirebirdObjectTemplate } from "@/lib/sidebar/firebirdObjectTemplates";
export type SidebarObjectTemplate = MysqlObjectTemplate | FirebirdObjectTemplate;
export function sidebarObjectTemplateForGroup(connection: JdbcDialectConnection | undefined, node: Pick<TreeNode, "type" | "database" | "tableName">): SidebarObjectTemplate | null {
return mysqlObjectTemplateForGroup(connection, node) || firebirdObjectTemplateForGroup(connection, node);
}
@@ -0,0 +1,88 @@
import type { TreeNode, TreeNodeType } from "@/types/database";
import { effectiveDatabaseTypeForConnection, type JdbcDialectConnection } from "@/lib/database/jdbcDialect";
export type FirebirdObjectTemplateKind = "procedure" | "function" | "trigger";
export interface FirebirdObjectTemplate {
kind: FirebirdObjectTemplateKind;
titleKey: "contextMenu.createProcedure" | "contextMenu.createFunction" | "contextMenu.createTrigger";
sql: string;
}
type FirebirdTemplateConnection = JdbcDialectConnection;
type FirebirdTemplateNode = Pick<TreeNode, "type" | "database" | "tableName">;
const templateKindByGroup: Partial<Record<TreeNodeType, FirebirdObjectTemplateKind>> = {
"group-procedures": "procedure",
"group-functions": "function",
"group-triggers": "trigger",
};
export function supportsFirebirdObjectTemplates(connection?: FirebirdTemplateConnection): boolean {
return effectiveDatabaseTypeForConnection(connection) === "firebird";
}
export function firebirdObjectTemplateForGroup(connection: FirebirdTemplateConnection | undefined, node: FirebirdTemplateNode): FirebirdObjectTemplate | null {
if (!supportsFirebirdObjectTemplates(connection) || !node.database) return null;
const kind = templateKindByGroup[node.type];
if (!kind) return null;
return buildFirebirdObjectTemplate(kind, node.tableName);
}
export function buildFirebirdObjectTemplate(kind: FirebirdObjectTemplateKind, tableName?: string): FirebirdObjectTemplate {
if (kind === "procedure") {
const name = "new_procedure";
return {
kind,
titleKey: "contextMenu.createProcedure",
sql: `CREATE OR ALTER PROCEDURE ${quoteFirebirdIdentifier(name)}
AS
BEGIN
/* Procedure body */
END;`,
};
}
if (kind === "function") {
const name = "new_function";
return {
kind,
titleKey: "contextMenu.createFunction",
sql: `CREATE OR ALTER FUNCTION ${quoteFirebirdIdentifier(name)}()
RETURNS INTEGER
AS
BEGIN
RETURN 0;
END;`,
};
}
const name = "new_trigger";
if (tableName) {
return {
kind,
titleKey: "contextMenu.createTrigger",
sql: `CREATE OR ALTER TRIGGER ${quoteFirebirdIdentifier(name)} FOR ${quoteFirebirdIdentifier(tableName)}
ACTIVE BEFORE INSERT POSITION 0
AS
BEGIN
/* Trigger body */
END;`,
};
}
return {
kind,
titleKey: "contextMenu.createTrigger",
sql: `CREATE OR ALTER TRIGGER ${quoteFirebirdIdentifier(name)}
ACTIVE ON CONNECT POSITION 0
AS
BEGIN
/* Trigger body */
END;`,
};
}
export function quoteFirebirdIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
@@ -8,7 +8,7 @@ export interface MysqlObjectTemplate {
sql: string;
}
type MysqlTemplateConnection = Pick<ConnectionConfig, "db_type" | "driver_profile">;
type MysqlTemplateConnection = Partial<Pick<ConnectionConfig, "db_type" | "driver_profile">>;
type MysqlTemplateNode = Pick<TreeNode, "type" | "database" | "tableName">;
const templateKindByGroup: Partial<Record<TreeNodeType, MysqlObjectTemplateKind>> = {
+1
View File
@@ -897,6 +897,7 @@ export function buildObjectGroupPlaceholderNodes({
}
export function objectGroupRefreshParentId(node: TreeNode): string | null {
if (node.tableName) return null;
if (!objectGroupNodeTypes.has(node.type)) return null;
const suffixStart = node.id.lastIndexOf(":__");
if (suffixStart < 0) return null;
+3 -3
View File
@@ -8069,7 +8069,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
invalidateCompletionCachesForNode(node);
invalidateMetadataCachesForNode(node, options);
if (objectTypesForGroupNode(node.type)) {
if (objectTypesForGroupNode(node.type) && !node.tableName) {
clearLoadedChildrenCache(node.id, { deletePersisted: false });
await loadObjectGroupChildren(node, { force: true });
await invalidateSidebarTableSearchIndexesForTreeRefresh(node);
@@ -8145,7 +8145,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) {
invalidateMetadataCachesForNode(node);
if (objectTypesForGroupNode(node.type)) {
if (objectTypesForGroupNode(node.type) && !node.tableName) {
clearLoadedChildrenCache(node.id);
await loadObjectGroupChildren(node, {
force: true,
@@ -10124,7 +10124,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (node.children) await refreshNodes(node.children);
continue;
}
if (objectTypesForGroupNode(node.type)) {
if (objectTypesForGroupNode(node.type) && !node.tableName) {
if (node.connectionId && connectedIds.value.has(node.connectionId)) {
clearLoadedChildrenCache(node.id);
if (node.isExpanded) {
+118
View File
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { buildFirebirdObjectTemplate, firebirdObjectTemplateForGroup, quoteFirebirdIdentifier, supportsFirebirdObjectTemplates } from "../../apps/desktop/src/lib/sidebar/firebirdObjectTemplates.ts";
import { sidebarObjectTemplateForGroup } from "../../apps/desktop/src/lib/sidebar/databaseObjectTemplates.ts";
import { executableStatementRanges } from "../../apps/desktop/src/lib/sql/sqlStatementRanges.ts";
import type { ConnectionConfig, TreeNode } from "../../apps/desktop/src/types/database.ts";
function connection(dbType: ConnectionConfig["db_type"], driverProfile?: string): ConnectionConfig {
return {
id: "conn-1",
name: "Test",
db_type: dbType,
driver_profile: driverProfile,
host: "127.0.0.1",
port: 3050,
username: "sysdba",
password: "",
};
}
function group(type: TreeNode["type"], overrides: Partial<TreeNode> = {}): TreeNode {
return {
id: `conn-1:app:${type}`,
label: type,
type,
connectionId: "conn-1",
database: "app",
children: [],
...overrides,
};
}
test("Firebird object templates are supported for Firebird connections", () => {
assert.equal(supportsFirebirdObjectTemplates(connection("firebird")), true);
assert.equal(supportsFirebirdObjectTemplates(connection("jdbc", "firebird")), true);
assert.equal(supportsFirebirdObjectTemplates({ db_type: "jdbc", connection_string: "jdbc:firebirdsql://localhost:3050/test.fdb" }), true);
assert.equal(supportsFirebirdObjectTemplates({ db_type: "jdbc", jdbc_driver_class: "org.firebirdsql.jdbc.FBDriver" }), true);
assert.equal(supportsFirebirdObjectTemplates(connection("mysql")), false);
assert.equal(supportsFirebirdObjectTemplates(connection("postgres")), false);
});
test("only supported Firebird object groups expose creation templates", () => {
const fb = connection("firebird");
assert.equal(firebirdObjectTemplateForGroup(fb, group("group-procedures"))?.kind, "procedure");
assert.equal(firebirdObjectTemplateForGroup(fb, group("group-functions"))?.kind, "function");
assert.equal(firebirdObjectTemplateForGroup(fb, group("group-triggers"))?.kind, "trigger");
assert.equal(firebirdObjectTemplateForGroup(fb, group("group-views")), null);
assert.equal(firebirdObjectTemplateForGroup(fb, group("group-functions", { database: "" })), null);
});
test("procedure and function templates use Firebird CREATE OR ALTER syntax", () => {
assert.equal(
buildFirebirdObjectTemplate("procedure").sql,
`CREATE OR ALTER PROCEDURE "new_procedure"
AS
BEGIN
/* Procedure body */
END;`,
);
assert.equal(
buildFirebirdObjectTemplate("function").sql,
`CREATE OR ALTER FUNCTION "new_function"()
RETURNS INTEGER
AS
BEGIN
RETURN 0;
END;`,
);
});
test("DBX parses every Firebird template as one executable statement", () => {
for (const kind of ["procedure", "function"] as const) {
const ranges = executableStatementRanges(buildFirebirdObjectTemplate(kind).sql, "firebird");
assert.equal(ranges.length, 1, kind);
assert.match(ranges[0].sql, new RegExp(`^CREATE OR ALTER ${kind.toUpperCase()}`));
}
const tableTriggerRanges = executableStatementRanges(buildFirebirdObjectTemplate("trigger", "A_DETAIL").sql, "firebird");
assert.equal(tableTriggerRanges.length, 1, "table trigger");
assert.match(tableTriggerRanges[0].sql, /^CREATE OR ALTER TRIGGER/);
const dbTriggerRanges = executableStatementRanges(buildFirebirdObjectTemplate("trigger").sql, "firebird");
assert.equal(dbTriggerRanges.length, 1, "database trigger");
assert.match(dbTriggerRanges[0].sql, /^CREATE OR ALTER TRIGGER/);
});
test("trigger templates use table context when available and ON CONNECT for database triggers", () => {
const tableTrigger = buildFirebirdObjectTemplate("trigger", 'order"items');
assert.match(tableTrigger.sql, /FOR "order""items"/);
assert.match(tableTrigger.sql, /ACTIVE BEFORE INSERT POSITION 0/);
const dbTrigger = buildFirebirdObjectTemplate("trigger");
assert.match(dbTrigger.sql, /ACTIVE ON CONNECT POSITION 0/);
const groupTemplate = firebirdObjectTemplateForGroup(connection("firebird"), group("group-triggers", { tableName: "A_DETAIL" }));
assert.equal(groupTemplate?.kind, "trigger");
assert.match(groupTemplate?.sql || "", /FOR "A_DETAIL"/);
});
test("quoteFirebirdIdentifier properly escapes quotes", () => {
assert.equal(quoteFirebirdIdentifier("table_name"), '"table_name"');
assert.equal(quoteFirebirdIdentifier('my"table"name'), '"my""table""name"');
});
test("sidebarObjectTemplateForGroup routes both MySQL and Firebird templates", () => {
const mysql = connection("mysql", "mysql");
const fb = connection("firebird");
const pg = connection("postgres");
const mysqlTpl = sidebarObjectTemplateForGroup(mysql, group("group-triggers", { tableName: "orders" }));
assert.equal(mysqlTpl?.kind, "trigger");
assert.match(mysqlTpl?.sql || "", /DELIMITER/);
const fbTpl = sidebarObjectTemplateForGroup(fb, group("group-triggers", { tableName: "ORDERS" }));
assert.equal(fbTpl?.kind, "trigger");
assert.match(fbTpl?.sql || "", /CREATE OR ALTER TRIGGER "new_trigger" FOR "ORDERS"/);
assert.equal(sidebarObjectTemplateForGroup(pg, group("group-triggers", { tableName: "orders" })), null);
});