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

+1
View File
@@ -166,6 +166,7 @@ export const listObjectStatistics = forward("listObjectStatistics");
export const listCompletionObjects = forward("listCompletionObjects");
export const completionAssistantSearch = forward("completionAssistantSearch");
export const getObjectSource = forward("getObjectSource");
export const getEventInfo = forward("getEventInfo");
export const getCustomTypeDetails = forward("getCustomTypeDetails");
export const getColumns = forward("getColumns");
export const getAllColumns = forward("getAllColumns");
+5
View File
@@ -17,6 +17,7 @@ import type {
CustomTypeDetails,
ObjectSource,
ObjectSourceKind,
MysqlEventInfo,
ColumnInfo,
SqlServerColumnMetadata,
IndexInfo,
@@ -862,6 +863,10 @@ export async function getObjectSource(connectionId: string, database: string, sc
return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature, relation_name: relationName })}`);
}
export async function getEventInfo(connectionId: string, database: string, schema: string, name: string): Promise<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> {
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,
ObjectSource,
ObjectSourceKind,
MysqlEventInfo,
ColumnInfo,
SqlServerColumnMetadata,
IndexInfo,
@@ -1132,6 +1133,10 @@ export async function getObjectSource(connectionId: string, database: string, sc
});
}
export async function getEventInfo(connectionId: string, database: string, schema: string, name: string): Promise<MysqlEventInfo> {
return invoke("get_event_info", { connectionId, database, schema, name });
}
export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise<string[]> {
return invoke("list_schemas", { connectionId, database, applyVisibleFilter });
}
@@ -5,6 +5,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"index",
"fkey",
"trigger",
"event",
"procedure",
"function",
"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 {
if (!row) return "none";
if (row.type === "TABLE") return "table-info";
if (row.type === "EVENT") return "none";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
@@ -31,6 +32,7 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, d
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (row.type === "TABLE") return "open-table";
if (row.type === "EVENT") return "open-source";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
@@ -83,5 +85,7 @@ function canOpenSource(row: ObjectBrowserRow, dbType?: DatabaseType): boolean {
// Xugu keeps its source editor entry through supportsTypeObjectSource below.
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return false;
if ((row.type === "TYPE" || row.type === "TYPE_BODY") && !supportsTypeObjectSource(dbType)) return false;
return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
return (
row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "TRIGGER" || row.type === "EVENT" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY"
);
}
@@ -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;
displayName: string;
schema?: string;
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
valid?: boolean | null;
signature?: string | null;
comment?: string | null;
@@ -24,7 +24,7 @@ export type ObjectBrowserRow = {
export type ObjectBrowserSortKey = "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
export type ObjectBrowserSortDirection = "asc" | "desc";
export type ObjectBrowserFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "sequences" | "packages" | "types";
export type ObjectBrowserFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "events" | "sequences" | "packages" | "types";
export type ObjectBrowserFilterCounts = Record<ObjectBrowserFilter, number>;
export type ObjectBrowserPinnedTreeNodeContext = {
@@ -42,6 +42,7 @@ export function objectBrowserRowTreeNodeType(type: ObjectBrowserRow["type"]): Tr
if (type === "PROCEDURE") return "procedure";
if (type === "FUNCTION") return "function";
if (type === "TRIGGER") return "trigger";
if (type === "EVENT") return "event";
if (type === "SEQUENCE") return "sequence";
if (type === "PACKAGE_BODY") return "package-body";
if (type === "PACKAGE") return "package";
@@ -142,6 +143,7 @@ export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type
if (normalized.includes("TYPE_BODY")) return "TYPE_BODY";
if (normalized.includes("PACKAGE")) return "PACKAGE";
if (normalized.includes("TRIGGER")) return "TRIGGER";
if (normalized.includes("EVENT")) return "EVENT";
if (normalized.includes("TYPE")) return "TYPE";
if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW";
if (value.includes("VIEW")) return "VIEW";
@@ -250,6 +252,7 @@ export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): Object
procedures: 0,
functions: 0,
triggers: 0,
events: 0,
sequences: 0,
packages: 0,
types: 0,
@@ -262,6 +265,7 @@ export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): Object
else if (row.type === "PROCEDURE") counts.procedures++;
else if (row.type === "FUNCTION") counts.functions++;
else if (row.type === "TRIGGER") counts.triggers++;
else if (row.type === "EVENT") counts.events++;
else if (row.type === "SEQUENCE") counts.sequences++;
else if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") counts.packages++;
else if (row.type === "TYPE" || row.type === "TYPE_BODY") counts.types++;