feat(export): include triggers and events in database export

This commit is contained in:
zipg authored and GitHub committed 2026-09-22 11:51:49 +08:00
1 parent e639ee51d6
commit 5301607388
4 files changed
+191 -12

No files matched your search

+152 -9
View File
@@ -182,6 +182,8 @@ struct DatabaseExportObjectCounts {
extensions: usize,
procedures: usize,
functions: usize,
triggers: usize,
events: usize,
}
fn exports_database_tables(request: &DatabaseExportRequest) -> bool {
@@ -191,9 +193,23 @@ fn exports_database_tables(request: &DatabaseExportRequest) -> bool {
fn exports_database_routines(request: &DatabaseExportRequest) -> bool {
// Routine export is schema-wide, so an explicit table selection must not
// add unrelated procedures or functions to either execution or progress.
exports_schema_wide_objects(request)
}
/// Schema-wide objects (routines, triggers, events) are exported as a whole. An explicit
/// table selection must not add unrelated objects to either execution or progress.
fn exports_schema_wide_objects(request: &DatabaseExportRequest) -> bool {
request.include_objects && request.selected_tables.is_empty()
}
/// MySQL lists triggers and events as schema-wide objects (see
/// `crates/dbx-drivers/src/db/mysql.rs`), which is what the export writes out. Other
/// engines either report triggers per table (PostgreSQL) or not at all, so their export
/// stays unchanged.
fn exports_mysql_trigger_objects(db_type: DatabaseType) -> bool {
matches!(db_type, DatabaseType::Mysql)
}
fn database_export_total_objects(request: &DatabaseExportRequest, counts: &DatabaseExportObjectCounts) -> usize {
let mut total = 0;
if exports_database_tables(request) {
@@ -208,6 +224,9 @@ fn database_export_total_objects(request: &DatabaseExportRequest, counts: &Datab
if exports_database_routines(request) {
total += counts.procedures + counts.functions;
}
if exports_schema_wide_objects(request) {
total += counts.triggers + counts.events;
}
total
}
@@ -3067,8 +3086,10 @@ async fn export_database_sql_core_inner(
// 8. Discover optional schema-wide objects before calculating workload.
let mut procedures: Vec<crate::types::ObjectInfo> = Vec::new();
let mut functions: Vec<crate::types::ObjectInfo> = Vec::new();
let mut triggers: Vec<crate::types::ObjectInfo> = Vec::new();
let mut events: Vec<crate::types::ObjectInfo> = Vec::new();
if exports_database_routines(request) {
if exports_schema_wide_objects(request) {
match crate::schema::list_objects_core(
state,
&request.connection_id,
@@ -3092,6 +3113,10 @@ async fn export_database_sql_core_inner(
procedures.push(obj.clone());
} else if ot.contains("FUNCTION") {
functions.push(obj.clone());
} else if exports_mysql_trigger_objects(db_type) && ot.contains("TRIGGER") {
triggers.push(obj.clone());
} else if exports_mysql_trigger_objects(db_type) && ot.contains("EVENT") {
events.push(obj.clone());
}
}
}
@@ -3111,6 +3136,8 @@ async fn export_database_sql_core_inner(
extensions: postgres_extensions.len(),
procedures: procedures.len(),
functions: functions.len(),
triggers: triggers.len(),
events: events.len(),
},
);
@@ -3792,6 +3819,71 @@ async fn export_database_sql_core_inner(
object_index += 1;
}
// Export triggers and events after routines: a trigger body may call a routine,
// and an event body may call one too.
for (trigger, object_type) in triggers
.iter()
.map(|t| (t, ObjectSourceKind::Trigger))
.chain(events.iter().map(|e| (e, ObjectSourceKind::Event)))
{
if is_export_cancelled(&request.export_id).await {
return Err("Export cancelled".to_string());
}
let object_name = &trigger.name;
on_progress(ExportProgress {
export_id: request.export_id.clone(),
current_object: object_name.clone(),
object_index,
total_objects,
rows_exported: total_rows_exported,
total_rows: None,
status: ExportStatus::Running,
error: None,
preparing: false,
error_count: 0,
error_summary: None,
});
let kind_label = if object_type == ObjectSourceKind::Trigger { "trigger" } else { "event" };
match crate::schema::get_object_source_core(
state,
&request.connection_id,
&request.database,
&request.schema,
object_name,
object_type.clone(),
None,
None,
)
.await
{
Ok(obj_source) => {
let source = build_database_export_object_source_sql(
db_type,
&object_type,
object_name,
&obj_source.source,
request.drop_table_if_exists,
);
if !source.is_empty() {
writeln!(file, "{source}\n").map_err(|e| format!("Failed to write file: {e}"))?;
}
}
Err(e) => {
record_export_error(
&mut file,
request.fail_on_error,
format!("exporting {kind_label} {object_name}: {e}"),
&mut lenient_errors,
)?;
}
}
object_index += 1;
}
}
// PostgreSQL trigger definitions reference their trigger functions. The
@@ -3863,6 +3955,8 @@ fn build_database_export_object_source_sql(
ObjectSourceKind::View => "VIEW",
ObjectSourceKind::Procedure => "PROCEDURE",
ObjectSourceKind::Function => "FUNCTION",
ObjectSourceKind::Trigger => "TRIGGER",
ObjectSourceKind::Event => "EVENT",
_ => return source,
};
let object_name = quote_identifier(object_name, &DatabaseType::Mysql);
@@ -3879,12 +3973,12 @@ mod tests {
};
use super::{
build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements,
build_export_insert_statements_excluding, build_export_sql_insert, create_database_export_writer,
database_export_query_options_for_timeout, database_export_select_sql, database_export_total_objects,
drop_table_if_exists_sql, ensure_export_destination_dir, export_destination_identity_mismatch,
filter_export_table_infos, format_export_sql_literal, format_export_table_ddl,
format_mysql_spatial_export_literal, format_xugu_spatial_export_literal, generate_postgres_extension_ddl,
generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl,
build_export_insert_statements_excluding, build_export_object_source_sql, build_export_sql_insert,
create_database_export_writer, database_export_query_options_for_timeout, database_export_select_sql,
database_export_total_objects, drop_table_if_exists_sql, ensure_export_destination_dir,
export_destination_identity_mismatch, filter_export_table_infos, format_export_sql_literal,
format_export_table_ddl, format_mysql_spatial_export_literal, format_xugu_spatial_export_literal,
generate_postgres_extension_ddl, generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl,
generate_postgres_sequence_setval_sql, is_postgres_extension_member_routine, mysql_database_export_preamble,
mysql_view_dependencies_from_rows, mysql_view_dependencies_sql, normalize_export_table_ddl,
record_export_destination_identity, record_export_error, replace_database_export_select_list,
@@ -4144,13 +4238,15 @@ mod tests {
extensions: 1,
procedures: 1,
functions: 1,
triggers: 2,
events: 1,
};
let cases = [
("structure", export_request(true, false, false, Vec::new()), 5),
("data", export_request(false, true, false, Vec::new()), 2),
("objects", export_request(false, false, true, Vec::new()), 3),
("all", export_request(true, true, true, Vec::new()), 8),
("objects", export_request(false, false, true, Vec::new()), 6),
("all", export_request(true, true, true, Vec::new()), 11),
("nothing", export_request(false, false, false, Vec::new()), 0),
];
@@ -4182,12 +4278,59 @@ mod tests {
extensions: 1,
procedures: 4,
functions: 5,
triggers: 2,
events: 3,
};
let request = export_request(true, true, true, vec!["users".to_string(), "active_users".to_string()]);
assert_eq!(database_export_total_objects(&request, &counts), 4);
}
#[test]
fn mysql_database_export_drops_triggers_and_events_before_create() {
let trigger = "CREATE DEFINER=`root`@`%` TRIGGER `trg_orders_ai` AFTER INSERT ON `orders` FOR EACH ROW INSERT INTO audit_log(msg) VALUES ('x')";
let event =
"CREATE DEFINER=`root`@`%` EVENT `ev_purge` ON SCHEDULE EVERY 1 DAY DO DELETE FROM audit_log WHERE id < 0";
assert_eq!(
build_database_export_object_source_sql(
DatabaseType::Mysql,
&ObjectSourceKind::Trigger,
"trg_orders_ai",
trigger,
true
),
format!(
"DROP TRIGGER IF EXISTS `trg_orders_ai`;\n{}",
build_export_object_source_sql(DatabaseType::Mysql, ObjectSourceKind::Trigger, trigger)
)
);
assert_eq!(
build_database_export_object_source_sql(
DatabaseType::Mysql,
&ObjectSourceKind::Event,
"ev_purge",
event,
true
),
format!(
"DROP EVENT IF EXISTS `ev_purge`;\n{}",
build_export_object_source_sql(DatabaseType::Mysql, ObjectSourceKind::Event, event)
)
);
// Without `dropTableIfExists` the source is written as-is.
assert_eq!(
build_database_export_object_source_sql(
DatabaseType::Mysql,
&ObjectSourceKind::Trigger,
"trg_orders_ai",
trigger,
false
),
build_export_object_source_sql(DatabaseType::Mysql, ObjectSourceKind::Trigger, trigger)
);
}
#[test]
fn postgres_extension_ddl_uses_target_default_version_and_source_schema() {
let extension = PostgresExportExtension { name: "pg_trgm".to_string(), schema: "addons".to_string() };
+37 -1
View File
@@ -283,7 +283,17 @@ pub fn build_export_object_source_sql(
if source.is_empty() {
return String::new();
}
if is_mysql_like(database_type) && matches!(object_type, ObjectSourceKind::Procedure | ObjectSourceKind::Function) {
// MySQL routine bodies, trigger bodies and event bodies may contain `;`, so the
// exported statements need a client-side delimiter the same way `mysqldump` emits one.
if is_mysql_like(database_type)
&& matches!(
object_type,
ObjectSourceKind::Procedure
| ObjectSourceKind::Function
| ObjectSourceKind::Trigger
| ObjectSourceKind::Event
)
{
return mysql_delimited_routine_source(source);
}
ensure_semicolon(source)
@@ -2198,6 +2208,32 @@ mod tests {
);
}
#[test]
fn mysql_trigger_and_event_export_use_delimiter_script() {
let trigger = build_export_object_source_sql(
DatabaseType::Mysql,
ObjectSourceKind::Trigger,
"CREATE DEFINER=`root`@`%` TRIGGER `trg_orders_ai` AFTER INSERT ON `orders` FOR EACH ROW\nBEGIN\n INSERT INTO audit_log(msg) VALUES ('x');\nEND",
);
assert_eq!(
trigger,
"DELIMITER //\nCREATE DEFINER=`root`@`%` TRIGGER `trg_orders_ai` AFTER INSERT ON `orders` FOR EACH ROW\nBEGIN\n INSERT INTO audit_log(msg) VALUES ('x');\nEND//\nDELIMITER ;"
);
// An event body already carries its schedule, so only the terminator changes.
let event = build_export_object_source_sql(
DatabaseType::Mysql,
ObjectSourceKind::Event,
"CREATE DEFINER=`root`@`%` EVENT `ev_purge` ON SCHEDULE EVERY 1 DAY DO DELETE FROM audit_log WHERE id < 0;",
);
assert_eq!(
event,
"DELIMITER //\nCREATE DEFINER=`root`@`%` EVENT `ev_purge` ON SCHEDULE EVERY 1 DAY DO DELETE FROM audit_log WHERE id < 0//\nDELIMITER ;"
);
}
#[test]
fn mysql_routine_export_does_not_double_wrap_delimiter_script() {
let source = "DELIMITER //\nCREATE PROCEDURE `refresh_cache`()\nBEGIN\n SELECT 1;\nEND//\nDELIMITER ;";