Files
dbx/crates/dbx-sql/src/mysql_event_sql.rs
T

151 lines
6.0 KiB
Rust

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,
}
pub use dbx_types::mysql_event::MysqlEventInfo;
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`"
);
}
}