mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(structure): enable and disable triggers for sql server
This commit is contained in:
@@ -16,6 +16,7 @@ import { canDropDatabaseTables, canEmptyDatabaseTables, useDatabaseTableEmpty }
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import {
|
||||
Ban,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Braces,
|
||||
@@ -87,7 +88,7 @@ import { parseDynamicMenuResponse, renderDynamicMenuEntries, type DynamicMenuAct
|
||||
import type { PluginContextMenuInvocation } from "@/lib/plugins/pluginContext";
|
||||
import type { InstalledPlugin, PluginContextMenuContribution, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
@@ -257,6 +258,7 @@ import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
|
||||
import { buildDamengCompileViewSql } from "@/lib/database/damengCompileSql";
|
||||
import { buildTriggerToggleSql, supportsTriggerToggle } from "@/lib/database/triggerToggleSql";
|
||||
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
|
||||
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
|
||||
import { collapseSubtreeDescendants } from "@/lib/sidebar/sidebarTreeCollapse";
|
||||
@@ -3068,6 +3070,78 @@ async function executeXuguSchedulerJobAction(action: XuguSchedulerJobAction) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTriggerState(node: TreeNode, enabled: boolean) {
|
||||
if (!node.connectionId || !node.database) return;
|
||||
const databaseType = databaseTypeForNode(node);
|
||||
const triggerName = node.meta && "name" in node.meta ? (node.meta as TriggerInfo).name : node.objectName || node.label.replace(/\s+\(.+\)$/, "");
|
||||
if (!triggerName) return;
|
||||
|
||||
const sql = buildTriggerToggleSql({
|
||||
databaseType,
|
||||
schema: node.schema,
|
||||
tableName: node.tableName,
|
||||
triggerName,
|
||||
enabled,
|
||||
});
|
||||
if (!sql) return;
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
if (!executed) return;
|
||||
if (node.meta && typeof node.meta === "object") {
|
||||
(node.meta as TriggerInfo).enabled = enabled;
|
||||
}
|
||||
toast(t(enabled ? "contextMenu.enableTriggerSuccess" : "contextMenu.disableTriggerSuccess", { name: triggerName }), 3000);
|
||||
await connectionStore.refreshTreeNode(node);
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerToggleMenuItems(node: TreeNode): ContextMenuItem[] {
|
||||
const dbType = databaseTypeForNode(node);
|
||||
if (!supportsTriggerToggle(dbType)) return [];
|
||||
const triggerName = node.meta && "name" in node.meta ? (node.meta as TriggerInfo).name : node.objectName || node.label.replace(/\s+\(.+\)$/, "");
|
||||
if (!triggerName) return [];
|
||||
if (["postgres", "kingbase", "opengauss", "vastbase", "highgo", "uxdb", "gaussdb", "kwdb"].includes(dbType || "") && !node.tableName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trigger = node.meta as TriggerInfo | undefined;
|
||||
const currentEnabled = trigger?.enabled;
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
if (currentEnabled === false) {
|
||||
items.push({
|
||||
label: t("contextMenu.enableTrigger"),
|
||||
action: () => toggleTriggerState(node, true),
|
||||
icon: Play,
|
||||
});
|
||||
} else if (currentEnabled === true) {
|
||||
items.push({
|
||||
label: t("contextMenu.disableTrigger"),
|
||||
action: () => toggleTriggerState(node, false),
|
||||
icon: Ban,
|
||||
});
|
||||
} else {
|
||||
items.push(
|
||||
{
|
||||
label: t("contextMenu.enableTrigger"),
|
||||
action: () => toggleTriggerState(node, true),
|
||||
icon: Play,
|
||||
},
|
||||
{
|
||||
label: t("contextMenu.disableTrigger"),
|
||||
action: () => toggleTriggerState(node, false),
|
||||
icon: Ban,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function requestDropObject() {
|
||||
void refreshDropObjectPreviewSql();
|
||||
showDropObjectConfirm.value = true;
|
||||
@@ -7484,6 +7558,11 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
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 });
|
||||
const toggleItems = triggerToggleMenuItems(node);
|
||||
if (toggleItems.length > 0) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push(...toggleItems);
|
||||
}
|
||||
}
|
||||
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 });
|
||||
@@ -7611,6 +7690,13 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
if (canViewDatabaseObjectDependencies(currentDatabaseType(), node)) {
|
||||
items.push({ label: t("contextMenu.viewDependencies"), action: openDatabaseObjectDependencies, icon: Network });
|
||||
}
|
||||
if (node.type === "trigger") {
|
||||
const toggleItems = triggerToggleMenuItems(node);
|
||||
if (toggleItems.length > 0) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push(...toggleItems);
|
||||
}
|
||||
}
|
||||
if (node.type === "package" && currentDatabaseType() === "xugu" && node.xuguPackageBodyAvailable === true) {
|
||||
items.push({
|
||||
label: `${t("contextMenu.viewSource")} (${t("objects.packageBody")})`,
|
||||
|
||||
apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.tableTriggerContextMenu.spec.ts
Vendored
+78
-2
@@ -8,7 +8,11 @@ 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([]) };
|
||||
return {
|
||||
...actual,
|
||||
listPlugins: vi.fn().mockResolvedValue([]),
|
||||
executeQuery: vi.fn().mockResolvedValue({ success: true, columns: [], rows: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue";
|
||||
@@ -30,7 +34,10 @@ 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 store = useConnectionStore();
|
||||
store.connections = [{ id: "test-conn", name: "TestDB", db_type: dbType, driver_profile: dbType, host: "localhost", port: 3050, username: "sysdba", password: "masterkey" }];
|
||||
vi.spyOn(store, "ensureConnected").mockResolvedValue(undefined as any);
|
||||
vi.spyOn(store, "refreshTreeNode").mockResolvedValue(undefined as any);
|
||||
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");
|
||||
@@ -66,4 +73,73 @@ describe("Table trigger context menu", () => {
|
||||
expect(items).toContain(tr("contextMenu.viewSource"));
|
||||
expect(items).toContain(tr("contextMenu.changeOpenMode"));
|
||||
});
|
||||
|
||||
it("exposes disableTrigger for enabled SQL Server table trigger and executes SQL on action", async () => {
|
||||
const api = await import("@/lib/backend/api");
|
||||
const executeQueryMock = vi.fn().mockResolvedValue({ success: true, columns: [], rows: [] });
|
||||
(api as any).executeQuery = executeQueryMock;
|
||||
|
||||
const host = await mountHost("sqlserver");
|
||||
const triggerNode: TreeNode = {
|
||||
...node("trigger", "trg_orders_audit", "orders"),
|
||||
schema: "dbo",
|
||||
meta: { name: "trg_orders_audit", timing: "AFTER", event: "INSERT", enabled: true },
|
||||
};
|
||||
const menu = host.buildContextMenu(triggerNode);
|
||||
const itemLabels = labels(menu);
|
||||
expect(itemLabels).toContain(tr("contextMenu.disableTrigger"));
|
||||
expect(itemLabels).not.toContain(tr("contextMenu.enableTrigger"));
|
||||
|
||||
const disableItem = menu.find((item) => item.label === tr("contextMenu.disableTrigger"));
|
||||
expect(disableItem).toBeDefined();
|
||||
await disableItem?.action?.();
|
||||
expect(executeQueryMock).toHaveBeenCalledWith("test-conn", "testdb", "DISABLE TRIGGER [dbo].[trg_orders_audit] ON [dbo].[orders];", "dbo", undefined, { timeoutSecs: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("exposes enableTrigger for disabled SQL Server table trigger and executes SQL on action", async () => {
|
||||
const api = await import("@/lib/backend/api");
|
||||
const executeQueryMock = vi.fn().mockResolvedValue({ success: true, columns: [], rows: [] });
|
||||
(api as any).executeQuery = executeQueryMock;
|
||||
|
||||
const host = await mountHost("sqlserver");
|
||||
const triggerNode: TreeNode = {
|
||||
...node("trigger", "trg_orders_audit", "orders"),
|
||||
schema: "dbo",
|
||||
meta: { name: "trg_orders_audit", timing: "AFTER", event: "INSERT", enabled: false },
|
||||
};
|
||||
const menu = host.buildContextMenu(triggerNode);
|
||||
const itemLabels = labels(menu);
|
||||
expect(itemLabels).toContain(tr("contextMenu.enableTrigger"));
|
||||
expect(itemLabels).not.toContain(tr("contextMenu.disableTrigger"));
|
||||
|
||||
const enableItem = menu.find((item) => item.label === tr("contextMenu.enableTrigger"));
|
||||
expect(enableItem).toBeDefined();
|
||||
await enableItem?.action?.();
|
||||
expect(executeQueryMock).toHaveBeenCalledWith("test-conn", "testdb", "ENABLE TRIGGER [dbo].[trg_orders_audit] ON [dbo].[orders];", "dbo", undefined, { timeoutSecs: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("exposes both enable and disable when trigger enabled status is unknown", async () => {
|
||||
const host = await mountHost("sqlserver");
|
||||
const triggerNode: TreeNode = {
|
||||
...node("trigger", "trg_orders_audit", "orders"),
|
||||
schema: "dbo",
|
||||
meta: { name: "trg_orders_audit", timing: "AFTER", event: "INSERT" },
|
||||
};
|
||||
const menu = host.buildContextMenu(triggerNode);
|
||||
const itemLabels = labels(menu);
|
||||
expect(itemLabels).toContain(tr("contextMenu.enableTrigger"));
|
||||
expect(itemLabels).toContain(tr("contextMenu.disableTrigger"));
|
||||
});
|
||||
|
||||
it("does not expose trigger toggle actions for unsupported databases like mysql", async () => {
|
||||
const host = await mountHost("mysql");
|
||||
const triggerNode: TreeNode = {
|
||||
...node("trigger", "trg_orders_audit", "orders"),
|
||||
meta: { name: "trg_orders_audit", timing: "AFTER", event: "INSERT", enabled: true },
|
||||
};
|
||||
const menu = host.buildContextMenu(triggerNode);
|
||||
const itemLabels = labels(menu);
|
||||
expect(itemLabels).not.toContain(tr("contextMenu.enableTrigger"));
|
||||
expect(itemLabels).not.toContain(tr("contextMenu.disableTrigger"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5668,6 +5668,7 @@ const canEditForeignKeys = computed(() => (isOracleForeignKeyEditor.value ? true
|
||||
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle" || structureDialect.value === "sqlserver");
|
||||
const isOracleTriggerEditor = computed(() => structureDialect.value === "oracle");
|
||||
const isSqlServerTriggerEditor = computed(() => structureDialect.value === "sqlserver");
|
||||
const supportsTriggerEnableToggle = computed(() => isOracleTriggerEditor.value || isSqlServerTriggerEditor.value);
|
||||
|
||||
function generatedForeignKeyName(column = ""): string {
|
||||
const table = structureIndexTableName() || "table";
|
||||
@@ -8656,7 +8657,7 @@ watch(
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
v-if="isOracleTriggerEditor && trigger.enabled !== undefined"
|
||||
v-if="supportsTriggerEnableToggle && trigger.enabled !== undefined"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="['h-[var(--structure-control-height)] shrink-0 px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]', !trigger.enabled ? 'text-muted-foreground' : '']"
|
||||
|
||||
Vendored
+315
@@ -0,0 +1,315 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, h, nextTick, type App } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connection: {
|
||||
id: "sqlserver-trigger-toggle",
|
||||
name: "SQL Server",
|
||||
db_type: "sqlserver",
|
||||
driver_label: "SQL Server",
|
||||
driver_profile: "sqlserver",
|
||||
},
|
||||
ensureConnected: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
listDataTypes: vi.fn(),
|
||||
buildTableStructureChangeSql: vi.fn(),
|
||||
buildMysqlAutoIncrementSql: vi.fn(),
|
||||
buildTableOwnerChangeSql: vi.fn(),
|
||||
loadObjectDdl: vi.fn(),
|
||||
invalidateObjectDdl: vi.fn(),
|
||||
loadObjectMetadataFacet: vi.fn(),
|
||||
invalidateObjectMetadataCache: vi.fn(),
|
||||
invalidateTableMetadataCache: vi.fn(),
|
||||
getTablePartitionStatus: vi.fn(),
|
||||
getTableOwner: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("vue-i18n")>();
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") });
|
||||
return {
|
||||
AlertTriangle: Icon,
|
||||
Check: Icon,
|
||||
ChevronDown: Icon,
|
||||
ChevronLeft: Icon,
|
||||
ChevronRight: Icon,
|
||||
ChevronUp: Icon,
|
||||
ClipboardList: Icon,
|
||||
Copy: Icon,
|
||||
Database: Icon,
|
||||
Info: Icon,
|
||||
Keyboard: Icon,
|
||||
KeyRound: Icon,
|
||||
ListChevronsUpDown: Icon,
|
||||
Loader2: Icon,
|
||||
Maximize2: Icon,
|
||||
Pencil: Icon,
|
||||
Plus: Icon,
|
||||
RefreshCw: Icon,
|
||||
RotateCcw: Icon,
|
||||
Rows3: Icon,
|
||||
Save: Icon,
|
||||
Search: Icon,
|
||||
Settings: Icon,
|
||||
SlidersHorizontal: Icon,
|
||||
Trash2: Icon,
|
||||
UserRound: Icon,
|
||||
X: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
name: "Button",
|
||||
inheritAttrs: false,
|
||||
props: { disabled: { type: Boolean, default: false } },
|
||||
setup:
|
||||
(props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("button", { ...attrs, disabled: props.disabled }, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
name: "Input",
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: { type: [String, Number], default: "" } },
|
||||
emits: ["update:modelValue"],
|
||||
setup:
|
||||
(props, { attrs, emit }) =>
|
||||
() =>
|
||||
h("input", {
|
||||
...attrs,
|
||||
value: props.modelValue,
|
||||
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/badge", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Badge: defineComponent({
|
||||
name: "Badge",
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("span", { ...attrs, "data-badge": "true" }, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { computed, defineComponent, h, inject, provide, ref } = await import("vue");
|
||||
const TabsSelectKey = Symbol("tabs:select");
|
||||
const TabsActiveKey = Symbol("tabs:active");
|
||||
const Div = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
const Tabs = defineComponent({
|
||||
name: "MockTabs",
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: { type: String, default: "" } },
|
||||
emits: ["update:modelValue"],
|
||||
setup: (props, { attrs, slots, emit }) => {
|
||||
provide(TabsSelectKey, (value: string) => emit("update:modelValue", value));
|
||||
provide(
|
||||
TabsActiveKey,
|
||||
computed(() => props.modelValue),
|
||||
);
|
||||
return () => h("div", attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
const TabsContent = defineComponent({
|
||||
name: "MockTabsContent",
|
||||
inheritAttrs: false,
|
||||
props: { value: { type: String, required: true } },
|
||||
setup: (props, { attrs, slots }) => {
|
||||
const active = inject<{ value: string }>(TabsActiveKey, ref(""));
|
||||
return () => h("div", attrs, active.value === props.value ? slots.default?.() : undefined);
|
||||
},
|
||||
});
|
||||
const TabsTrigger = defineComponent({
|
||||
name: "MockTabsTrigger",
|
||||
inheritAttrs: false,
|
||||
props: { value: { type: String, required: true } },
|
||||
setup: (props, { attrs, slots }) => {
|
||||
const select = inject<(value: string) => void>(TabsSelectKey, () => {});
|
||||
return () => h("button", { ...attrs, type: "button", "data-tab-trigger": props.value, onClick: () => select(props.value) }, slots.default?.());
|
||||
},
|
||||
});
|
||||
return { Tabs, TabsContent, TabsList: Div, TabsTrigger };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const Div = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div };
|
||||
});
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
connectionStateRevision: () => 0,
|
||||
isCurrentConnectionStateRevision: () => true,
|
||||
getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) }));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) }));
|
||||
vi.mock("@/stores/historyStore", () => ({
|
||||
useHistoryStore: () => ({ add: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
structureEditorDensity: "compact",
|
||||
enableExtendedTableProperties: false,
|
||||
useTableStructureDirectSave: false,
|
||||
excludeDdlStorage: false,
|
||||
},
|
||||
updateEditorSettings: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listDataTypes: mocks.listDataTypes,
|
||||
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
|
||||
buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql,
|
||||
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
|
||||
getTablePartitionStatus: mocks.getTablePartitionStatus,
|
||||
getTableOwner: mocks.getTableOwner,
|
||||
executeQuery: mocks.executeQuery,
|
||||
listTriggers: vi.fn().mockResolvedValue([{ name: "trg_orders_audit", timing: "AFTER", event: "INSERT", enabled: true, statement: "BEGIN SET NOCOUNT ON; END" }]),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/table/tableStructureMetadataLoading", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/table/tableStructureMetadataLoading")>();
|
||||
return {
|
||||
...actual,
|
||||
loadObjectMetadataFacet: mocks.loadObjectMetadataFacet,
|
||||
invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache,
|
||||
invalidateTableMetadataCache: mocks.invalidateTableMetadataCache,
|
||||
};
|
||||
});
|
||||
|
||||
let app: App | undefined;
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
|
||||
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
mocks.listDataTypes.mockResolvedValue(["int", "varchar", "datetime"]);
|
||||
mocks.loadObjectMetadataFacet.mockImplementation((_request: any, facet: string) =>
|
||||
Promise.resolve({
|
||||
value:
|
||||
facet === "columns"
|
||||
? [{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true }]
|
||||
: facet === "triggers"
|
||||
? [{ name: "trg_orders_audit", timing: "AFTER", event: "INSERT", enabled: true, statement: "BEGIN SET NOCOUNT ON; END" }]
|
||||
: facet === "comment"
|
||||
? ""
|
||||
: [],
|
||||
cacheStatus: "remote",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("TableStructureEditor SQL Server trigger enable/disable toggle", () => {
|
||||
it("renders trigger enabled toggle button for SQL Server and toggles state on click", async () => {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
|
||||
app = createApp({
|
||||
render: () =>
|
||||
h(
|
||||
TooltipProvider,
|
||||
{},
|
||||
{
|
||||
default: () =>
|
||||
h(TableStructureEditor, {
|
||||
connectionId: mocks.connection.id,
|
||||
database: "testdb",
|
||||
schema: "dbo",
|
||||
tableName: "orders",
|
||||
initialTab: "triggers",
|
||||
}),
|
||||
},
|
||||
),
|
||||
});
|
||||
app.use(pinia);
|
||||
app.component("LightTooltip", LightTooltip);
|
||||
app.mount(root);
|
||||
|
||||
// Wait for triggers tab and metadata to load
|
||||
await vi.waitFor(() => {
|
||||
expect(root.textContent).toContain("trg_orders_audit");
|
||||
});
|
||||
|
||||
// The trigger row should contain a Button for enabled state (not a static Badge)
|
||||
const toggleButton = await vi.waitFor(() => {
|
||||
const btn = Array.from(root.querySelectorAll("button")).find((b) => b.textContent?.trim() === "damengJobAdmin.enabled");
|
||||
expect(btn).toBeTruthy();
|
||||
return btn!;
|
||||
});
|
||||
|
||||
expect(toggleButton).toBeDefined();
|
||||
expect(toggleButton.disabled).toBe(false);
|
||||
|
||||
// Clicking the toggle button should switch state to disabled
|
||||
toggleButton.click();
|
||||
await nextTick();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(toggleButton.textContent?.trim()).toBe("damengJobAdmin.disabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4705,6 +4705,10 @@ export default {
|
||||
mongoIndexExtraOptionsPreserved: "Server-reported options (collation, wildcardProjection, weights, …) are preserved on rebuild.",
|
||||
dropForeignKey: "Drop Foreign Key",
|
||||
dropTrigger: "Drop Trigger",
|
||||
enableTrigger: "Enable Trigger",
|
||||
disableTrigger: "Disable Trigger",
|
||||
enableTriggerSuccess: 'Trigger "{name}" enabled.',
|
||||
disableTriggerSuccess: 'Trigger "{name}" disabled.',
|
||||
batchDrop: "Drop selected ({count})",
|
||||
batchEmpty: "Empty selected ({count})",
|
||||
batchTruncate: "Truncate selected ({count})",
|
||||
|
||||
@@ -4392,6 +4392,10 @@ export default withEnglishFallback({
|
||||
dropAllIndexes: "Eliminar todos los índices",
|
||||
dropForeignKey: "Eliminar clave foránea",
|
||||
dropTrigger: "Eliminar disparador",
|
||||
enableTrigger: "Habilitar disparador",
|
||||
disableTrigger: "Deshabilitar disparador",
|
||||
enableTriggerSuccess: 'Disparador "{name}" habilitado.',
|
||||
disableTriggerSuccess: 'Disparador "{name}" deshabilitado.',
|
||||
batchDrop: "Eliminar seleccionados ({count})",
|
||||
batchEmpty: "Vaciar seleccionadas ({count})",
|
||||
batchTruncate: "Truncar seleccionadas ({count})",
|
||||
|
||||
@@ -4473,6 +4473,10 @@ export default withEnglishFallback({
|
||||
copyPasswordConfirmTitle: "Copia con password",
|
||||
copyPasswordConfirmMessage: "Il testo copiato conterrà la password salvata in chiaro. Continuare?",
|
||||
copyPasswordConfirmAction: "Copia",
|
||||
enableTrigger: "Abilita trigger",
|
||||
disableTrigger: "Disabilita trigger",
|
||||
enableTriggerSuccess: "Trigger «{name}» abilitato",
|
||||
disableTriggerSuccess: "Trigger «{name}» disabilitato",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Database Visibili",
|
||||
|
||||
@@ -4496,6 +4496,10 @@ export default withEnglishFallback({
|
||||
copyPasswordConfirmTitle: "パスワードを含めてコピー",
|
||||
copyPasswordConfirmMessage: "コピーされるテキストには保存済みのパスワードが平文で含まれます。続行しますか?",
|
||||
copyPasswordConfirmAction: "コピー",
|
||||
enableTrigger: "トリガーを有効化",
|
||||
disableTrigger: "トリガーを無効化",
|
||||
enableTriggerSuccess: "トリガー「{name}」を有効化しました",
|
||||
disableTriggerSuccess: "トリガー「{name}」を無効化しました",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "表示するデータベース",
|
||||
|
||||
@@ -4404,6 +4404,10 @@ export default withEnglishFallback({
|
||||
copyPasswordConfirmTitle: "비밀번호 포함 복사",
|
||||
copyPasswordConfirmMessage: "복사되는 텍스트에 저장된 비밀번호가 평문으로 포함됩니다. 계속할까요?",
|
||||
copyPasswordConfirmAction: "복사",
|
||||
enableTrigger: "트리거 활성화",
|
||||
disableTrigger: "트리거 비활성화",
|
||||
enableTriggerSuccess: "트리거 '{name}'을(를) 활성화했습니다",
|
||||
disableTriggerSuccess: "트리거 '{name}'을(를) 비활성화했습니다",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "표시할 데이터베이스",
|
||||
|
||||
@@ -4473,6 +4473,10 @@ export default withEnglishFallback({
|
||||
copyPasswordConfirmTitle: "Copiar com senha",
|
||||
copyPasswordConfirmMessage: "O texto copiado conterá a senha armazenada em texto simples. Continuar?",
|
||||
copyPasswordConfirmAction: "Copiar",
|
||||
enableTrigger: "Ativar trigger",
|
||||
disableTrigger: "Desativar trigger",
|
||||
enableTriggerSuccess: 'Trigger "{name}" ativado',
|
||||
disableTriggerSuccess: 'Trigger "{name}" desativado',
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Bancos de dados visíveis",
|
||||
|
||||
@@ -4593,6 +4593,10 @@ export default withEnglishFallback({
|
||||
schemaCommentPlaceholder: "Введите комментарий к схеме...",
|
||||
schemaCommentSaving: "Сохранение...",
|
||||
editSchemaCommentSuccess: "Комментарий к схеме «{name}» обновлён",
|
||||
enableTrigger: "Включить триггер",
|
||||
disableTrigger: "Отключить триггер",
|
||||
enableTriggerSuccess: "Триггер «{name}» включён",
|
||||
disableTriggerSuccess: "Триггер «{name}» отключён",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Видимые базы данных",
|
||||
|
||||
@@ -4816,6 +4816,10 @@ export default withEnglishFallback({
|
||||
schemaCommentPlaceholder: "ป้อน Comment ของ Schema...",
|
||||
schemaCommentSaving: "กำลังบันทึก...",
|
||||
editSchemaCommentSuccess: "อัปเดต Comment ของ Schema “{name}” แล้ว",
|
||||
enableTrigger: "เปิดใช้งานทริกเกอร์",
|
||||
disableTrigger: "ปิดใช้งานทริกเกอร์",
|
||||
enableTriggerSuccess: 'เปิดใช้งานทริกเกอร์ "{name}" แล้ว',
|
||||
disableTriggerSuccess: 'ปิดใช้งานทริกเกอร์ "{name}" แล้ว',
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "ฐานข้อมูลที่มองเห็นได้",
|
||||
|
||||
@@ -4637,6 +4637,10 @@ export default withEnglishFallback({
|
||||
mongoIndexExtraOptionsPreserved: "服务器报告的选项(collation、wildcardProjection、weights 等)在重建时会被保留。",
|
||||
dropForeignKey: "删除外键",
|
||||
dropTrigger: "删除触发器",
|
||||
enableTrigger: "启用触发器",
|
||||
disableTrigger: "禁用触发器",
|
||||
enableTriggerSuccess: "已启用触发器「{name}」",
|
||||
disableTriggerSuccess: "已禁用触发器「{name}」",
|
||||
batchDrop: "删除所选({count})",
|
||||
batchEmpty: "清空所选({count})",
|
||||
batchTruncate: "截断所选({count})",
|
||||
|
||||
@@ -4216,6 +4216,10 @@ export default withEnglishFallback({
|
||||
dropAllIndexes: "刪除全部索引",
|
||||
dropForeignKey: "刪除外鍵",
|
||||
dropTrigger: "刪除觸發器",
|
||||
enableTrigger: "啟用觸發器",
|
||||
disableTrigger: "停用觸發器",
|
||||
enableTriggerSuccess: "已啟用觸發器「{name}」",
|
||||
disableTriggerSuccess: "已停用觸發器「{name}」",
|
||||
batchDrop: "刪除所選({count})",
|
||||
batchEmpty: "清空所選({count})",
|
||||
batchTruncate: "截斷所選({count})",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTriggerToggleSql, supportsTriggerToggle } from "@/lib/database/triggerToggleSql";
|
||||
|
||||
describe("triggerToggleSql", () => {
|
||||
describe("supportsTriggerToggle", () => {
|
||||
it("returns true for supported database types", () => {
|
||||
expect(supportsTriggerToggle("sqlserver")).toBe(true);
|
||||
expect(supportsTriggerToggle("oracle")).toBe(true);
|
||||
expect(supportsTriggerToggle("oceanbase-oracle")).toBe(true);
|
||||
expect(supportsTriggerToggle("dameng")).toBe(true);
|
||||
expect(supportsTriggerToggle("postgres")).toBe(true);
|
||||
expect(supportsTriggerToggle("kingbase")).toBe(true);
|
||||
expect(supportsTriggerToggle("opengauss")).toBe(true);
|
||||
expect(supportsTriggerToggle("vastbase")).toBe(true);
|
||||
expect(supportsTriggerToggle("highgo")).toBe(true);
|
||||
expect(supportsTriggerToggle("uxdb")).toBe(true);
|
||||
expect(supportsTriggerToggle("gaussdb")).toBe(true);
|
||||
expect(supportsTriggerToggle("kwdb")).toBe(true);
|
||||
expect(supportsTriggerToggle("xugu")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unsupported database types or undefined", () => {
|
||||
expect(supportsTriggerToggle("mysql")).toBe(false);
|
||||
expect(supportsTriggerToggle("sqlite")).toBe(false);
|
||||
expect(supportsTriggerToggle("clickhouse")).toBe(false);
|
||||
expect(supportsTriggerToggle(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTriggerToggleSql", () => {
|
||||
it("generates SQL Server table trigger enable/disable SQL", () => {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "orders",
|
||||
triggerName: "trg_orders_audit",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBe("ENABLE TRIGGER [dbo].[trg_orders_audit] ON [dbo].[orders];");
|
||||
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "orders",
|
||||
triggerName: "trg_orders_audit",
|
||||
enabled: false,
|
||||
}),
|
||||
).toBe("DISABLE TRIGGER [dbo].[trg_orders_audit] ON [dbo].[orders];");
|
||||
});
|
||||
|
||||
it("generates SQL Server table trigger SQL without schema", () => {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "sqlserver",
|
||||
tableName: "orders",
|
||||
triggerName: "trg_orders_audit",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBe("ENABLE TRIGGER [trg_orders_audit] ON [orders];");
|
||||
});
|
||||
|
||||
it("generates SQL Server database-level trigger SQL", () => {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "sqlserver",
|
||||
triggerName: "ddl_trg_database",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBe("ENABLE TRIGGER [ddl_trg_database] ON DATABASE;");
|
||||
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "sqlserver",
|
||||
triggerName: "ddl_trg_database",
|
||||
enabled: false,
|
||||
}),
|
||||
).toBe("DISABLE TRIGGER [ddl_trg_database] ON DATABASE;");
|
||||
});
|
||||
|
||||
it("escapes brackets for SQL Server identifiers", () => {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "d]bo",
|
||||
tableName: "ord]ers",
|
||||
triggerName: "trg]audit",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBe("ENABLE TRIGGER [d]]bo].[trg]]audit] ON [d]]bo].[ord]]ers];");
|
||||
});
|
||||
|
||||
it("generates Oracle / OceanBase / Dameng / Xugu trigger enable/disable SQL", () => {
|
||||
for (const databaseType of ["oracle", "oceanbase-oracle", "dameng", "xugu"] as const) {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType,
|
||||
schema: "HR",
|
||||
tableName: "ORDERS",
|
||||
triggerName: "TRG_ORDERS_BI",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBe('ALTER TRIGGER "HR"."TRG_ORDERS_BI" ENABLE;');
|
||||
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType,
|
||||
schema: "HR",
|
||||
tableName: "ORDERS",
|
||||
triggerName: "TRG_ORDERS_BI",
|
||||
enabled: false,
|
||||
}),
|
||||
).toBe('ALTER TRIGGER "HR"."TRG_ORDERS_BI" DISABLE;');
|
||||
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType,
|
||||
triggerName: "TRG_ORDERS_BI",
|
||||
enabled: false,
|
||||
}),
|
||||
).toBe('ALTER TRIGGER "TRG_ORDERS_BI" DISABLE;');
|
||||
}
|
||||
});
|
||||
|
||||
it("generates PostgreSQL family table trigger enable/disable SQL", () => {
|
||||
for (const databaseType of ["postgres", "kingbase", "opengauss", "vastbase"] as const) {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType,
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
triggerName: "trg_orders_audit",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBe('ALTER TABLE "public"."orders" ENABLE TRIGGER "trg_orders_audit";');
|
||||
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType,
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
triggerName: "trg_orders_audit",
|
||||
enabled: false,
|
||||
}),
|
||||
).toBe('ALTER TABLE "public"."orders" DISABLE TRIGGER "trg_orders_audit";');
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for PostgreSQL when tableName is missing", () => {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "postgres",
|
||||
triggerName: "trg_orders_audit",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unsupported database types or empty trigger name", () => {
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "orders",
|
||||
triggerName: "trg_orders_audit",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
buildTriggerToggleSql({
|
||||
databaseType: "sqlserver",
|
||||
tableName: "orders",
|
||||
triggerName: " ",
|
||||
enabled: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export interface TriggerToggleSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
triggerName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const SUPPORTED_TRIGGER_TOGGLE_DATABASES = new Set<DatabaseType>(["sqlserver", "oracle", "oceanbase-oracle", "dameng", "postgres", "kingbase", "opengauss", "vastbase", "highgo", "uxdb", "gaussdb", "kwdb", "xugu"]);
|
||||
|
||||
export function supportsTriggerToggle(databaseType?: DatabaseType): boolean {
|
||||
if (!databaseType) return false;
|
||||
return SUPPORTED_TRIGGER_TOGGLE_DATABASES.has(databaseType);
|
||||
}
|
||||
|
||||
function quoteSqlServer(name: string): string {
|
||||
return `[${name.replaceAll("]", "]]")}]`;
|
||||
}
|
||||
|
||||
function quoteDouble(name: string): string {
|
||||
return `"${name.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export function buildTriggerToggleSql(options: TriggerToggleSqlOptions): string | null {
|
||||
const { databaseType, schema, tableName, triggerName, enabled } = options;
|
||||
const cleanTrigger = triggerName.trim();
|
||||
if (!cleanTrigger || !databaseType || !supportsTriggerToggle(databaseType)) return null;
|
||||
|
||||
const stateKeyword = enabled ? "ENABLE" : "DISABLE";
|
||||
const cleanSchema = schema?.trim();
|
||||
const cleanTable = tableName?.trim();
|
||||
|
||||
switch (databaseType) {
|
||||
case "sqlserver": {
|
||||
const quotedTrigger = cleanSchema ? `${quoteSqlServer(cleanSchema)}.${quoteSqlServer(cleanTrigger)}` : quoteSqlServer(cleanTrigger);
|
||||
if (cleanTable) {
|
||||
const quotedTable = cleanSchema ? `${quoteSqlServer(cleanSchema)}.${quoteSqlServer(cleanTable)}` : quoteSqlServer(cleanTable);
|
||||
return `${stateKeyword} TRIGGER ${quotedTrigger} ON ${quotedTable};`;
|
||||
}
|
||||
return `${stateKeyword} TRIGGER ${quotedTrigger} ON DATABASE;`;
|
||||
}
|
||||
|
||||
case "oracle":
|
||||
case "oceanbase-oracle":
|
||||
case "dameng":
|
||||
case "xugu": {
|
||||
const quotedTrigger = cleanSchema ? `${quoteDouble(cleanSchema)}.${quoteDouble(cleanTrigger)}` : quoteDouble(cleanTrigger);
|
||||
return `ALTER TRIGGER ${quotedTrigger} ${stateKeyword};`;
|
||||
}
|
||||
|
||||
case "postgres":
|
||||
case "kingbase":
|
||||
case "opengauss":
|
||||
case "vastbase":
|
||||
case "highgo":
|
||||
case "uxdb":
|
||||
case "gaussdb":
|
||||
case "kwdb": {
|
||||
if (!cleanTable) return null;
|
||||
const quotedTable = cleanSchema ? `${quoteDouble(cleanSchema)}.${quoteDouble(cleanTable)}` : quoteDouble(cleanTable);
|
||||
const quotedTrigger = quoteDouble(cleanTrigger);
|
||||
return `ALTER TABLE ${quotedTable} ${stateKeyword} TRIGGER ${quotedTrigger};`;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user