fix(structure): enable and disable triggers for sql server

This commit is contained in:
lxk955
2026-10-17 12:06:18 +08:00
committed by GitHub
parent c86abb1ad4
commit 1065bf89ac
16 changed files with 772 additions and 4 deletions
@@ -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")})`,
@@ -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' : '']"
@@ -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");
});
});
});
+4
View File
@@ -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})",
+4
View File
@@ -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})",
+4
View File
@@ -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",
+4
View File
@@ -4496,6 +4496,10 @@ export default withEnglishFallback({
copyPasswordConfirmTitle: "パスワードを含めてコピー",
copyPasswordConfirmMessage: "コピーされるテキストには保存済みのパスワードが平文で含まれます。続行しますか?",
copyPasswordConfirmAction: "コピー",
enableTrigger: "トリガーを有効化",
disableTrigger: "トリガーを無効化",
enableTriggerSuccess: "トリガー「{name}」を有効化しました",
disableTriggerSuccess: "トリガー「{name}」を無効化しました",
},
visibleDatabases: {
title: "表示するデータベース",
+4
View File
@@ -4404,6 +4404,10 @@ export default withEnglishFallback({
copyPasswordConfirmTitle: "비밀번호 포함 복사",
copyPasswordConfirmMessage: "복사되는 텍스트에 저장된 비밀번호가 평문으로 포함됩니다. 계속할까요?",
copyPasswordConfirmAction: "복사",
enableTrigger: "트리거 활성화",
disableTrigger: "트리거 비활성화",
enableTriggerSuccess: "트리거 '{name}'을(를) 활성화했습니다",
disableTriggerSuccess: "트리거 '{name}'을(를) 비활성화했습니다",
},
visibleDatabases: {
title: "표시할 데이터베이스",
+4
View File
@@ -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",
+4
View File
@@ -4593,6 +4593,10 @@ export default withEnglishFallback({
schemaCommentPlaceholder: "Введите комментарий к схеме...",
schemaCommentSaving: "Сохранение...",
editSchemaCommentSuccess: "Комментарий к схеме «{name}» обновлён",
enableTrigger: "Включить триггер",
disableTrigger: "Отключить триггер",
enableTriggerSuccess: "Триггер «{name}» включён",
disableTriggerSuccess: "Триггер «{name}» отключён",
},
visibleDatabases: {
title: "Видимые базы данных",
+4
View File
@@ -4816,6 +4816,10 @@ export default withEnglishFallback({
schemaCommentPlaceholder: "ป้อน Comment ของ Schema...",
schemaCommentSaving: "กำลังบันทึก...",
editSchemaCommentSuccess: "อัปเดต Comment ของ Schema “{name}” แล้ว",
enableTrigger: "เปิดใช้งานทริกเกอร์",
disableTrigger: "ปิดใช้งานทริกเกอร์",
enableTriggerSuccess: 'เปิดใช้งานทริกเกอร์ "{name}" แล้ว',
disableTriggerSuccess: 'ปิดใช้งานทริกเกอร์ "{name}" แล้ว',
},
visibleDatabases: {
title: "ฐานข้อมูลที่มองเห็นได้",
+4
View File
@@ -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})",
+4
View File
@@ -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;
}
}