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
@@ -58,6 +58,7 @@ use std::future::Future;
use std::time::Duration;
// Re-export types so that `db::QueryResult` etc. work within dbx-core
pub use crate::mysql_event_sql::MysqlEventInfo;
pub use crate::types::*;
pub use file_validator::validate_file_path;
+39
View File
@@ -32,6 +32,7 @@ use crate::types::{
};
use super::file_validator::validate_file_path;
use crate::mysql_event_sql::MysqlEventInfo;
/// DBX-owned MySQL pool handle. The driver does not expose its configured
/// maximum, so retain that value beside the driver pool for checkout phase
@@ -70,6 +71,44 @@ pub trait MySqlPoolAccess {
fn checkout_max_connections(&self) -> Option<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 {
fn driver_pool(&self) -> &mysql_async::Pool {
&self.inner
+2
View File
@@ -14,6 +14,7 @@ pub enum DatabaseObjectType {
MaterializedView,
Procedure,
Function,
Event,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1106,6 +1107,7 @@ fn object_type_keyword(object_type: DatabaseObjectType) -> &'static str {
DatabaseObjectType::MaterializedView => "MATERIALIZED VIEW",
DatabaseObjectType::Procedure => "PROCEDURE",
DatabaseObjectType::Function => "FUNCTION",
DatabaseObjectType::Event => "EVENT",
}
}
+1
View File
@@ -58,6 +58,7 @@ pub mod mq;
#[cfg(feature = "mq-admin")]
pub mod mqtt;
pub(crate) mod mysql_ddl_normalize;
pub mod mysql_event_sql;
pub mod nacos;
#[cfg(all(target_os = "windows", target_env = "gnu"))]
mod nanosleep_stub;
+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))
}
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 {
if matches!(source.object_type, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function) {
source.source = normalize_routine_object_source(source.source);
+2
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
pub use crate::mysql_event_sql::MysqlEventInfo;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DatabaseInfo {
pub name: String,
+1
View File
@@ -418,6 +418,7 @@ async fn main() {
.route("/schema/completion-objects", get(routes::schema::list_completion_objects))
.route("/schema/completion-assistant", post(routes::schema::completion_assistant_search))
.route("/schema/object-source", get(routes::schema::get_object_source))
.route("/schema/event-info", get(routes::schema::get_event_info))
.route("/schema/custom-type-details", get(routes::schema::get_custom_type_details))
.route("/schema/columns", get(routes::schema::list_columns))
.route("/schema/all-columns", get(routes::schema::get_all_columns))
+16
View File
@@ -351,6 +351,22 @@ pub async fn get_object_source(
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(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,