@@ -3820,6 +3900,15 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
container-type: inline-size;
}
+.event-editor-layout > :first-child {
+ display: none;
+}
+
+.event-editor-layout > .object-browser-side-panel {
+ width: 100% !important;
+ border-left: 0;
+}
+
.table-info-action-button {
gap: 0.25rem;
max-width: 8rem;
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index a38f276c2e..e6edb52fb5 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -28,6 +28,7 @@ import {
Pin,
ArrowRightLeft,
Download,
+ Eye,
Upload,
FileCode,
Network,
@@ -966,6 +967,10 @@ function runRowClickAction(clickDetail: number, requestId: number) {
openMongoTreeData(node);
return;
}
+ if (node.type === "event") {
+ void openObjectBrowser();
+ return;
+ }
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation, currentDatabaseType(), settingsStore.editorSettings.sidebarOpenDatabaseOnSingleClick, canOpenObjectBrowser.value);
// WebKit can keep incrementing click.detail while the pointer moves quickly
// between adjacent rows. Nacos entries are idempotent navigation targets, so
@@ -1323,6 +1328,14 @@ function requestDeleteSelectedNode(): boolean {
function onDoubleClick(event: MouseEvent) {
if (dataTabOpenModeFromTreeClick(activeNode.value.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return;
+ if (activeNode.value.type === "event") {
+ void openObjectBrowser();
+ return;
+ }
+ if (activeNode.value.type === "group-events") {
+ void openObjectBrowser();
+ return;
+ }
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType(), canOpenConnectionDatabaseBrowser.value, settingsStore.editorSettings.sidebarOpenDatabaseOnSingleClick);
if (action === "open-database-browser") {
void openDatabaseBrowser();
@@ -1471,7 +1484,7 @@ async function confirmDeleteSavedSqlFile() {
releaseActiveNodeReference([node.id]);
}
-async function openObjectBrowser() {
+async function openObjectBrowser(eventReadOnly = false, openEventEditor = false) {
const node = activeNode.value;
if (!node.connectionId) return;
try {
@@ -1479,7 +1492,7 @@ async function openObjectBrowser() {
connectionStore.activeConnectionId = node.connectionId;
if (hasTreeNodeDatabaseContext(node)) {
- queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog);
+ queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, openEventEditor && node.type === "event" ? node.objectName || node.label : undefined, eventReadOnly, node.type === "event" || node.type === "group-events" ? "events" : undefined);
return;
}
@@ -1488,7 +1501,7 @@ async function openObjectBrowser() {
const options = await getDatabaseOptions(node.connectionId);
const database = resolveDefaultDatabase(connection, options);
if (database) {
- queryStore.openObjectBrowser(node.connectionId, database);
+ queryStore.openObjectBrowser(node.connectionId, database, undefined, undefined, openEventEditor && node.type === "event" ? node.objectName || node.label : undefined, eventReadOnly, node.type === "event" || node.type === "group-events" ? "events" : undefined);
} else {
await toggle();
}
@@ -2018,10 +2031,10 @@ function dropObjectSqlOptions(): DropObjectSqlOptions | null {
}
function dropObjectSqlOptionsForNode(node: TreeNode): DropObjectSqlOptions | null {
- if (node.type !== "view" && node.type !== "materialized_view" && node.type !== "procedure" && node.type !== "function") return null;
+ if (node.type !== "view" && node.type !== "materialized_view" && node.type !== "procedure" && node.type !== "function" && node.type !== "event") return null;
return {
databaseType: tableStructureDatabaseTypeForNode(node),
- objectType: node.type === "view" ? "VIEW" : node.type === "materialized_view" ? "MATERIALIZED_VIEW" : node.type === "procedure" ? "PROCEDURE" : "FUNCTION",
+ objectType: node.type === "view" ? "VIEW" : node.type === "materialized_view" ? "MATERIALIZED_VIEW" : node.type === "procedure" ? "PROCEDURE" : node.type === "function" ? "FUNCTION" : "EVENT",
schema: node.schema,
name: node.objectName || node.label,
signature: node.signature,
@@ -2082,6 +2095,7 @@ function dropObjectMenuLabel(): string {
if (activeNode.value.type === "materialized_view") return t("contextMenu.dropView");
if (activeNode.value.type === "procedure") return t("contextMenu.dropProcedure");
if (activeNode.value.type === "function") return t("contextMenu.dropFunction");
+ if (activeNode.value.type === "event") return t("contextMenu.dropObject");
return t("contextMenu.dropObject");
}
@@ -2243,7 +2257,7 @@ function requestDropTableChildObject() {
function canDropTreeNode(node: TreeNode): boolean {
if (isSqlServerLinkedNode(node)) return false;
if (node.type === "table") return !!node.connectionId && !!node.database;
- if (node.type === "view" || node.type === "materialized_view" || node.type === "procedure" || node.type === "function") {
+ if (node.type === "view" || node.type === "materialized_view" || node.type === "procedure" || node.type === "function" || node.type === "event") {
return !!node.connectionId && !!node.database && !!dropObjectSqlOptionsForNode(node);
}
if (canDropMongoIndexNode(node)) return true;
@@ -2756,7 +2770,8 @@ async function confirmDropObject() {
await connectionStore.ensureConnected(node.connectionId);
const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options));
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
- const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
+ const msgKey =
+ node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : node.type === "function" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropEventSuccess";
toast(t(msgKey, { name: node.label }), 3000);
closeDroppedTableObjectTabsForNode(node);
// Refresh the parent object list so group badges and children stay in sync.
@@ -5549,7 +5564,18 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
- if (node.type === "sequence" || node.type === "event") {
+ if (node.type === "event") {
+ // Event definitions use the dedicated editor hosted by ObjectBrowser.
+ // Open that surface from the tree instead of routing through the generic source dialog.
+ items.push({ label: t("contextMenu.editObject"), action: () => openObjectBrowser(false, true), icon: Pencil });
+ items.push({ label: t("contextMenu.viewObject"), action: () => openObjectBrowser(true, true), icon: Eye });
+ items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
+ items.push({ label: t("contextMenu.dropObject"), action: deleteMenuAction(requestDropObject), icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
+ items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw, shortcut: shortcutRefresh });
+ return true;
+ }
+
+ if (node.type === "sequence") {
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
@@ -5632,6 +5658,9 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
if (node.type === "group-views" && node.connectionId && node.database) {
items.push({ label: t("contextMenu.createView"), action: createView, icon: Plus });
}
+ if (node.type === "group-events" && node.connectionId && node.database) {
+ items.push({ label: t("contextMenu.createEvent"), action: openObjectBrowser, icon: Plus });
+ }
if (mysqlObjectTemplate) {
items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus });
}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 83ac1d9b1d..35a5e6df4e 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -2924,12 +2924,34 @@ export default {
dropTableSuccess: 'Table "{name}" dropped',
editView: "Edit View",
viewSource: "View Source",
+ viewObject: "View",
changeOpenMode: "Change open mode",
viewDetails: "View Details",
viewDdl: "View DDL",
viewDdlLoading: "Loading DDL...",
ddlCopied: "DDL copied",
dropObject: "Drop Object",
+ dropEventSuccess: 'Event "{name}" dropped',
+ editObject: "Edit Object",
+ createEvent: "Create Event",
+ eventEditorTitle: "MySQL Event",
+ eventDefinition: "Definition",
+ eventSchedule: "Schedule",
+ eventComment: "Comment",
+ eventSqlPreview: "SQL preview",
+ eventName: "Name",
+ eventBodyPlaceholder: "Event body, e.g. CALL procedure();",
+ eventExecuteAt: "Execute at",
+ eventIntervalValue: "Interval value",
+ eventIntervalUnit: "Interval unit",
+ eventStarts: "Starts",
+ eventEnds: "Ends",
+ eventCompletion: "Completion",
+ eventStatus: "Status",
+ eventCommentPlaceholder: "Optional comment",
+ eventLoading: "Loading...",
+ eventCancel: "Cancel",
+ eventSave: "Save",
dropView: "Drop View",
dropColumn: "Drop Column",
dropIndex: "Drop Index",
@@ -3019,6 +3041,8 @@ export default {
confirmDropProcedureMessage: 'Are you sure you want to drop procedure "{name}"?',
confirmDropFunctionTitle: "Drop Function",
confirmDropFunctionMessage: 'Are you sure you want to drop function "{name}"?',
+ confirmDropEventTitle: "Drop Event",
+ confirmDropEventMessage: 'Are you sure you want to drop event "{name}"?',
dropViewSuccess: 'View "{name}" dropped',
dropProcedureSuccess: 'Procedure "{name}" dropped',
dropFunctionSuccess: 'Function "{name}" dropped',
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index a8ae71fd5a..ecbf1e5993 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -3043,8 +3043,30 @@ export default withEnglishFallback({
createTrigger: "Nuevo disparador",
changeOpenMode: "Modificar modo de apertura",
closeRightTabs: "Cerrar pestañas a la derecha",
+ viewObject: "View",
+ eventEditorTitle: "MySQL Event",
+ eventDefinition: "Definition",
+ eventSchedule: "Schedule",
+ eventComment: "Comment",
+ eventSqlPreview: "SQL preview",
+ eventName: "Name",
+ eventBodyPlaceholder: "Event body, e.g. CALL procedure();",
+ eventExecuteAt: "Execute at",
+ eventIntervalValue: "Interval value",
+ eventIntervalUnit: "Interval unit",
+ eventStarts: "Starts",
+ eventEnds: "Ends",
+ eventCompletion: "Completion",
+ eventStatus: "Status",
+ eventCommentPlaceholder: "Optional comment",
+ eventLoading: "Loading...",
+ eventCancel: "Cancel",
+ eventSave: "Save",
compileObject: "Compilar",
compileObjectSuccess: '"{name}" compilado con éxito',
+ dropEventSuccess: "Evento eliminado {name}",
+ editObject: "Editar objeto",
+ createEvent: "Crear evento",
},
visibleDatabases: {
title: "Bases de datos visibles",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index cddd18740b..d0b65ceb1b 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -3041,8 +3041,30 @@ export default withEnglishFallback({
createTrigger: "Nuovo trigger",
changeOpenMode: "Modifica modalità apertura",
closeRightTabs: "Chiudi le schede a destra",
+ viewObject: "View",
+ eventEditorTitle: "MySQL Event",
+ eventDefinition: "Definition",
+ eventSchedule: "Schedule",
+ eventComment: "Comment",
+ eventSqlPreview: "SQL preview",
+ eventName: "Name",
+ eventBodyPlaceholder: "Event body, e.g. CALL procedure();",
+ eventExecuteAt: "Execute at",
+ eventIntervalValue: "Interval value",
+ eventIntervalUnit: "Interval unit",
+ eventStarts: "Starts",
+ eventEnds: "Ends",
+ eventCompletion: "Completion",
+ eventStatus: "Status",
+ eventCommentPlaceholder: "Optional comment",
+ eventLoading: "Loading...",
+ eventCancel: "Cancel",
+ eventSave: "Save",
compileObject: "Compila",
compileObjectSuccess: '"{name}" compilato con successo',
+ dropEventSuccess: "Evento eliminato {name}",
+ editObject: "Modifica oggetto",
+ createEvent: "Nuovo evento",
},
visibleDatabases: {
title: "Database Visibili",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 12dd70740c..d8ab47eba2 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -3067,8 +3067,30 @@ export default withEnglishFallback({
createTrigger: "新規トリガー",
changeOpenMode: "開き方を変更",
closeRightTabs: "右側のタブを閉じる",
+ viewObject: "View",
+ eventEditorTitle: "MySQL Event",
+ eventDefinition: "Definition",
+ eventSchedule: "Schedule",
+ eventComment: "Comment",
+ eventSqlPreview: "SQL preview",
+ eventName: "Name",
+ eventBodyPlaceholder: "Event body, e.g. CALL procedure();",
+ eventExecuteAt: "Execute at",
+ eventIntervalValue: "Interval value",
+ eventIntervalUnit: "Interval unit",
+ eventStarts: "Starts",
+ eventEnds: "Ends",
+ eventCompletion: "Completion",
+ eventStatus: "Status",
+ eventCommentPlaceholder: "Optional comment",
+ eventLoading: "Loading...",
+ eventCancel: "Cancel",
+ eventSave: "Save",
compileObject: "コンパイル",
compileObjectSuccess: '"{name}" のコンパイルに成功しました',
+ dropEventSuccess: "イベントが削除されました {name}",
+ editObject: "オブジェクトを編集",
+ createEvent: "イベントを作成",
},
visibleDatabases: {
title: "表示するデータベース",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index cf3b65a11d..1d175fa7f9 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -2924,6 +2924,25 @@ export default withEnglishFallback({
schemaCommentPlaceholder: "스키마 주석 입력...",
schemaCommentSaving: "저장 중...",
editSchemaCommentSuccess: '스키마 "{name}" 주석이 업데이트되었습니다',
+ viewObject: "View",
+ eventEditorTitle: "MySQL Event",
+ eventDefinition: "Definition",
+ eventSchedule: "Schedule",
+ eventComment: "Comment",
+ eventSqlPreview: "SQL preview",
+ eventName: "Name",
+ eventBodyPlaceholder: "Event body, e.g. CALL procedure();",
+ eventExecuteAt: "Execute at",
+ eventIntervalValue: "Interval value",
+ eventIntervalUnit: "Interval unit",
+ eventStarts: "Starts",
+ eventEnds: "Ends",
+ eventCompletion: "Completion",
+ eventStatus: "Status",
+ eventCommentPlaceholder: "Optional comment",
+ eventLoading: "Loading...",
+ eventCancel: "Cancel",
+ eventSave: "Save",
},
visibleDatabases: {
title: "표시할 데이터베이스",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index cae81367f9..74ad40fdcb 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -3039,12 +3039,34 @@ export default withEnglishFallback({
createTrigger: "Novo gatilho",
changeOpenMode: "Alterar modo de abertura",
closeRightTabs: "Fechar abas à direita",
+ viewObject: "View",
+ eventEditorTitle: "MySQL Event",
+ eventDefinition: "Definition",
+ eventSchedule: "Schedule",
+ eventComment: "Comment",
+ eventSqlPreview: "SQL preview",
+ eventName: "Name",
+ eventBodyPlaceholder: "Event body, e.g. CALL procedure();",
+ eventExecuteAt: "Execute at",
+ eventIntervalValue: "Interval value",
+ eventIntervalUnit: "Interval unit",
+ eventStarts: "Starts",
+ eventEnds: "Ends",
+ eventCompletion: "Completion",
+ eventStatus: "Status",
+ eventCommentPlaceholder: "Optional comment",
+ eventLoading: "Loading...",
+ eventCancel: "Cancel",
+ eventSave: "Save",
compileObject: "Compilar",
compileObjectSuccess: '"{name}" compilado com sucesso',
renameDatabase: "Renomear banco de dados",
renameDatabaseTitle: "Renomear banco de dados",
renameDatabaseNotOwner: "Somente o proprietário do banco de dados pode renomear «{name}».",
renameDatabasePreparedTransactions: "Há {count} transações preparadas pendentes no banco de dados «{name}». Execute COMMIT ou ROLLBACK antes de renomear.",
+ dropEventSuccess: "Evento excluído {name}",
+ editObject: "Editar objeto",
+ createEvent: "Criar evento",
},
visibleDatabases: {
title: "Bancos de dados visíveis",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 4a288a281e..ba2434bf28 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2848,6 +2848,7 @@ export default withEnglishFallback({
dropTableSuccess: "表「{name}」已删除",
editView: "编辑视图",
viewSource: "查看源码",
+ viewObject: "查看",
changeOpenMode: "修改打开方式",
viewDetails: "查看详情",
viewDdl: "查看 DDL",
@@ -2858,6 +2859,27 @@ export default withEnglishFallback({
dropColumn: "删除字段",
dropIndex: "删除索引",
dropAllIndexes: "删除全部索引",
+ dropEventSuccess: "事件已删除 {name}",
+ editObject: "编辑对象",
+ createEvent: "新建事件",
+ eventEditorTitle: "MySQL 事件",
+ eventDefinition: "定义",
+ eventSchedule: "计划",
+ eventComment: "注释",
+ eventSqlPreview: "SQL 预览",
+ eventName: "名称",
+ eventBodyPlaceholder: "事件内容,例如 CALL procedure();",
+ eventExecuteAt: "执行时间",
+ eventIntervalValue: "间隔值",
+ eventIntervalUnit: "间隔单位",
+ eventStarts: "开始时间",
+ eventEnds: "结束时间",
+ eventCompletion: "完成策略",
+ eventStatus: "状态",
+ eventCommentPlaceholder: "可选注释",
+ eventLoading: "正在加载...",
+ eventCancel: "取消",
+ eventSave: "保存",
createMongoIndex: "新建索引",
createMongoIndexTitle: "创建索引:「{collection}」",
createMongoIndexFields: "索引字段",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index ae561177a0..12e1aebdf6 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -3041,8 +3041,30 @@ export default withEnglishFallback({
createTrigger: "新增觸發器",
changeOpenMode: "修改開啟方式",
closeRightTabs: "關閉右側標籤頁",
+ viewObject: "View",
+ eventEditorTitle: "MySQL Event",
+ eventDefinition: "Definition",
+ eventSchedule: "Schedule",
+ eventComment: "Comment",
+ eventSqlPreview: "SQL preview",
+ eventName: "Name",
+ eventBodyPlaceholder: "Event body, e.g. CALL procedure();",
+ eventExecuteAt: "Execute at",
+ eventIntervalValue: "Interval value",
+ eventIntervalUnit: "Interval unit",
+ eventStarts: "Starts",
+ eventEnds: "Ends",
+ eventCompletion: "Completion",
+ eventStatus: "Status",
+ eventCommentPlaceholder: "Optional comment",
+ eventLoading: "Loading...",
+ eventCancel: "Cancel",
+ eventSave: "Save",
compileObject: "編譯",
compileObjectSuccess: '"{name}" 編譯成功',
+ dropEventSuccess: "事件已刪除 {name}",
+ editObject: "編輯物件",
+ createEvent: "新建事件",
},
visibleDatabases: {
title: "顯示資料庫",
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index 020149388f..a7e797fcc9 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -166,6 +166,7 @@ export const listObjectStatistics = forward("listObjectStatistics");
export const listCompletionObjects = forward("listCompletionObjects");
export const completionAssistantSearch = forward("completionAssistantSearch");
export const getObjectSource = forward("getObjectSource");
+export const getEventInfo = forward("getEventInfo");
export const getCustomTypeDetails = forward("getCustomTypeDetails");
export const getColumns = forward("getColumns");
export const getAllColumns = forward("getAllColumns");
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index 48c6546350..f39b54349f 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -17,6 +17,7 @@ import type {
CustomTypeDetails,
ObjectSource,
ObjectSourceKind,
+ MysqlEventInfo,
ColumnInfo,
SqlServerColumnMetadata,
IndexInfo,
@@ -862,6 +863,10 @@ export async function getObjectSource(connectionId: string, database: string, sc
return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature, relation_name: relationName })}`);
}
+export async function getEventInfo(connectionId: string, database: string, schema: string, name: string): Promise
{
+ return get(`/api/schema/event-info?${qs({ connection_id: connectionId, database, schema, table: name })}`);
+}
+
export async function getCustomTypeDetails(connectionId: string, database: string, schema: string, name: string): Promise {
return get(`/api/schema/custom-type-details?${qs({ connection_id: connectionId, database, schema, table: name })}`);
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index c296c7d703..dbcd1e8918 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -33,6 +33,7 @@ import type {
CustomTypeDetails,
ObjectSource,
ObjectSourceKind,
+ MysqlEventInfo,
ColumnInfo,
SqlServerColumnMetadata,
IndexInfo,
@@ -1132,6 +1133,10 @@ export async function getObjectSource(connectionId: string, database: string, sc
});
}
+export async function getEventInfo(connectionId: string, database: string, schema: string, name: string): Promise {
+ return invoke("get_event_info", { connectionId, database, schema, name });
+}
+
export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise {
return invoke("list_schemas", { connectionId, database, applyVisibleFilter });
}
diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts
index 3c8fab73d0..080cb3f91a 100644
--- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts
@@ -5,6 +5,7 @@ const leafTypes: Set = new Set([
"index",
"fkey",
"trigger",
+ "event",
"procedure",
"function",
"synonym",
diff --git a/apps/desktop/src/lib/table/mysqlEventSql.spec.ts b/apps/desktop/src/lib/table/mysqlEventSql.spec.ts
new file mode 100644
index 0000000000..c549b33c77
--- /dev/null
+++ b/apps/desktop/src/lib/table/mysqlEventSql.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { buildMysqlEventSql } from "./mysqlEventSql";
+
+describe("buildMysqlEventSql", () => {
+ it("builds an escaped EVERY event", () => {
+ expect(buildMysqlEventSql({ name: "daily`sync", schedule: { mode: "every", intervalValue: "1", intervalUnit: "day" }, preserve: true, enabled: false, comment: "owner's job", body: "CALL refresh();" }, "CREATE")).toBe(
+ "CREATE EVENT `daily``sync` ON SCHEDULE EVERY 1 DAY ON COMPLETION PRESERVE DISABLE COMMENT 'owner\\'s job' DO CALL refresh();",
+ );
+ });
+ it("rejects empty body and invalid units", () => {
+ expect(() => buildMysqlEventSql({ name: "e", schedule: { mode: "every", intervalValue: "1", intervalUnit: "FORTNIGHT" }, body: "SELECT 1" })).toThrow("Invalid interval unit");
+ expect(() => buildMysqlEventSql({ name: "e", schedule: { mode: "at", executeAt: "" }, body: " " })).toThrow("Event body");
+ });
+ it("rejects non-positive or non-numeric interval values", () => {
+ for (const intervalValue of ["", "0", " 000 ", "1 DAY", "1; DROP EVENT other"]) {
+ expect(() => buildMysqlEventSql({ name: "e", schedule: { mode: "every", intervalValue, intervalUnit: "DAY" }, body: "SELECT 1" })).toThrow("positive integer interval value");
+ }
+ });
+});
diff --git a/apps/desktop/src/lib/table/mysqlEventSql.ts b/apps/desktop/src/lib/table/mysqlEventSql.ts
new file mode 100644
index 0000000000..4c20dc73bc
--- /dev/null
+++ b/apps/desktop/src/lib/table/mysqlEventSql.ts
@@ -0,0 +1,48 @@
+export type MysqlEventSchedule = { mode: "at"; executeAt: string } | { mode: "every"; intervalValue: string; intervalUnit: string };
+
+export type MysqlEventDraft = {
+ name: string;
+ schema?: string;
+ schedule: MysqlEventSchedule;
+ starts?: string;
+ ends?: string;
+ preserve?: boolean;
+ enabled?: boolean;
+ comment?: string;
+ body: string;
+};
+
+function ident(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) throw new Error("Event name is required");
+ return `\`${trimmed.replaceAll("`", "``")}\``;
+}
+function literal(value: string): string {
+ return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
+}
+const units = new Set(["SECOND", "MINUTE", "HOUR", "DAY", "WEEK", "MONTH", "QUARTER", "YEAR"]);
+
+export function buildMysqlEventSql(draft: MysqlEventDraft, operation: "CREATE" | "ALTER" = "ALTER"): string {
+ const name = ident(draft.name);
+ if (!draft.body.trim()) throw new Error("Event body is required");
+ let schedule: string;
+ if (draft.schedule.mode === "at") {
+ if (!draft.schedule.executeAt.trim()) throw new Error("AT schedule requires an execution time");
+ schedule = `AT ${literal(draft.schedule.executeAt)}`;
+ } else {
+ const unit = draft.schedule.intervalUnit.trim().toUpperCase();
+ const intervalValue = draft.schedule.intervalValue.trim();
+ if (!/^\d+$/.test(intervalValue) || /^0+$/.test(intervalValue)) {
+ throw new Error("EVERY schedule requires a positive integer interval value");
+ }
+ if (!units.has(unit)) throw new Error(`Invalid interval unit: ${draft.schedule.intervalUnit}`);
+ schedule = `EVERY ${intervalValue} ${unit}`;
+ }
+ let sql = `${operation} EVENT ${name} ON SCHEDULE ${schedule}`;
+ if (draft.starts?.trim()) sql += ` STARTS ${literal(draft.starts)}`;
+ if (draft.ends?.trim()) sql += ` ENDS ${literal(draft.ends)}`;
+ if (draft.preserve !== undefined) sql += draft.preserve ? " ON COMPLETION PRESERVE" : " ON COMPLETION NOT PRESERVE";
+ if (draft.enabled !== undefined) sql += draft.enabled ? " ENABLE" : " DISABLE";
+ if (draft.comment !== undefined) sql += ` COMMENT ${literal(draft.comment)}`;
+ return `${sql} DO ${draft.body.trim()}`;
+}
diff --git a/apps/desktop/src/lib/table/objectBrowserRowAction.ts b/apps/desktop/src/lib/table/objectBrowserRowAction.ts
index a01671db06..50021ed90c 100644
--- a/apps/desktop/src/lib/table/objectBrowserRowAction.ts
+++ b/apps/desktop/src/lib/table/objectBrowserRowAction.ts
@@ -17,6 +17,7 @@ export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" |
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (row.type === "TABLE") return "table-info";
+ if (row.type === "EVENT") return "none";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
@@ -31,6 +32,7 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, d
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (row.type === "TABLE") return "open-table";
+ if (row.type === "EVENT") return "open-source";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
@@ -83,5 +85,7 @@ function canOpenSource(row: ObjectBrowserRow, dbType?: DatabaseType): boolean {
// Xugu keeps its source editor entry through supportsTypeObjectSource below.
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return false;
if ((row.type === "TYPE" || row.type === "TYPE_BODY") && !supportsTypeObjectSource(dbType)) return false;
- return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
+ return (
+ row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "TRIGGER" || row.type === "EVENT" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY"
+ );
}
diff --git a/apps/desktop/src/lib/table/objectBrowserRows.event.spec.ts b/apps/desktop/src/lib/table/objectBrowserRows.event.spec.ts
new file mode 100644
index 0000000000..e5d2330f89
--- /dev/null
+++ b/apps/desktop/src/lib/table/objectBrowserRows.event.spec.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+import { buildObjectBrowserRows, countObjectBrowserRowsByFilter, normalizeObjectBrowserType } from "./objectBrowserRows";
+
+describe("object browser Event rows", () => {
+ it("normalizes and counts MySQL events", () => {
+ expect(normalizeObjectBrowserType("EVENT")).toBe("EVENT");
+ const rows = buildObjectBrowserRows({ database: "shop", fallbackSchema: "shop", objects: [{ name: "daily_sync", object_type: "EVENT", schema: "shop", comment: null, created_at: null, updated_at: null, parent_schema: null, parent_name: null }] });
+ expect(rows[0]?.type).toBe("EVENT");
+ expect(countObjectBrowserRowsByFilter(rows).events).toBe(1);
+ });
+});
diff --git a/apps/desktop/src/lib/table/objectBrowserRows.ts b/apps/desktop/src/lib/table/objectBrowserRows.ts
index 3e4bef60c9..5f773ae3a4 100644
--- a/apps/desktop/src/lib/table/objectBrowserRows.ts
+++ b/apps/desktop/src/lib/table/objectBrowserRows.ts
@@ -8,7 +8,7 @@ export type ObjectBrowserRow = {
name: string;
displayName: string;
schema?: string;
- type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
+ type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
valid?: boolean | null;
signature?: string | null;
comment?: string | null;
@@ -24,7 +24,7 @@ export type ObjectBrowserRow = {
export type ObjectBrowserSortKey = "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
export type ObjectBrowserSortDirection = "asc" | "desc";
-export type ObjectBrowserFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "sequences" | "packages" | "types";
+export type ObjectBrowserFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "events" | "sequences" | "packages" | "types";
export type ObjectBrowserFilterCounts = Record;
export type ObjectBrowserPinnedTreeNodeContext = {
@@ -42,6 +42,7 @@ export function objectBrowserRowTreeNodeType(type: ObjectBrowserRow["type"]): Tr
if (type === "PROCEDURE") return "procedure";
if (type === "FUNCTION") return "function";
if (type === "TRIGGER") return "trigger";
+ if (type === "EVENT") return "event";
if (type === "SEQUENCE") return "sequence";
if (type === "PACKAGE_BODY") return "package-body";
if (type === "PACKAGE") return "package";
@@ -142,6 +143,7 @@ export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type
if (normalized.includes("TYPE_BODY")) return "TYPE_BODY";
if (normalized.includes("PACKAGE")) return "PACKAGE";
if (normalized.includes("TRIGGER")) return "TRIGGER";
+ if (normalized.includes("EVENT")) return "EVENT";
if (normalized.includes("TYPE")) return "TYPE";
if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW";
if (value.includes("VIEW")) return "VIEW";
@@ -250,6 +252,7 @@ export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): Object
procedures: 0,
functions: 0,
triggers: 0,
+ events: 0,
sequences: 0,
packages: 0,
types: 0,
@@ -262,6 +265,7 @@ export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): Object
else if (row.type === "PROCEDURE") counts.procedures++;
else if (row.type === "FUNCTION") counts.functions++;
else if (row.type === "TRIGGER") counts.triggers++;
+ else if (row.type === "EVENT") counts.events++;
else if (row.type === "SEQUENCE") counts.sequences++;
else if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") counts.packages++;
else if (row.type === "TYPE" || row.type === "TYPE_BODY") counts.types++;
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 3ddb972b19..be8bec25c4 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -1879,10 +1879,19 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
- function openObjectBrowser(connectionId: string, database: string, schema?: string, catalog?: string) {
+ function openObjectBrowser(connectionId: string, database: string, schema?: string, catalog?: string, eventName?: string, eventReadOnly = false, initialObjectFilter?: "tables" | "events") {
const title = catalog ? `${catalog}.${database} objects` : schema ? `${schema} objects` : `${database} objects`;
const existing = tabs.value.find((tab) => tab.mode === "objects" && tab.connectionId === connectionId && tab.database === database && (tab.objectBrowser?.catalog || "") === (catalog || "") && (tab.objectBrowser?.schema || "") === (schema || ""));
if (existing) {
+ if (eventName) {
+ existing.objectBrowser = {
+ ...existing.objectBrowser,
+ eventName,
+ eventReadOnly,
+ initialObjectFilter: initialObjectFilter ?? (eventName ? "events" : existing.objectBrowser?.initialObjectFilter),
+ eventOpenRequestId: (existing.objectBrowser?.eventOpenRequestId ?? 0) + 1,
+ };
+ }
switchTab(existing.id);
return existing.id;
}
@@ -1903,6 +1912,10 @@ export const useQueryStore = defineStore("query", () => {
catalog,
schema,
objectType: "tables",
+ eventName,
+ eventReadOnly,
+ initialObjectFilter: initialObjectFilter ?? (eventName ? "events" : undefined),
+ eventOpenRequestId: eventName ? 1 : undefined,
},
};
tabs.value.push(tab);
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index 8376b15f1b..038434fb8f 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -443,7 +443,7 @@ export interface TableInfo {
parent_name?: string | null;
}
-export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
+export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
export interface ObjectInfo {
name: string;
@@ -482,6 +482,28 @@ export interface ObjectSource {
editable?: boolean;
}
+export interface MysqlEventInfo {
+ name: string;
+ schema: string;
+ definer?: string | null;
+ time_zone?: string | null;
+ event_type?: string | null;
+ execute_at?: string | null;
+ interval_value?: string | null;
+ interval_field?: string | null;
+ starts?: string | null;
+ ends?: string | null;
+ status?: string | null;
+ on_completion?: string | null;
+ comment?: string | null;
+ event_body?: string | null;
+ event_definition?: string | null;
+ created_at?: string | null;
+ updated_at?: string | null;
+ last_executed?: string | null;
+ source?: string | null;
+}
+
export type CustomTypeKind = "base" | "composite" | "domain" | "enum" | "range" | "multirange";
export interface CustomTypeMember {
@@ -1244,6 +1266,10 @@ export interface QueryTab {
catalog?: string;
schema?: string;
objectType?: "tables";
+ eventName?: string;
+ eventReadOnly?: boolean;
+ eventOpenRequestId?: number;
+ initialObjectFilter?: "tables" | "events";
viewport?: ObjectBrowserViewport;
};
objectSource?: {
diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs
index 755c9eb90e..6aa43aa043 100644
--- a/crates/dbx-core/src/db/mod.rs
+++ b/crates/dbx-core/src/db/mod.rs
@@ -58,6 +58,7 @@ use std::future::Future;
use std::time::Duration;
// Re-export types so that `db::QueryResult` etc. work within dbx-core
+pub use crate::mysql_event_sql::MysqlEventInfo;
pub use crate::types::*;
pub use file_validator::validate_file_path;
diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs
index da15b10bea..aaf0d9b25c 100644
--- a/crates/dbx-core/src/db/mysql.rs
+++ b/crates/dbx-core/src/db/mysql.rs
@@ -32,6 +32,7 @@ use crate::types::{
};
use super::file_validator::validate_file_path;
+use crate::mysql_event_sql::MysqlEventInfo;
/// DBX-owned MySQL pool handle. The driver does not expose its configured
/// maximum, so retain that value beside the driver pool for checkout phase
@@ -70,6 +71,44 @@ pub trait MySqlPoolAccess {
fn checkout_max_connections(&self) -> Option;
}
+pub async fn get_event_info(
+ pool: &P,
+ database: &str,
+ name: &str,
+) -> Result {
+ let mut conn = get_conn_with_health_check(pool).await?;
+ let sql = format!(
+ "SELECT EVENT_SCHEMA, EVENT_NAME, DEFINER, TIME_ZONE, EVENT_TYPE, EXECUTE_AT, INTERVAL_VALUE, INTERVAL_FIELD, STARTS, ENDS, STATUS, ON_COMPLETION, EVENT_COMMENT, EVENT_DEFINITION, CREATED, LAST_ALTERED, LAST_EXECUTED FROM information_schema.EVENTS WHERE EVENT_SCHEMA = {} AND EVENT_NAME = {} LIMIT 1",
+ quote_value(database), quote_value(name)
+ );
+ let row = conn
+ .query_first::(sql)
+ .await
+ .map_err(|e| e.to_string())?
+ .ok_or_else(|| format!("MySQL event not found: {database}.{name}"))?;
+ Ok(MysqlEventInfo {
+ schema: get_str_by_name(&row, "EVENT_SCHEMA"),
+ name: get_str_by_name(&row, "EVENT_NAME"),
+ definer: get_opt_str(&row, "DEFINER"),
+ time_zone: get_opt_str(&row, "TIME_ZONE"),
+ event_type: get_opt_str(&row, "EVENT_TYPE"),
+ execute_at: get_opt_str(&row, "EXECUTE_AT"),
+ interval_value: get_opt_str(&row, "INTERVAL_VALUE"),
+ interval_field: get_opt_str(&row, "INTERVAL_FIELD"),
+ starts: get_opt_str(&row, "STARTS"),
+ ends: get_opt_str(&row, "ENDS"),
+ status: get_opt_str(&row, "STATUS"),
+ on_completion: get_opt_str(&row, "ON_COMPLETION"),
+ comment: get_opt_str(&row, "EVENT_COMMENT"),
+ event_body: get_opt_str(&row, "EVENT_DEFINITION"),
+ event_definition: get_opt_str(&row, "EVENT_DEFINITION"),
+ created_at: get_opt_str(&row, "CREATED"),
+ updated_at: get_opt_str(&row, "LAST_ALTERED"),
+ last_executed: get_opt_str(&row, "LAST_EXECUTED"),
+ source: None,
+ })
+}
+
impl MySqlPoolAccess for MySqlPool {
fn driver_pool(&self) -> &mysql_async::Pool {
&self.inner
diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs
index 546a77e02a..eece852b1c 100644
--- a/crates/dbx-core/src/db_admin_sql.rs
+++ b/crates/dbx-core/src/db_admin_sql.rs
@@ -14,6 +14,7 @@ pub enum DatabaseObjectType {
MaterializedView,
Procedure,
Function,
+ Event,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1106,6 +1107,7 @@ fn object_type_keyword(object_type: DatabaseObjectType) -> &'static str {
DatabaseObjectType::MaterializedView => "MATERIALIZED VIEW",
DatabaseObjectType::Procedure => "PROCEDURE",
DatabaseObjectType::Function => "FUNCTION",
+ DatabaseObjectType::Event => "EVENT",
}
}
diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs
index 851bd96e8e..8b98b5ac57 100644
--- a/crates/dbx-core/src/lib.rs
+++ b/crates/dbx-core/src/lib.rs
@@ -58,6 +58,7 @@ pub mod mq;
#[cfg(feature = "mq-admin")]
pub mod mqtt;
pub(crate) mod mysql_ddl_normalize;
+pub mod mysql_event_sql;
pub mod nacos;
#[cfg(all(target_os = "windows", target_env = "gnu"))]
mod nanosleep_stub;
diff --git a/crates/dbx-core/src/mysql_event_sql.rs b/crates/dbx-core/src/mysql_event_sql.rs
new file mode 100644
index 0000000000..8795adf01f
--- /dev/null
+++ b/crates/dbx-core/src/mysql_event_sql.rs
@@ -0,0 +1,171 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum MysqlEventSchedule {
+ At { execute_at: String },
+ Every { interval_value: String, interval_unit: String },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct MysqlEventDefinition {
+ pub name: String,
+ pub schema: Option,
+ pub schedule: MysqlEventSchedule,
+ pub starts: Option,
+ pub ends: Option,
+ pub on_completion_preserve: Option,
+ pub enabled: Option,
+ pub comment: Option,
+ pub body: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct MysqlEventInfo {
+ pub name: String,
+ pub schema: String,
+ pub definer: Option,
+ pub time_zone: Option,
+ pub event_type: Option,
+ pub execute_at: Option,
+ pub interval_value: Option,
+ pub interval_field: Option,
+ pub starts: Option,
+ pub ends: Option,
+ pub status: Option,
+ pub on_completion: Option,
+ pub comment: Option,
+ pub event_body: Option,
+ pub event_definition: Option,
+ pub created_at: Option,
+ pub updated_at: Option,
+ pub last_executed: Option,
+ pub source: Option,
+}
+
+fn ident(value: &str) -> Result {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err("event name must not be empty".into());
+ }
+ Ok(format!("`{}`", value.replace('`', "``")))
+}
+fn literal(value: &str) -> String {
+ format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
+}
+fn unit(value: &str) -> Result<&'static str, String> {
+ match value.trim().to_ascii_uppercase().as_str() {
+ "SECOND" => Ok("SECOND"),
+ "MINUTE" => Ok("MINUTE"),
+ "HOUR" => Ok("HOUR"),
+ "DAY" => Ok("DAY"),
+ "WEEK" => Ok("WEEK"),
+ "MONTH" => Ok("MONTH"),
+ "QUARTER" => Ok("QUARTER"),
+ "YEAR" => Ok("YEAR"),
+ _ => Err(format!("invalid event interval unit: {value}")),
+ }
+}
+pub fn create_event_sql(definition: &MysqlEventDefinition) -> Result {
+ build_event_sql("CREATE EVENT", definition)
+}
+pub fn alter_event_sql(definition: &MysqlEventDefinition) -> Result {
+ build_event_sql("ALTER EVENT", definition)
+}
+fn build_event_sql(prefix: &str, definition: &MysqlEventDefinition) -> Result {
+ let name = ident(&definition.name)?;
+ if definition.body.trim().is_empty() {
+ return Err("event body must not be empty".into());
+ }
+ let schedule = match &definition.schedule {
+ MysqlEventSchedule::At { execute_at } if execute_at.trim().is_empty() => {
+ return Err("event AT schedule must include execute_at".into())
+ }
+ MysqlEventSchedule::At { execute_at } => format!("AT {}", literal(execute_at)),
+ MysqlEventSchedule::Every { interval_value, interval_unit } => {
+ let interval_value = interval_value.trim();
+ if !interval_value.bytes().all(|byte| byte.is_ascii_digit())
+ || interval_value.bytes().all(|byte| byte == b'0')
+ {
+ return Err("event EVERY schedule requires a positive integer interval_value".into());
+ }
+ format!("EVERY {} {}", interval_value, unit(interval_unit)?)
+ }
+ };
+ let mut sql = format!("{prefix} {name} ON SCHEDULE {schedule}");
+ if let Some(value) = definition.starts.as_deref().filter(|v| !v.trim().is_empty()) {
+ sql.push_str(&format!(" STARTS {}", literal(value)));
+ }
+ if let Some(value) = definition.ends.as_deref().filter(|v| !v.trim().is_empty()) {
+ sql.push_str(&format!(" ENDS {}", literal(value)));
+ }
+ if let Some(value) = definition.on_completion_preserve {
+ sql.push_str(if value { " ON COMPLETION PRESERVE" } else { " ON COMPLETION NOT PRESERVE" });
+ }
+ if let Some(value) = definition.enabled {
+ sql.push_str(if value { " ENABLE" } else { " DISABLE" });
+ }
+ if let Some(value) = definition.comment.as_deref() {
+ sql.push_str(&format!(" COMMENT {}", literal(value)));
+ }
+ sql.push_str(" DO ");
+ sql.push_str(definition.body.trim());
+ Ok(sql)
+}
+pub fn drop_event_sql(schema: Option<&str>, name: &str, if_exists: bool) -> Result {
+ let qualified = match schema.filter(|s| !s.trim().is_empty()) {
+ Some(schema) => format!("{}.{}", ident(schema)?, ident(name)?),
+ None => ident(name)?,
+ };
+ Ok(format!("DROP EVENT {}{}", if if_exists { "IF EXISTS " } else { "" }, qualified))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn definition() -> MysqlEventDefinition {
+ MysqlEventDefinition {
+ name: "daily`sync".into(),
+ schema: Some("shop".into()),
+ schedule: MysqlEventSchedule::Every { interval_value: "1".into(), interval_unit: "day".into() },
+ starts: Some("2026-01-01 00:00:00".into()),
+ ends: None,
+ on_completion_preserve: Some(true),
+ enabled: Some(false),
+ comment: Some("owner's job".into()),
+ body: "CALL `refresh`();".into(),
+ }
+ }
+ #[test]
+ fn builds_every_event_with_escaped_values() {
+ assert_eq!(create_event_sql(&definition()).unwrap(), "CREATE EVENT `daily``sync` ON SCHEDULE EVERY 1 DAY STARTS '2026-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE COMMENT 'owner\\'s job' DO CALL `refresh`();");
+ }
+ #[test]
+ fn validates_schedule_and_body() {
+ let mut def = definition();
+ def.body = " ".into();
+ assert!(create_event_sql(&def).is_err());
+ def.body = "SELECT 1".into();
+ def.schedule = MysqlEventSchedule::Every { interval_value: "1".into(), interval_unit: "fortnight".into() };
+ assert!(create_event_sql(&def).is_err());
+ }
+ #[test]
+ fn rejects_non_positive_or_non_numeric_interval_values() {
+ let mut def = definition();
+ for interval_value in ["", "0", " 000 ", "1 DAY", "1; DROP EVENT other"] {
+ def.schedule =
+ MysqlEventSchedule::Every { interval_value: interval_value.into(), interval_unit: "DAY".into() };
+ assert!(create_event_sql(&def).is_err(), "unexpectedly accepted {interval_value:?}");
+ }
+ }
+ #[test]
+ fn builds_at_and_drop_sql() {
+ let mut def = definition();
+ def.schedule = MysqlEventSchedule::At { execute_at: "2026-02-01 10:00:00".into() };
+ assert!(create_event_sql(&def).unwrap().contains("ON SCHEDULE AT '2026-02-01 10:00:00'"));
+ assert_eq!(
+ drop_event_sql(Some("shop"), "daily`sync", true).unwrap(),
+ "DROP EVENT IF EXISTS `shop`.`daily``sync`"
+ );
+ }
+}
diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs
index 0393e5ee90..48088b4896 100644
--- a/crates/dbx-core/src/schema.rs
+++ b/crates/dbx-core/src/schema.rs
@@ -8347,6 +8347,59 @@ pub async fn get_object_source_core(
Ok(finalize_object_source(source))
}
+pub async fn get_event_info_core(
+ state: &AppState,
+ connection_id: &str,
+ database: &str,
+ _schema: &str,
+ name: &str,
+) -> Result {
+ let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?;
+ let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?;
+ match pool {
+ PoolKind::Mysql(pool, _) => db::mysql::get_event_info(&pool, database, name).await,
+ PoolKind::ExternalDriver { config, session, .. } => {
+ let quote = |value: &str| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"));
+ let sql = format!("SELECT EVENT_SCHEMA, EVENT_NAME, DEFINER, TIME_ZONE, EVENT_TYPE, EXECUTE_AT, INTERVAL_VALUE, INTERVAL_FIELD, STARTS, ENDS, STATUS, ON_COMPLETION, EVENT_COMMENT, EVENT_DEFINITION, CREATED, LAST_ALTERED, LAST_EXECUTED FROM information_schema.EVENTS WHERE EVENT_SCHEMA = {} AND EVENT_NAME = {} LIMIT 1", quote(database), quote(name));
+ let result: db::QueryResult = session.invoke_with_timeout("executeQuery", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": _schema, "sql": sql, "maxRows": 1 }), agent_metadata_timeout(Some(&config))).await?;
+ let row = result.rows.first().ok_or_else(|| format!("MySQL event not found: {database}.{name}"))?;
+ let text = |column: &str| {
+ result.columns.iter().position(|c| c.eq_ignore_ascii_case(column)).and_then(|i| row.get(i)).and_then(
+ |v| {
+ if v.is_null() {
+ None
+ } else {
+ Some(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))
+ }
+ },
+ )
+ };
+ Ok(db::MysqlEventInfo {
+ name: text("EVENT_NAME").unwrap_or_else(|| name.to_string()),
+ schema: text("EVENT_SCHEMA").unwrap_or_else(|| database.to_string()),
+ definer: text("DEFINER"),
+ time_zone: text("TIME_ZONE"),
+ event_type: text("EVENT_TYPE"),
+ execute_at: text("EXECUTE_AT"),
+ interval_value: text("INTERVAL_VALUE"),
+ interval_field: text("INTERVAL_FIELD"),
+ starts: text("STARTS"),
+ ends: text("ENDS"),
+ status: text("STATUS"),
+ on_completion: text("ON_COMPLETION"),
+ comment: text("EVENT_COMMENT"),
+ event_body: text("EVENT_DEFINITION"),
+ event_definition: text("EVENT_DEFINITION"),
+ created_at: text("CREATED"),
+ updated_at: text("LAST_ALTERED"),
+ last_executed: text("LAST_EXECUTED"),
+ source: None,
+ })
+ }
+ _ => Err("MySQL event details are only supported for MySQL connections".into()),
+ }
+}
+
fn finalize_object_source(mut source: db::ObjectSource) -> db::ObjectSource {
if matches!(source.object_type, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function) {
source.source = normalize_routine_object_source(source.source);
diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs
index 02e4925071..438eb056e8 100644
--- a/crates/dbx-core/src/types.rs
+++ b/crates/dbx-core/src/types.rs
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
+pub use crate::mysql_event_sql::MysqlEventInfo;
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DatabaseInfo {
pub name: String,
diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs
index eec52137fd..5665733627 100644
--- a/crates/dbx-web/src/main.rs
+++ b/crates/dbx-web/src/main.rs
@@ -418,6 +418,7 @@ async fn main() {
.route("/schema/completion-objects", get(routes::schema::list_completion_objects))
.route("/schema/completion-assistant", post(routes::schema::completion_assistant_search))
.route("/schema/object-source", get(routes::schema::get_object_source))
+ .route("/schema/event-info", get(routes::schema::get_event_info))
.route("/schema/custom-type-details", get(routes::schema::get_custom_type_details))
.route("/schema/columns", get(routes::schema::list_columns))
.route("/schema/all-columns", get(routes::schema::get_all_columns))
diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs
index 113057d378..bc217c130c 100644
--- a/crates/dbx-web/src/routes/schema.rs
+++ b/crates/dbx-web/src/routes/schema.rs
@@ -351,6 +351,22 @@ pub async fn get_object_source(
Ok(Json(result))
}
+pub async fn get_event_info(
+ State(state): State>,
+ Query(q): Query,
+) -> Result, AppError> {
+ let result = dbx_core::schema::get_event_info_core(
+ &state.app,
+ &q.connection_id,
+ q.database.as_deref().unwrap_or(""),
+ q.schema.as_deref().unwrap_or(""),
+ q.table.as_deref().unwrap_or(""),
+ )
+ .await
+ .map_err(AppError::from)?;
+ Ok(Json(result))
+}
+
pub async fn get_custom_type_details(
State(state): State>,
Query(q): Query,
diff --git a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java
index 100192c5d6..e1e3b729a9 100644
--- a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java
+++ b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java
@@ -1921,9 +1921,41 @@ private static JsonNode listObjects(
}
}
+ if ((allowedObjectTypes.isEmpty() || allowedObjectTypes.contains("EVENT")) && isMysqlFamilyConnection(connection)) {
+ appendMysqlEvents(result, conn, database, schema);
+ }
+
return filterMetadataNodes(result, filter, limit, offset, objectTypes, "object_type", false);
}
+ private static boolean isMysqlFamilyConnection(JsonNode connection) {
+ String url = jdbcUrl(connection);
+ return urlMatchesPrefix(url, "jdbc:mysql:") || urlMatchesPrefix(url, "jdbc:mariadb:") || urlMatchesPrefix(url, "jdbc:tidb:");
+ }
+
+ private static void appendMysqlEvents(ArrayNode result, Connection conn, String database, String schema) {
+ String eventSchema = emptyToNull(schema) != null ? schema : database;
+ if (eventSchema == null || eventSchema.isBlank()) return;
+ String sql = "SELECT EVENT_NAME, EVENT_SCHEMA, EVENT_COMMENT, CREATED, LAST_ALTERED FROM information_schema.EVENTS WHERE EVENT_SCHEMA = ?";
+ try (PreparedStatement statement = conn.prepareStatement(sql)) {
+ statement.setString(1, eventSchema);
+ try (ResultSet rs = statement.executeQuery()) {
+ while (rs.next()) {
+ ObjectNode item = MAPPER.createObjectNode();
+ item.put("name", rs.getString("EVENT_NAME"));
+ item.put("object_type", "EVENT");
+ putNullable(item, "schema", rs.getString("EVENT_SCHEMA"));
+ putNullable(item, "comment", rs.getString("EVENT_COMMENT"));
+ putNullable(item, "created_at", rs.getString("CREATED"));
+ putNullable(item, "updated_at", rs.getString("LAST_ALTERED"));
+ result.add(item);
+ }
+ }
+ } catch (SQLException ignored) {
+ // Lack of EVENT privilege must not hide tables and routines.
+ }
+ }
+
private static JsonNode listDataTypes(JsonNode connection, String database) throws SQLException {
Connection conn = openConnection(connection);
JdbcDriverQuirks quirks = driverQuirks(connection);
diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs
index aaf1593fb8..c5cd4f39df 100644
--- a/src-tauri/src/commands/schema.rs
+++ b/src-tauri/src/commands/schema.rs
@@ -322,6 +322,17 @@ pub async fn get_object_source(
.await
}
+#[tauri::command]
+pub async fn get_event_info(
+ state: State<'_, Arc>,
+ connection_id: String,
+ database: String,
+ schema: String,
+ name: String,
+) -> Result {
+ dbx_core::schema::get_event_info_core(&state, &connection_id, &database, &schema, &name).await
+}
+
#[tauri::command]
pub async fn get_custom_type_details(
state: State<'_, Arc>,
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index d0e3812e8e..50a772dece 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1669,6 +1669,7 @@ pub fn run() {
commands::schema::list_completion_objects,
commands::schema::completion_assistant_search,
commands::schema::get_object_source,
+ commands::schema::get_event_info,
commands::schema::get_custom_type_details,
commands::schema::list_schemas,
commands::schema::list_schema_infos,