feat(mysql): manage and edit MySQL events
This commit is contained in:
1 parent
701f188e45
commit
d8bc5153d2
35 files changed
+981
-27
No files matched your search
@@ -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++;
|
||||
|
||||
Reference in new issue
Block a user