feat(mysql): manage and edit MySQL events

This commit is contained in:
miracle authored and GitHub committed 2026-08-23 12:23:46 +08:00
1 parent 701f188e45
commit d8bc5153d2
35 files changed
+981 -27

No files matched your search

@@ -2309,6 +2309,10 @@ defineExpose({
:database="activeTab.database" :database="activeTab.database"
:catalog="activeTab.objectBrowser?.catalog" :catalog="activeTab.objectBrowser?.catalog"
:schema="activeTab.objectBrowser?.schema" :schema="activeTab.objectBrowser?.schema"
:initial-event-name="activeTab.objectBrowser?.eventName"
:initial-event-read-only="activeTab.objectBrowser?.eventReadOnly"
:initial-event-open-request-id="activeTab.objectBrowser?.eventOpenRequestId"
:initial-object-filter="activeTab.objectBrowser?.initialObjectFilter"
:viewport="activeTab.objectBrowser?.viewport" :viewport="activeTab.objectBrowser?.viewport"
@open-table="emit('openObjectTable', $event)" @open-table="emit('openObjectTable', $event)"
@schema-change="emit('objectSchemaChange', $event)" @schema-change="emit('objectSchemaChange', $event)"
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { CalendarDateTime } from "@internationalized/date";
import { Check, Clock, Eye, Loader2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue";
import { formatLocalDateTime, parseLocalDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { buildMysqlEventSql } from "@/lib/table/mysqlEventSql";
import type { ConnectionConfig, MysqlEventInfo } from "@/types/database";
const props = defineProps<{ connection: ConnectionConfig; database: string; schema: string; name?: string; readOnly?: boolean }>();
const emit = defineEmits<{ saved: [name: string]; close: [] }>();
const { t, locale } = useI18n();
const loading = ref(false),
saving = ref(false),
error = ref(""),
preview = ref("");
const activeTab = ref<"definition" | "schedule" | "comment" | "preview">("definition");
const draft = ref<any>({ name: props.name || "", schema: props.schema, schedule: { mode: "every", intervalValue: "1", intervalUnit: "DAY" }, preserve: true, enabled: true, body: "" });
const existing = computed(() => !!props.name);
const units = ["SECOND", "MINUTE", "HOUR", "DAY", "WEEK", "MONTH", "QUARTER", "YEAR"];
const tabs = computed(
() =>
[
{ id: "definition", label: t("contextMenu.eventDefinition") },
{ id: "schedule", label: t("contextMenu.eventSchedule") },
{ id: "comment", label: t("contextMenu.eventComment") },
{ id: "preview", label: t("contextMenu.eventSqlPreview") },
] as const,
);
const executeAtDate = computed<CalendarDateTime | null>({
get: () => parseLocalDateTime(draft.value.schedule.executeAt || ""),
set: (value) => {
draft.value.schedule.executeAt = value ? formatLocalDateTime(value) : "";
refreshPreview();
},
});
const startsDate = computed<CalendarDateTime | null>({
get: () => parseLocalDateTime(draft.value.starts || ""),
set: (value) => {
draft.value.starts = value ? formatLocalDateTime(value) : "";
refreshPreview();
},
});
const endsDate = computed<CalendarDateTime | null>({
get: () => parseLocalDateTime(draft.value.ends || ""),
set: (value) => {
draft.value.ends = value ? formatLocalDateTime(value) : "";
refreshPreview();
},
});
function refreshPreview() {
try {
preview.value = buildMysqlEventSql(draft.value, existing.value ? "ALTER" : "CREATE");
error.value = "";
} catch (e) {
preview.value = "";
error.value = e instanceof Error ? e.message : String(e);
}
}
async function load() {
if (!props.name) {
refreshPreview();
return;
}
loading.value = true;
try {
const info: MysqlEventInfo = await api.getEventInfo(props.connection.id, props.database, props.schema, props.name);
draft.value = {
name: info.name,
schema: info.schema,
schedule: info.execute_at ? { mode: "at", executeAt: info.execute_at } : { mode: "every", intervalValue: info.interval_value || "1", intervalUnit: info.interval_field || "DAY" },
starts: info.starts || "",
ends: info.ends || "",
preserve: info.on_completion?.toUpperCase() === "PRESERVE",
enabled: info.status?.toUpperCase() === "ENABLED",
comment: info.comment || "",
body: info.event_definition || info.event_body || "",
};
refreshPreview();
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
async function save() {
if (props.readOnly) return;
refreshPreview();
if (!preview.value) return;
saving.value = true;
try {
await executeWithProductionSqlGuard({
connection: props.connection,
database: props.database,
sql: preview.value,
source: t("contextMenu.eventEditorTitle"),
execute: async () => {
await api.executeQuery(props.connection.id, props.database, preview.value, props.schema);
return true;
},
});
emit("saved", draft.value.name.trim());
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-4" data-mysql-event-editor>
<div class="flex items-center justify-between border-b pb-2">
<div class="flex items-center gap-2 text-sm font-semibold"><Clock class="h-4 w-4 text-orange-300" /> {{ t("contextMenu.eventEditorTitle") }}</div>
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="t('contextMenu.eventCancel')" @click="emit('close')"><X class="h-4 w-4" /></Button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" /> {{ t("contextMenu.eventLoading") }}</div>
<template v-else>
<div class="flex gap-1 border-b" role="tablist">
<button v-for="tab in tabs" :key="tab.id" type="button" class="border-b-2 px-3 py-1.5 text-xs" :class="activeTab === tab.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'" @click="activeTab = tab.id">{{ tab.label }}</button>
</div>
<div v-if="activeTab === 'definition'" class="flex flex-col gap-3">
<label class="text-xs">{{ t("contextMenu.eventName") }}<Input v-model="draft.name" :disabled="props.readOnly || existing" class="mt-1 h-8" @input="refreshPreview" /></label
><textarea v-model="draft.body" :disabled="props.readOnly" class="min-h-40 rounded border bg-background p-2 font-mono text-xs" :placeholder="t('contextMenu.eventBodyPlaceholder')" @input="refreshPreview" />
</div>
<div v-else-if="activeTab === 'schedule'" class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-2">
<label class="text-xs"
>{{ t("contextMenu.eventSchedule")
}}<select v-model="draft.schedule.mode" :disabled="props.readOnly" class="mt-1 h-8 w-full rounded border bg-background px-2 text-xs" @change="refreshPreview">
<option value="every">EVERY</option>
<option value="at">AT</option>
</select></label
><label v-if="draft.schedule.mode === 'every'" class="text-xs"
>{{ t("contextMenu.eventIntervalUnit")
}}<select v-model="draft.schedule.intervalUnit" :disabled="props.readOnly" class="mt-1 h-8 w-full rounded border bg-background px-2 text-xs" @change="refreshPreview">
<option v-for="unit in units" :key="unit" :value="unit">{{ unit }}</option>
</select></label
>
</div>
<label class="text-xs"
>{{ draft.schedule.mode === "at" ? t("contextMenu.eventExecuteAt") : t("contextMenu.eventIntervalValue")
}}<DateTimePicker v-if="draft.schedule.mode === 'at'" v-model="executeAtDate" class="mt-1" :locale="locale" :placeholder="t('dateTimePicker.inputPlaceholder')" :disabled="props.readOnly" full-width /><Input
v-else
v-model="draft.schedule.intervalValue"
:disabled="props.readOnly"
class="mt-1 h-8"
placeholder="1"
@input="refreshPreview"
/></label>
<div class="grid grid-cols-2 gap-2">
<label class="text-xs">{{ t("contextMenu.eventStarts") }}<DateTimePicker v-model="startsDate" class="mt-1" :locale="locale" :placeholder="t('dateTimePicker.inputPlaceholder')" :disabled="props.readOnly" full-width /></label
><label class="text-xs">{{ t("contextMenu.eventEnds") }}<DateTimePicker v-model="endsDate" class="mt-1" :locale="locale" :placeholder="t('dateTimePicker.inputPlaceholder')" :disabled="props.readOnly" full-width /></label>
</div>
<div class="grid grid-cols-2 gap-2">
<label class="text-xs"
>{{ t("contextMenu.eventCompletion")
}}<select v-model="draft.preserve" :disabled="props.readOnly" class="mt-1 h-8 w-full rounded border bg-background px-2 text-xs" @change="refreshPreview">
<option :value="true">PRESERVE</option>
<option :value="false">NOT PRESERVE</option>
</select></label
><label class="text-xs"
>{{ t("contextMenu.eventStatus")
}}<select v-model="draft.enabled" :disabled="props.readOnly" class="mt-1 h-8 w-full rounded border bg-background px-2 text-xs" @change="refreshPreview">
<option :value="true">ENABLE</option>
<option :value="false">DISABLE</option>
</select></label
>
</div>
</div>
<div v-else-if="activeTab === 'comment'">
<label class="text-xs">{{ t("contextMenu.eventComment") }}<Input v-model="draft.comment" :disabled="props.readOnly" class="mt-1 h-8" :placeholder="t('contextMenu.eventCommentPlaceholder')" @input="refreshPreview" /></label>
</div>
<div v-else class="flex min-h-0 flex-1 flex-col gap-2">
<div class="flex items-center gap-2 text-xs font-medium"><Eye class="h-3.5 w-3.5" /> {{ t("contextMenu.eventSqlPreview") }}</div>
<pre class="min-h-48 flex-1 overflow-auto rounded border bg-muted/30 p-2 text-xs whitespace-pre-wrap">{{ preview }}</pre>
</div>
<div v-if="error" class="whitespace-pre-wrap text-xs text-destructive">{{ error }}</div>
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" @click="emit('close')">{{ t("contextMenu.eventCancel") }}</Button
><Button v-if="!props.readOnly" size="sm" :disabled="saving || !preview" @click="save"><Loader2 v-if="saving" class="mr-1 h-3.5 w-3.5 animate-spin" /><Check v-else class="mr-1 h-3.5 w-3.5" />{{ t("contextMenu.eventSave") }}</Button>
</div>
</template>
</div>
</template>
@@ -9,6 +9,7 @@ import {
ArrowUp, ArrowUp,
Braces, Braces,
CheckSquare, CheckSquare,
Clock,
Clipboard, Clipboard,
Code2, Code2,
Copy, Copy,
@@ -95,6 +96,7 @@ import { useExportTracker, type ExportTask } from "@/composables/useExportTracke
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { useQueryStore } from "@/stores/queryStore"; import { useQueryStore } from "@/stores/queryStore";
import QueryEditor from "@/components/editor/QueryEditor.vue"; import QueryEditor from "@/components/editor/QueryEditor.vue";
import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
@@ -134,6 +136,9 @@ import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCach
import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard"; import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard";
import { loadObjectDdl, type ObjectDdlRequest } from "@/lib/metadata/objectDdlCache"; import { loadObjectDdl, type ObjectDdlRequest } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache";
import { invalidateObjectDdl } from "@/lib/metadata/objectDdlCache";
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
type ObjectFilter = ObjectBrowserFilter; type ObjectFilter = ObjectBrowserFilter;
type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment"; type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
@@ -143,6 +148,10 @@ const props = defineProps<{
database: string; database: string;
catalog?: string; catalog?: string;
schema?: string; schema?: string;
initialEventName?: string;
initialEventReadOnly?: boolean;
initialEventOpenRequestId?: number;
initialObjectFilter?: "tables" | "events";
viewport?: ObjectBrowserViewport; viewport?: ObjectBrowserViewport;
}>(); }>();
@@ -183,7 +192,9 @@ const sourceCanEdit = ref(true);
// --- Right-side panel state --- // --- Right-side panel state ---
// Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.) // Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.)
const sidePanelRow = ref<ObjectBrowserRow | null>(null); const sidePanelRow = ref<ObjectBrowserRow | null>(null);
const sidePanelMode = ref<"table-info" | "source" | "type-info">("source"); const openedInitialEvent = ref("");
const isEventEditor = computed(() => sidePanelMode.value === "event-editor");
const sidePanelMode = ref<"table-info" | "source" | "type-info" | "event-editor">("source");
// Table info panel state // Table info panel state
const tableInfoTab = ref<TableInfoTab>("ddl"); const tableInfoTab = ref<TableInfoTab>("ddl");
const tableColumns = ref<ColumnInfo[]>([]); const tableColumns = ref<ColumnInfo[]>([]);
@@ -322,6 +333,7 @@ const objectFilters = computed<ObjectFilter[]>(() =>
["procedures", objectCounts.value.procedures], ["procedures", objectCounts.value.procedures],
["functions", objectCounts.value.functions], ["functions", objectCounts.value.functions],
["triggers", objectCounts.value.triggers], ["triggers", objectCounts.value.triggers],
["events", objectCounts.value.events],
["sequences", objectCounts.value.sequences], ["sequences", objectCounts.value.sequences],
["packages", objectCounts.value.packages], ["packages", objectCounts.value.packages],
["types", objectCounts.value.types], ["types", objectCounts.value.types],
@@ -459,6 +471,7 @@ watch(objectFilter, () => {
} }
scrollObjectsToTop(); scrollObjectsToTop();
}); });
watch([() => props.initialEventName, () => props.initialEventOpenRequestId, rows, loadingObjects], openInitialEventIfNeeded, { flush: "post" });
watch( watch(
() => props.connection.show_system_schemas, () => props.connection.show_system_schemas,
(value, oldValue) => { (value, oldValue) => {
@@ -606,6 +619,7 @@ function iconFor(row: ObjectBrowserRow) {
if (row.type === "PROCEDURE") return ScrollText; if (row.type === "PROCEDURE") return ScrollText;
if (row.type === "FUNCTION") return Braces; if (row.type === "FUNCTION") return Braces;
if (row.type === "TRIGGER") return RotateCcw; if (row.type === "TRIGGER") return RotateCcw;
if (row.type === "EVENT") return Clock;
if (row.type === "SEQUENCE") return ListTree; if (row.type === "SEQUENCE") return ListTree;
if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") return Package; if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") return Package;
if (row.type === "TYPE" || row.type === "TYPE_BODY") return Braces; if (row.type === "TYPE" || row.type === "TYPE_BODY") return Braces;
@@ -618,6 +632,7 @@ function typeLabel(type: ObjectBrowserRow["type"]) {
if (type === "PROCEDURE") return t("objects.procedure"); if (type === "PROCEDURE") return t("objects.procedure");
if (type === "FUNCTION") return t("objects.function"); if (type === "FUNCTION") return t("objects.function");
if (type === "TRIGGER") return t("objects.trigger"); if (type === "TRIGGER") return t("objects.trigger");
if (type === "EVENT") return t("tree.events");
if (type === "SEQUENCE") return t("objects.sequence"); if (type === "SEQUENCE") return t("objects.sequence");
if (type === "PACKAGE") return t("objects.package"); if (type === "PACKAGE") return t("objects.package");
if (type === "PACKAGE_BODY") return t("objects.packageBody"); if (type === "PACKAGE_BODY") return t("objects.packageBody");
@@ -721,6 +736,7 @@ function rowMatchesFilter(row: ObjectBrowserRow, filter: ObjectFilter) {
if (filter === "procedures") return row.type === "PROCEDURE"; if (filter === "procedures") return row.type === "PROCEDURE";
if (filter === "functions") return row.type === "FUNCTION"; if (filter === "functions") return row.type === "FUNCTION";
if (filter === "triggers") return row.type === "TRIGGER"; if (filter === "triggers") return row.type === "TRIGGER";
if (filter === "events") return row.type === "EVENT";
if (filter === "sequences") return row.type === "SEQUENCE"; if (filter === "sequences") return row.type === "SEQUENCE";
if (filter === "packages") return row.type === "PACKAGE" || row.type === "PACKAGE_BODY"; if (filter === "packages") return row.type === "PACKAGE" || row.type === "PACKAGE_BODY";
if (filter === "types") return row.type === "TYPE" || row.type === "TYPE_BODY"; if (filter === "types") return row.type === "TYPE" || row.type === "TYPE_BODY";
@@ -814,6 +830,7 @@ function iconClass(type: ObjectBrowserRow["type"]) {
if (type === "PROCEDURE") return "text-blue-500"; if (type === "PROCEDURE") return "text-blue-500";
if (type === "FUNCTION") return "text-amber-500"; if (type === "FUNCTION") return "text-amber-500";
if (type === "TRIGGER") return "text-rose-500"; if (type === "TRIGGER") return "text-rose-500";
if (type === "EVENT") return "text-orange-500";
if (type === "SEQUENCE") return "text-emerald-500"; if (type === "SEQUENCE") return "text-emerald-500";
if (type === "PACKAGE" || type === "PACKAGE_BODY") return "text-cyan-500"; if (type === "PACKAGE" || type === "PACKAGE_BODY") return "text-cyan-500";
if (type === "TYPE" || type === "TYPE_BODY") return "text-violet-500"; if (type === "TYPE" || type === "TYPE_BODY") return "text-violet-500";
@@ -825,6 +842,7 @@ function iconBgClass(type: ObjectBrowserRow["type"]) {
if (type === "PROCEDURE") return "object-browser-icon-bg object-browser-icon-bg-procedure"; if (type === "PROCEDURE") return "object-browser-icon-bg object-browser-icon-bg-procedure";
if (type === "FUNCTION") return "object-browser-icon-bg object-browser-icon-bg-function"; if (type === "FUNCTION") return "object-browser-icon-bg object-browser-icon-bg-function";
if (type === "TRIGGER") return "object-browser-icon-bg object-browser-icon-bg-procedure"; if (type === "TRIGGER") return "object-browser-icon-bg object-browser-icon-bg-procedure";
if (type === "EVENT") return "object-browser-icon-bg object-browser-icon-bg-procedure";
if (type === "SEQUENCE") return "object-browser-icon-bg object-browser-icon-bg-sequence"; if (type === "SEQUENCE") return "object-browser-icon-bg object-browser-icon-bg-sequence";
if (type === "PACKAGE" || type === "PACKAGE_BODY") return "object-browser-icon-bg object-browser-icon-bg-package"; if (type === "PACKAGE" || type === "PACKAGE_BODY") return "object-browser-icon-bg object-browser-icon-bg-package";
if (type === "TYPE" || type === "TYPE_BODY") return "object-browser-icon-bg object-browser-icon-bg-function"; if (type === "TYPE" || type === "TYPE_BODY") return "object-browser-icon-bg object-browser-icon-bg-function";
@@ -867,7 +885,7 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog }); emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog });
break; break;
case "open-source": case "open-source":
void openSource(row); void (row.type === "EVENT" ? openEventEditor(row) : openSource(row));
break; break;
} }
} }
@@ -1301,6 +1319,29 @@ async function openSource(row: ObjectBrowserRow) {
} }
} }
function openEventEditor(row: ObjectBrowserRow) {
sidePanelGuard.start();
sidePanelRow.value = row;
sourceRow.value = null;
sidePanelMode.value = "event-editor";
}
async function onEventSaved(savedName: string) {
const row = sidePanelRow.value;
const name = savedName.trim() || row?.name || "";
if (name) {
const cacheSchema = row?.schema || selectedSchema.value || props.database;
const cacheScope = { connectionId: props.connection.id, database: props.database, schema: cacheSchema, tableName: name };
await Promise.all([invalidateObjectMetadataCache(cacheScope), invalidateObjectDdl(cacheScope)]);
invalidateObjectBrowserRowsCache({ connectionId: props.connection.id, database: props.database, schema: cacheSchema });
if (row && row.name !== name) {
sidePanelRow.value = { ...row, id: `event:${cacheSchema}:${name}`, name, displayName: name };
}
}
await loadObjects({ allowCached: false });
toast(t("objects.sourceSaved"));
}
async function openNewQuery(row: ObjectBrowserRow) { async function openNewQuery(row: ObjectBrowserRow) {
const schema = row.schema || selectedSchema.value; const schema = row.schema || selectedSchema.value;
const tabId = queryStore.createTab(props.connection.id, props.database, row.name, "query", schema, undefined, props.catalog); const tabId = queryStore.createTab(props.connection.id, props.database, row.name, "query", schema, undefined, props.catalog);
@@ -1466,8 +1507,11 @@ async function confirmDrop() {
const sql = dropPreviewSql.value || (await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value })); const sql = dropPreviewSql.value || (await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value }));
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql)); const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return; if (!executed) return;
const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess"; const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : row.type === "EVENT" ? "contextMenu.dropEventSuccess" : "contextMenu.dropTableSuccess";
toast(t(successKey, { name: row.name })); toast(t(successKey, { name: row.name }));
const cacheSchema = row.schema || selectedSchema.value || props.database;
await Promise.all([invalidateObjectMetadataCache({ connectionId: props.connection.id, database: props.database, schema: cacheSchema, tableName: row.name }), invalidateObjectDdl({ connectionId: props.connection.id, database: props.database, schema: cacheSchema, tableName: row.name })]);
invalidateObjectBrowserRowsCache({ connectionId: props.connection.id, database: props.database, schema: cacheSchema });
closeDroppedTableObjectTabsForRow(row); closeDroppedTableObjectTabsForRow(row);
removePinnedObjectBrowserRows([row]); removePinnedObjectBrowserRows([row]);
await reload(); await reload();
@@ -1513,6 +1557,7 @@ function dropConfirmTitle(): string {
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return t("contextMenu.confirmDropViewTitle"); if (type === "VIEW" || type === "MATERIALIZED_VIEW") return t("contextMenu.confirmDropViewTitle");
if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureTitle"); if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureTitle");
if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionTitle"); if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionTitle");
if (type === "EVENT") return t("contextMenu.confirmDropEventTitle");
return t("contextMenu.confirmDropTableTitle"); return t("contextMenu.confirmDropTableTitle");
} }
@@ -1523,6 +1568,7 @@ function dropConfirmMessage(): string {
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return t("contextMenu.confirmDropViewMessage", { name }); if (type === "VIEW" || type === "MATERIALIZED_VIEW") return t("contextMenu.confirmDropViewMessage", { name });
if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureMessage", { name }); if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureMessage", { name });
if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionMessage", { name }); if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionMessage", { name });
if (type === "EVENT") return t("contextMenu.confirmDropEventMessage", { name });
return t("contextMenu.confirmDropTableMessage", { name }); return t("contextMenu.confirmDropTableMessage", { name });
} }
@@ -2584,17 +2630,34 @@ function applyObjectBrowserRows(nextRows: ObjectBrowserRow[]) {
expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount))); expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount)));
} }
function openInitialEventIfNeeded() {
const name = props.initialEventName?.trim();
const requestKey = `${props.initialEventOpenRequestId ?? 0}:${name}`;
if (!name || openedInitialEvent.value === requestKey || loadingObjects.value) return;
const row = rows.value.find((candidate) => candidate.type === "EVENT" && candidate.name === name);
if (!row) return;
openedInitialEvent.value = requestKey;
openEventEditor(row);
}
function finishObjectBrowserRowsLoad() { function finishObjectBrowserRowsLoad() {
loadingObjects.value = false; loadingObjects.value = false;
if (!userHasSelectedFilter.value && objectCounts.value.tables > 0) { const preferredFilter = props.initialObjectFilter ?? (props.initialEventName ? "events" : "tables");
if (!userHasSelectedFilter.value && objectCounts.value[preferredFilter] > 0) {
// The default table filter is a presentation choice, not a user query // The default table filter is a presentation choice, not a user query
// change, so preserve the tab's saved scroll offset across remounts. // change, so preserve the tab's saved scroll offset across remounts.
preserveObjectFilterScrollOnce = objectFilter.value !== "tables"; preserveObjectFilterScrollOnce = objectFilter.value !== "tables";
objectFilter.value = "tables"; objectFilter.value = preferredFilter;
} }
openInitialEventIfNeeded();
restoreObjectBrowserViewport(); restoreObjectBrowserViewport();
} }
watch([() => props.initialEventName, () => props.initialEventOpenRequestId], ([name, requestId], [previousName, previousRequestId]) => {
if (name !== previousName || requestId !== previousRequestId) openedInitialEvent.value = "";
openInitialEventIfNeeded();
});
async function loadObjects(options?: { allowCached?: boolean }) { async function loadObjects(options?: { allowCached?: boolean }) {
error.value = ""; error.value = "";
const schema = needsSchema.value ? selectedSchema.value || "" : props.database; const schema = needsSchema.value ? selectedSchema.value || "" : props.database;
@@ -2704,13 +2767,15 @@ function filterLabel(filter: ObjectFilter) {
? "objects.functions" ? "objects.functions"
: filter === "triggers" : filter === "triggers"
? "tree.triggers" ? "tree.triggers"
: filter === "sequences" : filter === "events"
? "objects.sequences" ? "tree.events"
: filter === "packages" : filter === "sequences"
? "objects.packages" ? "objects.sequences"
: filter === "types" : filter === "packages"
? "tree.types" ? "objects.packages"
: "objects.all"; : filter === "types"
? "tree.types"
: "objects.all";
return `${t(key)} ${filterCount(filter)}`; return `${t(key)} ${filterCount(filter)}`;
} }
@@ -2930,6 +2995,17 @@ function getProcFuncMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
]; ];
} }
function getEventMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
return [
{ label: t("contextMenu.editObject"), action: () => openEventEditor(item), icon: PencilLine },
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
{ label: "", separator: true },
{ label: t("contextMenu.dropObject"), action: () => requestDrop(item), icon: Trash2, variant: "destructive" as const },
{ label: "", separator: true },
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
];
}
function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
return [ return [
...(effectiveDatabaseType.value === "xugu" && buildXuguCompileSql({ objectType: item.type, schema: item.schema || selectedSchema.value, name: item.name }) ? [{ label: t("contextMenu.compileObject"), action: () => compileXuguObject(item), icon: Wrench }] : []), ...(effectiveDatabaseType.value === "xugu" && buildXuguCompileSql({ objectType: item.type, schema: item.schema || selectedSchema.value, name: item.name }) ? [{ label: t("contextMenu.compileObject"), action: () => compileXuguObject(item), icon: Wrench }] : []),
@@ -2964,6 +3040,7 @@ function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (item.type === "TABLE") return getTableMenuItems(item); if (item.type === "TABLE") return getTableMenuItems(item);
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item); if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
if (item.type === "EVENT") return getEventMenuItems(item);
if (item.type === "TYPE" || item.type === "TYPE_BODY") return getTypeMenuItems(item); if (item.type === "TYPE" || item.type === "TYPE_BODY") return getTypeMenuItems(item);
if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item); if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item);
return getProcFuncMenuItems(item); return getProcFuncMenuItems(item);
@@ -2972,7 +3049,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<template> <template>
<div ref="rootRef" data-object-browser-root class="flex h-full min-h-0 min-w-0 flex-col bg-background outline-none" tabindex="0" @keydown="onObjectBrowserKeydown"> <div ref="rootRef" data-object-browser-root class="flex h-full min-h-0 min-w-0 flex-col bg-background outline-none" tabindex="0" @keydown="onObjectBrowserKeydown">
<div class="flex h-10 shrink-0 items-center gap-2 border-b px-3"> <div v-if="!isEventEditor" class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<span class="inline-flex max-w-[14rem] min-w-0 items-center rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium truncate" :title="selectedSchema || props.database"> <span class="inline-flex max-w-[14rem] min-w-0 items-center rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium truncate" :title="selectedSchema || props.database">
{{ selectedSchema || props.database }} {{ selectedSchema || props.database }}
@@ -3097,7 +3174,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<div v-else-if="filteredRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground"> <div v-else-if="filteredRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{{ t("objects.empty") }} {{ t("objects.empty") }}
</div> </div>
<div v-else class="flex min-h-0 min-w-0 flex-1"> <div v-else class="flex min-h-0 min-w-0 flex-1" :class="{ 'event-editor-layout': isEventEditor }">
<div class="flex min-h-0 min-w-0 flex-1 flex-col"> <div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div v-if="isListView" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden"> <div v-if="isListView" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
<div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${objectGridMinWidth}px` }"> <div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${objectGridMinWidth}px` }">
@@ -3463,6 +3540,9 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<template v-else-if="sidePanelMode === 'type-info'"> <template v-else-if="sidePanelMode === 'type-info'">
<CustomTypeInfoPanel ref="sidePanelRef" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name || ''" :catalog="props.catalog" @close="closeSidePanel" /> <CustomTypeInfoPanel ref="sidePanelRef" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name || ''" :catalog="props.catalog" @close="closeSidePanel" />
</template> </template>
<template v-else-if="sidePanelMode === 'event-editor'">
<MySqlEventEditor :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name" :read-only="props.initialEventReadOnly" @saved="onEventSaved" @close="closeSidePanel" />
</template>
<!-- Source mode (views, procedures, functions, sequences) --> <!-- Source mode (views, procedures, functions, sequences) -->
<template v-else> <template v-else>
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3"> <div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
@@ -3820,6 +3900,15 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
container-type: inline-size; 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 { .table-info-action-button {
gap: 0.25rem; gap: 0.25rem;
max-width: 8rem; max-width: 8rem;
@@ -28,6 +28,7 @@ import {
Pin, Pin,
ArrowRightLeft, ArrowRightLeft,
Download, Download,
Eye,
Upload, Upload,
FileCode, FileCode,
Network, Network,
@@ -966,6 +967,10 @@ function runRowClickAction(clickDetail: number, requestId: number) {
openMongoTreeData(node); openMongoTreeData(node);
return; return;
} }
if (node.type === "event") {
void openObjectBrowser();
return;
}
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation, currentDatabaseType(), settingsStore.editorSettings.sidebarOpenDatabaseOnSingleClick, canOpenObjectBrowser.value); 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 // WebKit can keep incrementing click.detail while the pointer moves quickly
// between adjacent rows. Nacos entries are idempotent navigation targets, so // between adjacent rows. Nacos entries are idempotent navigation targets, so
@@ -1323,6 +1328,14 @@ function requestDeleteSelectedNode(): boolean {
function onDoubleClick(event: MouseEvent) { function onDoubleClick(event: MouseEvent) {
if (dataTabOpenModeFromTreeClick(activeNode.value.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return; 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); const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType(), canOpenConnectionDatabaseBrowser.value, settingsStore.editorSettings.sidebarOpenDatabaseOnSingleClick);
if (action === "open-database-browser") { if (action === "open-database-browser") {
void openDatabaseBrowser(); void openDatabaseBrowser();
@@ -1471,7 +1484,7 @@ async function confirmDeleteSavedSqlFile() {
releaseActiveNodeReference([node.id]); releaseActiveNodeReference([node.id]);
} }
async function openObjectBrowser() { async function openObjectBrowser(eventReadOnly = false, openEventEditor = false) {
const node = activeNode.value; const node = activeNode.value;
if (!node.connectionId) return; if (!node.connectionId) return;
try { try {
@@ -1479,7 +1492,7 @@ async function openObjectBrowser() {
connectionStore.activeConnectionId = node.connectionId; connectionStore.activeConnectionId = node.connectionId;
if (hasTreeNodeDatabaseContext(node)) { 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; return;
} }
@@ -1488,7 +1501,7 @@ async function openObjectBrowser() {
const options = await getDatabaseOptions(node.connectionId); const options = await getDatabaseOptions(node.connectionId);
const database = resolveDefaultDatabase(connection, options); const database = resolveDefaultDatabase(connection, options);
if (database) { 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 { } else {
await toggle(); await toggle();
} }
@@ -2018,10 +2031,10 @@ function dropObjectSqlOptions(): DropObjectSqlOptions | null {
} }
function dropObjectSqlOptionsForNode(node: TreeNode): 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 { return {
databaseType: tableStructureDatabaseTypeForNode(node), 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, schema: node.schema,
name: node.objectName || node.label, name: node.objectName || node.label,
signature: node.signature, signature: node.signature,
@@ -2082,6 +2095,7 @@ function dropObjectMenuLabel(): string {
if (activeNode.value.type === "materialized_view") return t("contextMenu.dropView"); if (activeNode.value.type === "materialized_view") return t("contextMenu.dropView");
if (activeNode.value.type === "procedure") return t("contextMenu.dropProcedure"); if (activeNode.value.type === "procedure") return t("contextMenu.dropProcedure");
if (activeNode.value.type === "function") return t("contextMenu.dropFunction"); if (activeNode.value.type === "function") return t("contextMenu.dropFunction");
if (activeNode.value.type === "event") return t("contextMenu.dropObject");
return t("contextMenu.dropObject"); return t("contextMenu.dropObject");
} }
@@ -2243,7 +2257,7 @@ function requestDropTableChildObject() {
function canDropTreeNode(node: TreeNode): boolean { function canDropTreeNode(node: TreeNode): boolean {
if (isSqlServerLinkedNode(node)) return false; if (isSqlServerLinkedNode(node)) return false;
if (node.type === "table") return !!node.connectionId && !!node.database; 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); return !!node.connectionId && !!node.database && !!dropObjectSqlOptionsForNode(node);
} }
if (canDropMongoIndexNode(node)) return true; if (canDropMongoIndexNode(node)) return true;
@@ -2756,7 +2770,8 @@ async function confirmDropObject() {
await connectionStore.ensureConnected(node.connectionId); await connectionStore.ensureConnected(node.connectionId);
const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options)); const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options));
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema }); 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); toast(t(msgKey, { name: node.label }), 3000);
closeDroppedTableObjectTabsForNode(node); closeDroppedTableObjectTabsForNode(node);
// Refresh the parent object list so group badges and children stay in sync. // Refresh the parent object list so group badges and children stay in sync.
@@ -5549,7 +5564,18 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true; 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.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); 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 }); 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) { if (node.type === "group-views" && node.connectionId && node.database) {
items.push({ label: t("contextMenu.createView"), action: createView, icon: Plus }); 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) { if (mysqlObjectTemplate) {
items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus }); items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus });
} }
+24
View File
@@ -2924,12 +2924,34 @@ export default {
dropTableSuccess: 'Table "{name}" dropped', dropTableSuccess: 'Table "{name}" dropped',
editView: "Edit View", editView: "Edit View",
viewSource: "View Source", viewSource: "View Source",
viewObject: "View",
changeOpenMode: "Change open mode", changeOpenMode: "Change open mode",
viewDetails: "View Details", viewDetails: "View Details",
viewDdl: "View DDL", viewDdl: "View DDL",
viewDdlLoading: "Loading DDL...", viewDdlLoading: "Loading DDL...",
ddlCopied: "DDL copied", ddlCopied: "DDL copied",
dropObject: "Drop Object", 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", dropView: "Drop View",
dropColumn: "Drop Column", dropColumn: "Drop Column",
dropIndex: "Drop Index", dropIndex: "Drop Index",
@@ -3019,6 +3041,8 @@ export default {
confirmDropProcedureMessage: 'Are you sure you want to drop procedure "{name}"?', confirmDropProcedureMessage: 'Are you sure you want to drop procedure "{name}"?',
confirmDropFunctionTitle: "Drop Function", confirmDropFunctionTitle: "Drop Function",
confirmDropFunctionMessage: 'Are you sure you want to drop function "{name}"?', 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', dropViewSuccess: 'View "{name}" dropped',
dropProcedureSuccess: 'Procedure "{name}" dropped', dropProcedureSuccess: 'Procedure "{name}" dropped',
dropFunctionSuccess: 'Function "{name}" dropped', dropFunctionSuccess: 'Function "{name}" dropped',
+22
View File
@@ -3043,8 +3043,30 @@ export default withEnglishFallback({
createTrigger: "Nuevo disparador", createTrigger: "Nuevo disparador",
changeOpenMode: "Modificar modo de apertura", changeOpenMode: "Modificar modo de apertura",
closeRightTabs: "Cerrar pestañas a la derecha", 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", compileObject: "Compilar",
compileObjectSuccess: '"{name}" compilado con éxito', compileObjectSuccess: '"{name}" compilado con éxito',
dropEventSuccess: "Evento eliminado {name}",
editObject: "Editar objeto",
createEvent: "Crear evento",
}, },
visibleDatabases: { visibleDatabases: {
title: "Bases de datos visibles", title: "Bases de datos visibles",
+22
View File
@@ -3041,8 +3041,30 @@ export default withEnglishFallback({
createTrigger: "Nuovo trigger", createTrigger: "Nuovo trigger",
changeOpenMode: "Modifica modalità apertura", changeOpenMode: "Modifica modalità apertura",
closeRightTabs: "Chiudi le schede a destra", 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", compileObject: "Compila",
compileObjectSuccess: '"{name}" compilato con successo', compileObjectSuccess: '"{name}" compilato con successo',
dropEventSuccess: "Evento eliminato {name}",
editObject: "Modifica oggetto",
createEvent: "Nuovo evento",
}, },
visibleDatabases: { visibleDatabases: {
title: "Database Visibili", title: "Database Visibili",
+22
View File
@@ -3067,8 +3067,30 @@ export default withEnglishFallback({
createTrigger: "新規トリガー", createTrigger: "新規トリガー",
changeOpenMode: "開き方を変更", changeOpenMode: "開き方を変更",
closeRightTabs: "右側のタブを閉じる", 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: "コンパイル", compileObject: "コンパイル",
compileObjectSuccess: '"{name}" のコンパイルに成功しました', compileObjectSuccess: '"{name}" のコンパイルに成功しました',
dropEventSuccess: "イベントが削除されました {name}",
editObject: "オブジェクトを編集",
createEvent: "イベントを作成",
}, },
visibleDatabases: { visibleDatabases: {
title: "表示するデータベース", title: "表示するデータベース",
+19
View File
@@ -2924,6 +2924,25 @@ export default withEnglishFallback({
schemaCommentPlaceholder: "스키마 주석 입력...", schemaCommentPlaceholder: "스키마 주석 입력...",
schemaCommentSaving: "저장 중...", schemaCommentSaving: "저장 중...",
editSchemaCommentSuccess: '스키마 "{name}" 주석이 업데이트되었습니다', 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: { visibleDatabases: {
title: "표시할 데이터베이스", title: "표시할 데이터베이스",
+22
View File
@@ -3039,12 +3039,34 @@ export default withEnglishFallback({
createTrigger: "Novo gatilho", createTrigger: "Novo gatilho",
changeOpenMode: "Alterar modo de abertura", changeOpenMode: "Alterar modo de abertura",
closeRightTabs: "Fechar abas à direita", 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", compileObject: "Compilar",
compileObjectSuccess: '"{name}" compilado com sucesso', compileObjectSuccess: '"{name}" compilado com sucesso',
renameDatabase: "Renomear banco de dados", renameDatabase: "Renomear banco de dados",
renameDatabaseTitle: "Renomear banco de dados", renameDatabaseTitle: "Renomear banco de dados",
renameDatabaseNotOwner: "Somente o proprietário do banco de dados pode renomear «{name}».", 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.", 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: { visibleDatabases: {
title: "Bancos de dados visíveis", title: "Bancos de dados visíveis",
+22
View File
@@ -2848,6 +2848,7 @@ export default withEnglishFallback({
dropTableSuccess: "表「{name}」已删除", dropTableSuccess: "表「{name}」已删除",
editView: "编辑视图", editView: "编辑视图",
viewSource: "查看源码", viewSource: "查看源码",
viewObject: "查看",
changeOpenMode: "修改打开方式", changeOpenMode: "修改打开方式",
viewDetails: "查看详情", viewDetails: "查看详情",
viewDdl: "查看 DDL", viewDdl: "查看 DDL",
@@ -2858,6 +2859,27 @@ export default withEnglishFallback({
dropColumn: "删除字段", dropColumn: "删除字段",
dropIndex: "删除索引", dropIndex: "删除索引",
dropAllIndexes: "删除全部索引", 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: "新建索引", createMongoIndex: "新建索引",
createMongoIndexTitle: "创建索引:「{collection}」", createMongoIndexTitle: "创建索引:「{collection}」",
createMongoIndexFields: "索引字段", createMongoIndexFields: "索引字段",
+22
View File
@@ -3041,8 +3041,30 @@ export default withEnglishFallback({
createTrigger: "新增觸發器", createTrigger: "新增觸發器",
changeOpenMode: "修改開啟方式", changeOpenMode: "修改開啟方式",
closeRightTabs: "關閉右側標籤頁", 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: "編譯", compileObject: "編譯",
compileObjectSuccess: '"{name}" 編譯成功', compileObjectSuccess: '"{name}" 編譯成功',
dropEventSuccess: "事件已刪除 {name}",
editObject: "編輯物件",
createEvent: "新建事件",
}, },
visibleDatabases: { visibleDatabases: {
title: "顯示資料庫", title: "顯示資料庫",
+1
View File
@@ -166,6 +166,7 @@ export const listObjectStatistics = forward("listObjectStatistics");
export const listCompletionObjects = forward("listCompletionObjects"); export const listCompletionObjects = forward("listCompletionObjects");
export const completionAssistantSearch = forward("completionAssistantSearch"); export const completionAssistantSearch = forward("completionAssistantSearch");
export const getObjectSource = forward("getObjectSource"); export const getObjectSource = forward("getObjectSource");
export const getEventInfo = forward("getEventInfo");
export const getCustomTypeDetails = forward("getCustomTypeDetails"); export const getCustomTypeDetails = forward("getCustomTypeDetails");
export const getColumns = forward("getColumns"); export const getColumns = forward("getColumns");
export const getAllColumns = forward("getAllColumns"); export const getAllColumns = forward("getAllColumns");
+5
View File
@@ -17,6 +17,7 @@ import type {
CustomTypeDetails, CustomTypeDetails,
ObjectSource, ObjectSource,
ObjectSourceKind, ObjectSourceKind,
MysqlEventInfo,
ColumnInfo, ColumnInfo,
SqlServerColumnMetadata, SqlServerColumnMetadata,
IndexInfo, 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 })}`); 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<MysqlEventInfo> {
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<CustomTypeDetails> { export async function getCustomTypeDetails(connectionId: string, database: string, schema: string, name: string): Promise<CustomTypeDetails> {
return get(`/api/schema/custom-type-details?${qs({ connection_id: connectionId, database, schema, table: name })}`); return get(`/api/schema/custom-type-details?${qs({ connection_id: connectionId, database, schema, table: name })}`);
} }
+5
View File
@@ -33,6 +33,7 @@ import type {
CustomTypeDetails, CustomTypeDetails,
ObjectSource, ObjectSource,
ObjectSourceKind, ObjectSourceKind,
MysqlEventInfo,
ColumnInfo, ColumnInfo,
SqlServerColumnMetadata, SqlServerColumnMetadata,
IndexInfo, 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<MysqlEventInfo> {
return invoke("get_event_info", { connectionId, database, schema, name });
}
export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise<string[]> { export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise<string[]> {
return invoke("list_schemas", { connectionId, database, applyVisibleFilter }); return invoke("list_schemas", { connectionId, database, applyVisibleFilter });
} }
@@ -5,6 +5,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"index", "index",
"fkey", "fkey",
"trigger", "trigger",
"event",
"procedure", "procedure",
"function", "function",
"synonym", "synonym",
+19
View File
@@ -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");
}
});
});
@@ -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()}`;
}
@@ -17,6 +17,7 @@ export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" |
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction { export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none"; if (!row) return "none";
if (row.type === "TABLE") return "table-info"; if (row.type === "TABLE") return "table-info";
if (row.type === "EVENT") return "none";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info"; if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source"; if (canOpenSource(row, dbType)) return "open-source";
return "none"; return "none";
@@ -31,6 +32,7 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, d
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction { export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none"; if (!row) return "none";
if (row.type === "TABLE") return "open-table"; 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 (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source"; if (canOpenSource(row, dbType)) return "open-source";
return "none"; return "none";
@@ -83,5 +85,7 @@ function canOpenSource(row: ObjectBrowserRow, dbType?: DatabaseType): boolean {
// Xugu keeps its source editor entry through supportsTypeObjectSource below. // Xugu keeps its source editor entry through supportsTypeObjectSource below.
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return false; if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return false;
if ((row.type === "TYPE" || row.type === "TYPE_BODY") && !supportsTypeObjectSource(dbType)) 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"
);
} }
@@ -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);
});
});
@@ -8,7 +8,7 @@ export type ObjectBrowserRow = {
name: string; name: string;
displayName: string; displayName: string;
schema?: 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; valid?: boolean | null;
signature?: string | null; signature?: string | null;
comment?: 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 ObjectBrowserSortKey = "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
export type ObjectBrowserSortDirection = "asc" | "desc"; 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<ObjectBrowserFilter, number>; export type ObjectBrowserFilterCounts = Record<ObjectBrowserFilter, number>;
export type ObjectBrowserPinnedTreeNodeContext = { export type ObjectBrowserPinnedTreeNodeContext = {
@@ -42,6 +42,7 @@ export function objectBrowserRowTreeNodeType(type: ObjectBrowserRow["type"]): Tr
if (type === "PROCEDURE") return "procedure"; if (type === "PROCEDURE") return "procedure";
if (type === "FUNCTION") return "function"; if (type === "FUNCTION") return "function";
if (type === "TRIGGER") return "trigger"; if (type === "TRIGGER") return "trigger";
if (type === "EVENT") return "event";
if (type === "SEQUENCE") return "sequence"; if (type === "SEQUENCE") return "sequence";
if (type === "PACKAGE_BODY") return "package-body"; if (type === "PACKAGE_BODY") return "package-body";
if (type === "PACKAGE") return "package"; 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("TYPE_BODY")) return "TYPE_BODY";
if (normalized.includes("PACKAGE")) return "PACKAGE"; if (normalized.includes("PACKAGE")) return "PACKAGE";
if (normalized.includes("TRIGGER")) return "TRIGGER"; if (normalized.includes("TRIGGER")) return "TRIGGER";
if (normalized.includes("EVENT")) return "EVENT";
if (normalized.includes("TYPE")) return "TYPE"; if (normalized.includes("TYPE")) return "TYPE";
if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW"; if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW";
if (value.includes("VIEW")) return "VIEW"; if (value.includes("VIEW")) return "VIEW";
@@ -250,6 +252,7 @@ export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): Object
procedures: 0, procedures: 0,
functions: 0, functions: 0,
triggers: 0, triggers: 0,
events: 0,
sequences: 0, sequences: 0,
packages: 0, packages: 0,
types: 0, types: 0,
@@ -262,6 +265,7 @@ export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): Object
else if (row.type === "PROCEDURE") counts.procedures++; else if (row.type === "PROCEDURE") counts.procedures++;
else if (row.type === "FUNCTION") counts.functions++; else if (row.type === "FUNCTION") counts.functions++;
else if (row.type === "TRIGGER") counts.triggers++; 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 === "SEQUENCE") counts.sequences++;
else if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") counts.packages++; else if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") counts.packages++;
else if (row.type === "TYPE" || row.type === "TYPE_BODY") counts.types++; else if (row.type === "TYPE" || row.type === "TYPE_BODY") counts.types++;
+14 -1
View File
@@ -1879,10 +1879,19 @@ export const useQueryStore = defineStore("query", () => {
return id; 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 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 || "")); 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 (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); switchTab(existing.id);
return existing.id; return existing.id;
} }
@@ -1903,6 +1912,10 @@ export const useQueryStore = defineStore("query", () => {
catalog, catalog,
schema, schema,
objectType: "tables", objectType: "tables",
eventName,
eventReadOnly,
initialObjectFilter: initialObjectFilter ?? (eventName ? "events" : undefined),
eventOpenRequestId: eventName ? 1 : undefined,
}, },
}; };
tabs.value.push(tab); tabs.value.push(tab);
+27 -1
View File
@@ -443,7 +443,7 @@ export interface TableInfo {
parent_name?: string | null; 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 { export interface ObjectInfo {
name: string; name: string;
@@ -482,6 +482,28 @@ export interface ObjectSource {
editable?: boolean; 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 type CustomTypeKind = "base" | "composite" | "domain" | "enum" | "range" | "multirange";
export interface CustomTypeMember { export interface CustomTypeMember {
@@ -1244,6 +1266,10 @@ export interface QueryTab {
catalog?: string; catalog?: string;
schema?: string; schema?: string;
objectType?: "tables"; objectType?: "tables";
eventName?: string;
eventReadOnly?: boolean;
eventOpenRequestId?: number;
initialObjectFilter?: "tables" | "events";
viewport?: ObjectBrowserViewport; viewport?: ObjectBrowserViewport;
}; };
objectSource?: { objectSource?: {
+1
View File
@@ -58,6 +58,7 @@ use std::future::Future;
use std::time::Duration; use std::time::Duration;
// Re-export types so that `db::QueryResult` etc. work within dbx-core // 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 crate::types::*;
pub use file_validator::validate_file_path; pub use file_validator::validate_file_path;
+39
View File
@@ -32,6 +32,7 @@ use crate::types::{
}; };
use super::file_validator::validate_file_path; 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 /// DBX-owned MySQL pool handle. The driver does not expose its configured
/// maximum, so retain that value beside the driver pool for checkout phase /// 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<usize>; fn checkout_max_connections(&self) -> Option<usize>;
} }
pub async fn get_event_info<P: MySqlPoolAccess + ?Sized>(
pool: &P,
database: &str,
name: &str,
) -> Result<MysqlEventInfo, String> {
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::<mysql_async::Row, _>(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 { impl MySqlPoolAccess for MySqlPool {
fn driver_pool(&self) -> &mysql_async::Pool { fn driver_pool(&self) -> &mysql_async::Pool {
&self.inner &self.inner
+2
View File
@@ -14,6 +14,7 @@ pub enum DatabaseObjectType {
MaterializedView, MaterializedView,
Procedure, Procedure,
Function, Function,
Event,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[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::MaterializedView => "MATERIALIZED VIEW",
DatabaseObjectType::Procedure => "PROCEDURE", DatabaseObjectType::Procedure => "PROCEDURE",
DatabaseObjectType::Function => "FUNCTION", DatabaseObjectType::Function => "FUNCTION",
DatabaseObjectType::Event => "EVENT",
} }
} }
+1
View File
@@ -58,6 +58,7 @@ pub mod mq;
#[cfg(feature = "mq-admin")] #[cfg(feature = "mq-admin")]
pub mod mqtt; pub mod mqtt;
pub(crate) mod mysql_ddl_normalize; pub(crate) mod mysql_ddl_normalize;
pub mod mysql_event_sql;
pub mod nacos; pub mod nacos;
#[cfg(all(target_os = "windows", target_env = "gnu"))] #[cfg(all(target_os = "windows", target_env = "gnu"))]
mod nanosleep_stub; mod nanosleep_stub;
+171
View File
@@ -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<String>,
pub schedule: MysqlEventSchedule,
pub starts: Option<String>,
pub ends: Option<String>,
pub on_completion_preserve: Option<bool>,
pub enabled: Option<bool>,
pub comment: Option<String>,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MysqlEventInfo {
pub name: String,
pub schema: String,
pub definer: Option<String>,
pub time_zone: Option<String>,
pub event_type: Option<String>,
pub execute_at: Option<String>,
pub interval_value: Option<String>,
pub interval_field: Option<String>,
pub starts: Option<String>,
pub ends: Option<String>,
pub status: Option<String>,
pub on_completion: Option<String>,
pub comment: Option<String>,
pub event_body: Option<String>,
pub event_definition: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub last_executed: Option<String>,
pub source: Option<String>,
}
fn ident(value: &str) -> Result<String, String> {
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<String, String> {
build_event_sql("CREATE EVENT", definition)
}
pub fn alter_event_sql(definition: &MysqlEventDefinition) -> Result<String, String> {
build_event_sql("ALTER EVENT", definition)
}
fn build_event_sql(prefix: &str, definition: &MysqlEventDefinition) -> Result<String, String> {
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<String, String> {
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`"
);
}
}
+53
View File
@@ -8347,6 +8347,59 @@ pub async fn get_object_source_core(
Ok(finalize_object_source(source)) Ok(finalize_object_source(source))
} }
pub async fn get_event_info_core(
state: &AppState,
connection_id: &str,
database: &str,
_schema: &str,
name: &str,
) -> Result<db::MysqlEventInfo, String> {
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 { fn finalize_object_source(mut source: db::ObjectSource) -> db::ObjectSource {
if matches!(source.object_type, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function) { if matches!(source.object_type, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function) {
source.source = normalize_routine_object_source(source.source); source.source = normalize_routine_object_source(source.source);
+2
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use crate::mysql_event_sql::MysqlEventInfo;
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DatabaseInfo { pub struct DatabaseInfo {
pub name: String, pub name: String,
+1
View File
@@ -418,6 +418,7 @@ async fn main() {
.route("/schema/completion-objects", get(routes::schema::list_completion_objects)) .route("/schema/completion-objects", get(routes::schema::list_completion_objects))
.route("/schema/completion-assistant", post(routes::schema::completion_assistant_search)) .route("/schema/completion-assistant", post(routes::schema::completion_assistant_search))
.route("/schema/object-source", get(routes::schema::get_object_source)) .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/custom-type-details", get(routes::schema::get_custom_type_details))
.route("/schema/columns", get(routes::schema::list_columns)) .route("/schema/columns", get(routes::schema::list_columns))
.route("/schema/all-columns", get(routes::schema::get_all_columns)) .route("/schema/all-columns", get(routes::schema::get_all_columns))
+16
View File
@@ -351,6 +351,22 @@ pub async fn get_object_source(
Ok(Json(result)) Ok(Json(result))
} }
pub async fn get_event_info(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<dbx_core::db::MysqlEventInfo>, 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( pub async fn get_custom_type_details(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>, Query(q): Query<SchemaQuery>,
@@ -1921,9 +1921,41 @@ public final class DbxJdbcPlugin {
} }
} }
if ((allowedObjectTypes.isEmpty() || allowedObjectTypes.contains("EVENT")) && isMysqlFamilyConnection(connection)) {
appendMysqlEvents(result, conn, database, schema);
}
return filterMetadataNodes(result, filter, limit, offset, objectTypes, "object_type", false); 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 { private static JsonNode listDataTypes(JsonNode connection, String database) throws SQLException {
Connection conn = openConnection(connection); Connection conn = openConnection(connection);
JdbcDriverQuirks quirks = driverQuirks(connection); JdbcDriverQuirks quirks = driverQuirks(connection);
+11
View File
@@ -322,6 +322,17 @@ pub async fn get_object_source(
.await .await
} }
#[tauri::command]
pub async fn get_event_info(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
name: String,
) -> Result<db::MysqlEventInfo, String> {
dbx_core::schema::get_event_info_core(&state, &connection_id, &database, &schema, &name).await
}
#[tauri::command] #[tauri::command]
pub async fn get_custom_type_details( pub async fn get_custom_type_details(
state: State<'_, Arc<AppState>>, state: State<'_, Arc<AppState>>,
+1
View File
@@ -1669,6 +1669,7 @@ pub fn run() {
commands::schema::list_completion_objects, commands::schema::list_completion_objects,
commands::schema::completion_assistant_search, commands::schema::completion_assistant_search,
commands::schema::get_object_source, commands::schema::get_object_source,
commands::schema::get_event_info,
commands::schema::get_custom_type_details, commands::schema::get_custom_type_details,
commands::schema::list_schemas, commands::schema::list_schemas,
commands::schema::list_schema_infos, commands::schema::list_schema_infos,