use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::future::Future; use std::io::{BufWriter, Write}; use std::sync::RwLock; use std::time::Duration; use crate::connection::task_client_session_id; use crate::models::connection::DatabaseType; use crate::mysql_ddl_normalize::DdlNormalizeOptions; use crate::object_source_sql::build_export_object_source_sql; use crate::sql_dialect::{qualified_table_name, uses_single_row_insert_statements}; use crate::transfer::{ format_ch_array_sql_literal, format_pg_array_sql_literal, is_identity_column_extra, is_mysql_generated_column_extra, keyset_pagination_sql_with_identifier_quote, quote_identifier, quote_postgres_string_literal, wrap_dameng_identity_insert_sql_for_table, }; use crate::types::ObjectSourceKind; static EXPORT_CANCELLED: std::sync::LazyLock>> = std::sync::LazyLock::new(|| RwLock::new(HashSet::new())); const EXPORT_CANCELLED_ERROR: &str = "Export cancelled"; const EXPORT_CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(50); pub fn database_export_client_session_id(export_id: &str) -> String { task_client_session_id("database-export", export_id) } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DatabaseExportRequest { pub export_id: String, pub connection_id: String, pub database: String, pub schema: String, pub file_path: String, #[serde(default)] pub selected_tables: Vec, #[serde(default)] pub excluded_tables: Vec, pub include_structure: bool, pub include_data: bool, pub include_objects: bool, /// Include a MySQL `CREATE DATABASE IF NOT EXISTS` and `USE` preamble. /// The option is intentionally opt-in because it needs CREATE privileges /// when importing into a new server. #[serde(default)] pub include_create_database: bool, #[serde(default)] pub drop_table_if_exists: bool, /// Drop the table-level `AUTO_INCREMENT=N` clause from exported MySQL DDL, /// so the script can initialize a fresh database without pinning a sequence /// position. No-op for non-MySQL databases. Defaults to `false` (preserve). #[serde(default)] pub omit_auto_increment: bool, #[serde(default)] pub fail_on_error: bool, #[serde(default)] pub snapshot_session_id: Option, pub batch_size: usize, } #[derive(Debug, Default, PartialEq, Eq)] struct DatabaseExportObjectCounts { tables: usize, views: usize, sequences: usize, extensions: usize, procedures: usize, functions: usize, } fn exports_database_tables(request: &DatabaseExportRequest) -> bool { request.include_structure || request.include_data } 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. request.include_objects && request.selected_tables.is_empty() } fn database_export_total_objects(request: &DatabaseExportRequest, counts: &DatabaseExportObjectCounts) -> usize { let mut total = 0; if exports_database_tables(request) { total += counts.tables; } if request.include_structure { total += counts.sequences + counts.extensions; } if request.include_objects { total += counts.views; } if exports_database_routines(request) { total += counts.procedures + counts.functions; } total } fn mysql_sql_string_literal(value: &str) -> String { format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''")) } fn mysql_view_dependencies_sql(database: &str) -> String { let database = mysql_sql_string_literal(database); format!( "SELECT VIEW_NAME, TABLE_NAME FROM information_schema.VIEW_TABLE_USAGE \ WHERE VIEW_SCHEMA = {database} AND TABLE_SCHEMA = {database} \ ORDER BY VIEW_NAME, TABLE_NAME" ) } fn mysql_view_dependencies_from_rows(rows: &[Vec]) -> Vec<(String, String)> { rows.iter() .filter_map(|row| { let view_name = row.first()?.as_str()?.trim(); let referenced_name = row.get(1)?.as_str()?.trim(); (!view_name.is_empty() && !referenced_name.is_empty()) .then(|| (view_name.to_string(), referenced_name.to_string())) }) .collect() } async fn list_mysql_export_view_dependencies( state: &crate::connection::AppState, connection_id: &str, database: &str, ) -> Result, String> { let result = crate::query::execute_sql_statement_with_options( state, connection_id, database, &mysql_view_dependencies_sql(database), None, None, crate::query::QueryExecutionOptions { max_rows: Some(usize::MAX), ..Default::default() }, ) .await?; Ok(mysql_view_dependencies_from_rows(&result.rows)) } fn sort_export_views_by_dependencies<'a>( views: &[&'a crate::types::TableInfo], dependencies: &[(String, String)], ) -> Vec<&'a crate::types::TableInfo> { let names = views.iter().map(|view| view.name.clone()).collect::>(); let sorted_names = crate::transfer::sort_table_names_by_dependencies(&names, dependencies, true); let views_by_name = views.iter().map(|view| (view.name.as_str(), *view)).collect::>(); sorted_names.iter().filter_map(|name| views_by_name.get(name.as_str()).copied()).collect() } fn mysql_database_export_preamble(database: &str, charset: Option<&str>, collation: Option<&str>) -> String { let database = quote_identifier(database, &DatabaseType::Mysql); let charset = charset.map(str::trim).filter(|value| !value.is_empty()); let collation = collation.map(str::trim).filter(|value| !value.is_empty()); let options = match charset { Some(charset) => match collation { Some(collation) => format!(" CHARACTER SET {charset} COLLATE {collation}"), None => format!(" CHARACTER SET {charset}"), }, None => String::new(), }; format!("CREATE DATABASE IF NOT EXISTS {database}{options};\nUSE {database};\n") } async fn mysql_database_export_preamble_for_request( state: &crate::connection::AppState, request: &DatabaseExportRequest, ) -> String { let metadata_sql = format!( "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = {}", mysql_sql_string_literal(&request.database) ); let metadata = crate::query::execute_sql_statement( state, &request.connection_id, &request.database, &metadata_sql, None, None, ) .await .ok() .and_then(|result| { result.rows.first().map(|row| { ( row.first().and_then(Value::as_str).map(str::to_string), row.get(1).and_then(Value::as_str).map(str::to_string), ) }) }); match metadata { Some((charset, collation)) => { mysql_database_export_preamble(&request.database, charset.as_deref(), collation.as_deref()) } None => mysql_database_export_preamble(&request.database, None, None), } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DatabaseBackupSnapshot { pub session_id: String, pub schemas: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportProgress { pub export_id: String, pub current_object: String, pub object_index: usize, pub total_objects: usize, pub rows_exported: u64, pub total_rows: Option, pub status: ExportStatus, pub error: Option, /// True while listing schema / prefetching table metadata — before objects are written. #[serde(default)] pub preparing: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ExportStatus { Running, Writing, Done, Error, Cancelled, } pub const DATABASE_EXPORT_ROW_LIMIT: usize = 10_000; pub const DATABASE_EXPORT_PAGE_SIZE: usize = 500; pub const DATABASE_EXPORT_INSERT_BATCH_SIZE: usize = 100; pub const DATABASE_EXPORT_TARGET_STATEMENT_BYTES: usize = 512 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] struct PostgresExportSequence { name: String, data_type: String, start_value: String, min_value: String, max_value: String, increment: String, cycle: bool, cache_value: String, last_value: Option, owner_table: Option, owner_column: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct PostgresExportExtension { name: String, schema: String, } #[derive(Debug, Default)] struct PostgresExtensionMembers { relation_names: HashSet, function_keys: HashSet<(String, String)>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportedTableSql { pub display_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identifier_quote: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub table_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub qualified_table_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ddl: Option, #[serde(default)] pub columns: Vec, #[serde(default)] pub column_types: Vec>, #[serde(default)] pub column_extras: Vec>, #[serde(default)] pub rows: Vec>, #[serde(default)] pub truncated: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildExportInsertStatementsOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identifier_quote: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub table_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub qualified_table_name: Option, #[serde(default)] pub columns: Vec, #[serde(default)] pub column_types: Vec>, #[serde(default)] pub column_extras: Vec>, #[serde(default)] pub rows: Vec>, #[serde(default, skip_serializing_if = "Option::is_none")] pub batch_size: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildExportSqlInsertOptions { #[serde(flatten)] pub insert: BuildExportInsertStatementsOptions, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildDatabaseSqlExportOptions { pub database_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub exported_at: Option, #[serde(default)] pub tables: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub row_limit_per_table: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub insert_batch_size: Option, /// Optional connection info for FK-aware table ordering. /// When set, the caller should sort tables by dependency before passing them /// to `build_database_sql_export`. #[serde(default, skip_serializing_if = "Option::is_none")] pub connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub database: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, /// Drop the table-level `AUTO_INCREMENT=N` clause from exported MySQL DDL. /// Defaults to `false` (preserve). See `DatabaseExportRequest::omit_auto_increment`. #[serde(default)] pub omit_auto_increment: bool, } pub fn format_export_sql_literal(value: &Value) -> String { format_export_sql_literal_for_database(value, None) } fn format_export_sql_literal_for_database(value: &Value, database_type: Option) -> String { if value.is_null() { return "NULL".to_string(); } if let Some(number) = value.as_number() { return number.to_string(); } if let Some(value) = value.as_bool() { if matches!(database_type, Some(DatabaseType::Dameng) | Some(DatabaseType::SqlServer)) { return if value { "1" } else { "0" }.to_string(); } return if value { "TRUE" } else { "FALSE" }.to_string(); } if let Some(arr) = value.as_array() { return format_pg_array_sql_literal(arr); } let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string); quote_export_sql_string_for_database(&text, database_type) } fn format_export_sql_literal_typed( value: &Value, database_type: Option, column_type: Option<&str>, sqlserver_unicode_string: bool, ) -> String { if is_postgres_json_export_column(database_type, column_type) { return format_postgres_json_export_literal(value); } if is_postgres_vector_export_column(database_type, column_type) { return format_postgres_vector_export_literal(value); } if matches!(database_type, Some(DatabaseType::Mysql)) && column_type.is_some_and(is_mysql_bit_type) { return format_mysql_bit_literal(value); } if let Some(literal) = format_mysql_spatial_export_literal(value, database_type, column_type) { return literal; } if is_mysql_compatible_export_literal_target(database_type) { if column_type.is_some_and(is_mysql_binary_export_type) { if let Some(literal) = format_mysql_binary_export_literal(value) { return literal; } } if column_type.is_some_and(is_export_numeric_type) { if let Some(literal) = format_export_numeric_literal(value) { return literal; } } } if let Some(arr) = value.as_array() { if matches!(database_type, Some(DatabaseType::ClickHouse) | Some(DatabaseType::Databend)) { return format_ch_array_sql_literal(arr); } } if let Some(literal) = format_oracle_export_temporal_literal(value, database_type, column_type) { return literal; } if let Some(literal) = format_export_temporal_literal(value, database_type, column_type) { return literal; } if sqlserver_unicode_string { if let Some(text) = value.as_str() { return format!("N{}", quote_export_sql_string(text)); } } format_export_sql_literal_for_database(value, database_type) } fn format_postgres_json_export_literal(value: &Value) -> String { if value.is_null() { return "NULL".to_string(); } let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string); // PostgreSQL standard strings keep backslashes literal; JSON text needs its // own escape sequences, so only SQL-escape the surrounding string delimiter. quote_postgres_string_literal(&text) } fn format_postgres_vector_export_literal(value: &Value) -> String { if value.is_null() { return "NULL".to_string(); } let text = match value { // pgvector vector/halfvec are scalar extension types whose importable // literal grammar uses square brackets, unlike PostgreSQL arrays. Value::Array(arr) => format_postgres_vector_export_text(arr), Value::String(text) => text.to_string(), _ => value.to_string(), }; quote_postgres_string_literal(&text) } fn format_postgres_vector_export_text(arr: &[Value]) -> String { let elements = arr.iter().map(format_postgres_vector_export_element).collect::>(); format!("[{}]", elements.join(",")) } fn format_postgres_vector_export_element(value: &Value) -> String { match value { Value::String(text) => text.trim().to_string(), Value::Number(number) => number.to_string(), Value::Bool(value) => value.to_string(), Value::Null => "NULL".to_string(), _ => value.to_string(), } } fn quote_export_sql_string(text: &str) -> String { format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''")) } fn is_sqlserver_unicode_export_type(column_type: &str) -> bool { let base = column_type.trim().split(|ch: char| ch == '(' || ch.is_whitespace()).next().unwrap_or(""); ["nchar", "nvarchar", "ntext", "sysname"].iter().any(|candidate| base.eq_ignore_ascii_case(candidate)) } fn quote_export_sql_string_for_database(text: &str, database_type: Option) -> String { match database_type { Some(DatabaseType::Dameng) => quote_dameng_export_sql_string(text), Some(DatabaseType::Postgres) => quote_postgres_string_literal(text), database_type if is_mysql_compatible_export_literal_target(database_type) => { quote_mysql_compatible_export_sql_string(text) } _ => quote_export_sql_string(text), } } fn quote_dameng_export_sql_string(text: &str) -> String { if !text.contains('\0') { return quote_export_sql_string(text); } let mut parts = Vec::new(); for (index, segment) in text.split('\0').enumerate() { if index > 0 { parts.push("CHR(0)".to_string()); } if !segment.is_empty() { parts.push(quote_export_sql_string(segment)); } } parts.join(" || ") } fn quote_mysql_compatible_export_sql_string(text: &str) -> String { format!("'{}'", escape_mysql_compatible_export_sql_string(text)) } fn escape_mysql_compatible_export_sql_string(text: &str) -> String { let mut escaped = String::with_capacity(text.len()); for ch in text.chars() { match ch { // MySQL-family dumps should keep control characters out of the // physical script layout while relying on the dialect's escapes. '\0' => escaped.push_str("\\0"), '\x08' => escaped.push_str("\\b"), '\n' => escaped.push_str("\\n"), '\r' => escaped.push_str("\\r"), '\t' => escaped.push_str("\\t"), '\x0c' => escaped.push_str("\\f"), '\x1a' => escaped.push_str("\\Z"), '\\' => escaped.push_str("\\\\"), '\'' => escaped.push_str("''"), _ => escaped.push(ch), } } escaped } fn is_mysql_compatible_export_literal_target(database_type: Option) -> bool { matches!( database_type, Some(DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::Goldendb) ) } fn format_oracle_export_temporal_literal( value: &Value, database_type: Option, column_type: Option<&str>, ) -> Option { if !matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) { return None; } let column_type = column_type?; let lower = column_type.trim().trim_matches('"').to_ascii_lowercase(); let base = lower.split(['(', ' ', '\t', '\n']).next().unwrap_or(""); let kind = match base { "timestampdty" => ExportTemporalKind::DateTime, "timestamptz_dty" => ExportTemporalKind::DateTimeWithTimeZone, _ => export_temporal_column_kind(database_type, column_type)?, }; let parts = parse_export_date_parts(value.as_str()?)?; if base == "date" { return Some(format_oracle_export_date_parts_literal(&parts)); } let fraction = parts.fraction.as_deref().unwrap_or_default(); let datetime = format!("{} {}{fraction}", parts.date, parts.time); let mask = if fraction.is_empty() { "YYYY-MM-DD HH24:MI:SS" } else { "YYYY-MM-DD HH24:MI:SS.FF" }; if kind == ExportTemporalKind::DateTimeWithTimeZone && !parts.zone.is_empty() { let zone = normalize_export_timezone(&parts.zone); return Some(format!("TO_TIMESTAMP_TZ('{datetime} {zone}', '{mask} TZH:TZM')")); } Some(format!("TO_TIMESTAMP('{datetime}', '{mask}')")) } fn format_oracle_export_date_parts_literal(parts: &ExportRfc3339Parts) -> String { if export_temporal_parts_are_midnight(parts) { format!("DATE '{}'", parts.date) } else { format!("TO_DATE('{} {}', 'YYYY-MM-DD HH24:MI:SS')", parts.date, parts.time) } } fn export_temporal_parts_are_midnight(parts: &ExportRfc3339Parts) -> bool { parts.time == "00:00:00" && parts .fraction .as_deref() .map(|fraction| fraction.trim_start_matches('.').chars().all(|ch| ch == '0')) .unwrap_or(true) } fn format_export_temporal_literal( value: &Value, database_type: Option, column_type: Option<&str>, ) -> Option { let text = value.as_str()?; let column_type = column_type?; if database_type == Some(DatabaseType::SqlServer) { return crate::sqlserver_temporal::normalize_sqlserver_temporal_literal(text, Some(column_type)) .map(|text| quote_export_sql_string(&text)); } let kind = export_temporal_column_kind(database_type, column_type)?; format_rfc3339_export_temporal_text(text, kind, database_type).map(|text| quote_export_sql_string(&text)) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ExportTemporalKind { Date, Time, DateTime, DateTimeWithTimeZone, } fn export_temporal_column_kind(database_type: Option, column_type: &str) -> Option { let lower = column_type.trim().trim_matches('"').to_ascii_lowercase(); let base = lower.split(['(', ' ', '\t', '\n']).next().unwrap_or(""); match base { "date" if matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) => { Some(ExportTemporalKind::DateTime) } "date" => Some(ExportTemporalKind::Date), "time" => Some(ExportTemporalKind::Time), "datetime" | "datetime2" | "smalldatetime" | "datetime64" => Some(ExportTemporalKind::DateTime), "datetimeoffset" | "timestamptz" => Some(ExportTemporalKind::DateTimeWithTimeZone), _ if lower.starts_with("timestamp") && (lower.contains("with time zone") || lower.contains("with local time zone")) => { Some(ExportTemporalKind::DateTimeWithTimeZone) } _ if lower.starts_with("timestamp") => Some(ExportTemporalKind::DateTime), _ => None, } } fn format_rfc3339_export_temporal_text( text: &str, kind: ExportTemporalKind, database_type: Option, ) -> Option { let parts = parse_export_rfc3339_parts(text)?; let fraction = normalize_export_fraction(parts.fraction.as_deref(), database_type); match kind { ExportTemporalKind::Date => Some(parts.date), ExportTemporalKind::Time => Some(format!("{}{fraction}", parts.time)), ExportTemporalKind::DateTime => Some(format!("{} {}{fraction}", parts.date, parts.time)), ExportTemporalKind::DateTimeWithTimeZone => { Some(format!("{} {}{fraction}{}", parts.date, parts.time, normalize_export_timezone(&parts.zone))) } } } struct ExportRfc3339Parts { date: String, time: String, fraction: Option, zone: String, } fn parse_export_date_parts(text: &str) -> Option { parse_export_rfc3339_parts(text).or_else(|| parse_export_local_temporal_parts(text)) } fn parse_export_local_temporal_parts(text: &str) -> Option { let bytes = text.as_bytes(); if bytes.len() < 10 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') { return None; } let date = &text[0..10]; if !date.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) { return None; } if bytes.len() == 10 { return Some(ExportRfc3339Parts { date: date.to_string(), time: "00:00:00".to_string(), fraction: None, zone: String::new(), }); } let separator = *bytes.get(10)?; if separator != b'T' && separator != b' ' { return None; } if bytes.len() < 19 || bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') { return None; } let time = &text[11..19]; if !time.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 2 | 5) || byte.is_ascii_digit()) { return None; } let rest = &text[19..]; let fraction = if let Some(rest) = rest.strip_prefix('.') { let digit_count = rest.chars().take_while(|ch| ch.is_ascii_digit()).count(); if digit_count == 0 || digit_count > 9 || digit_count != rest.len() { return None; } Some(format!(".{}", &rest[..digit_count])) } else if rest.is_empty() { None } else { return None; }; Some(ExportRfc3339Parts { date: date.to_string(), time: time.to_string(), fraction, zone: String::new() }) } fn parse_export_rfc3339_parts(text: &str) -> Option { let bytes = text.as_bytes(); if bytes.len() < 20 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') { return None; } let separator = *bytes.get(10)?; if separator != b'T' && separator != b' ' { return None; } if bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') { return None; } let date = &text[0..10]; let time = &text[11..19]; if !date.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) || !time.as_bytes().iter().enumerate().all(|(index, byte)| matches!(index, 2 | 5) || byte.is_ascii_digit()) { return None; } let rest = &text[19..]; let (fraction, zone) = if let Some(rest) = rest.strip_prefix('.') { let digit_count = rest.chars().take_while(|ch| ch.is_ascii_digit()).count(); if digit_count == 0 || digit_count > 9 { return None; } (Some(format!(".{}", &rest[..digit_count])), &rest[digit_count..]) } else { (None, rest) }; if zone.eq_ignore_ascii_case("z") || is_export_timezone_offset(zone) { Some(ExportRfc3339Parts { date: date.to_string(), time: time.to_string(), fraction, zone: zone.to_string() }) } else { None } } fn normalize_export_fraction(fraction: Option<&str>, database_type: Option) -> String { match fraction { Some(fraction) if database_type == Some(DatabaseType::Mysql) && fraction.len() > 7 => fraction[..7].to_string(), Some(fraction) => fraction.to_string(), None => String::new(), } } fn normalize_export_timezone(zone: &str) -> String { if zone.eq_ignore_ascii_case("z") { "+00:00".to_string() } else { zone.to_string() } } fn is_export_timezone_offset(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 6 && matches!(bytes[0], b'+' | b'-') && bytes[3] == b':' && bytes[1].is_ascii_digit() && bytes[2].is_ascii_digit() && bytes[4].is_ascii_digit() && bytes[5].is_ascii_digit() } fn is_mysql_bit_type(column_type: &str) -> bool { let trimmed = column_type.trim(); let lower = trimmed.to_ascii_lowercase(); lower == "bit" || lower.starts_with("bit(") || lower.starts_with("bit ") } fn format_mysql_bit_literal(value: &Value) -> String { match value { Value::Null => "NULL".to_string(), Value::Bool(value) => { if *value { "b'1'".to_string() } else { "b'0'".to_string() } } Value::Number(value) => { let s = value.to_string(); if s == "0" || s == "1" { format!("b'{s}'") } else { s } } Value::String(value) => { let trimmed = value.trim(); if trimmed.eq_ignore_ascii_case("true") { return "b'1'".to_string(); } if trimmed.eq_ignore_ascii_case("false") { return "b'0'".to_string(); } if trimmed == "0" || trimmed == "1" { return format!("b'{trimmed}'"); } if !trimmed.is_empty() && trimmed.bytes().all(|byte| byte == b'0' || byte == b'1') { return format!("b'{trimmed}'"); } format!("b'{}'", escape_mysql_compatible_export_sql_string(value)) } other => format_export_sql_literal(other), } } fn is_mysql_binary_export_type(column_type: &str) -> bool { let lower = column_type.trim().to_ascii_lowercase(); let base = lower.split(['(', ':', ' ', '\t', '\n']).next().unwrap_or("").trim(); matches!(base, "binary" | "varbinary" | "blob" | "tinyblob" | "mediumblob" | "longblob") } pub(crate) fn is_mysql_spatial_export_type(column_type: &str) -> bool { let base = column_type .trim() .to_ascii_lowercase() .split(['(', ':', ' ', '\t', '\n']) .next() .unwrap_or("") .trim() .to_string(); matches!( base.as_str(), "geometry" | "point" | "linestring" | "polygon" | "multipoint" | "multilinestring" | "multipolygon" | "geometrycollection" | "geomcollection" ) } /// Database exports encode MySQL spatial cells as `DBX_WKB::` while /// reading them. Keeping this marker internal lets the normal JSON row shape /// and all non-export query paths continue to expose readable WKT values. pub(crate) fn format_mysql_spatial_export_literal( value: &Value, database_type: Option, column_type: Option<&str>, ) -> Option { if database_type != Some(DatabaseType::Mysql) || !column_type.is_some_and(is_mysql_spatial_export_type) { return None; } let Value::String(value) = value else { return value.is_null().then(|| "NULL".to_string()); }; let marker = value.strip_prefix("DBX_WKB:")?; let (srid, hex) = marker.split_once(':')?; if srid.is_empty() || !srid.as_bytes().iter().all(u8::is_ascii_digit) || hex.is_empty() || hex.len() % 2 != 0 || !hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { return None; } let wkb = decode_mysql_spatial_export_wkb(hex)?; crate::db::wkb::decode_wkb_geometry(&wkb)?; let srid = srid.parse::().ok()?; Some(if srid == 0 { format!("ST_GeomFromWKB(0x{hex})") } else { format!("ST_GeomFromWKB(0x{hex}, {srid})") }) } fn decode_mysql_spatial_export_wkb(hex: &str) -> Option> { fn nibble(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } hex.as_bytes().chunks_exact(2).map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?)).collect() } fn format_mysql_binary_export_literal(value: &Value) -> Option { match value { Value::Null => Some("NULL".to_string()), Value::String(text) => format_mysql_binary_export_literal_text(text), _ => None, } } fn format_mysql_binary_export_literal_text(text: &str) -> Option { let trimmed = text.trim(); let hex = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X"))?; if hex.chars().all(|ch| ch.is_ascii_hexdigit()) { // DBX exposes MySQL binary cells as 0x-prefixed hex text. Keep it as a // MySQL hex literal so exported INSERT statements round-trip bytes. Some(if hex.is_empty() { "X''".to_string() } else { format!("0x{hex}") }) } else { None } } fn is_export_numeric_type(column_type: &str) -> bool { let lower = column_type.to_ascii_lowercase(); [ "int", "integer", "bigint", "smallint", "tinyint", "mediumint", "serial", "number", "numeric", "decimal", "dec", "fixed", "float", "double", "real", ] .iter() .any(|part| lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == *part)) } fn format_export_numeric_literal(value: &Value) -> Option { match value { Value::Number(number) => Some(number.to_string()), Value::String(text) if is_export_numeric_literal(text) => Some(text.to_string()), _ => None, } } fn is_export_numeric_literal(text: &str) -> bool { if text.trim() != text || text.is_empty() { return false; } text.parse::().is_ok_and(f64::is_finite) && text.chars().all(|ch| ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E')) && text.chars().any(|ch| ch.is_ascii_digit()) } pub fn build_export_insert_statements(options: BuildExportInsertStatementsOptions) -> Result, String> { if options.columns.is_empty() || options.rows.is_empty() { return Ok(Vec::new()); } let table = export_qualified_table_name( options.database_type, options.schema.as_deref(), options.table_name.as_deref(), options.qualified_table_name.as_deref(), options.identifier_quote.as_deref(), )?; let insert_columns = options .columns .iter() .enumerate() .filter_map(|(index, column)| { let column_type = options.column_types.get(index).and_then(|value| value.as_deref()); is_export_insert_column( options.database_type, column, column_type, options.column_extras.get(index).and_then(|value| value.as_deref()), ) .then(|| { let sqlserver_unicode_string = options.database_type == Some(DatabaseType::SqlServer) && column_type.is_some_and(is_sqlserver_unicode_export_type); (index, column, sqlserver_unicode_string) }) }) .collect::>(); if insert_columns.is_empty() { return Ok(Vec::new()); } let batch_size = if options.database_type.is_some_and(uses_single_row_insert_statements) { 1 } else { let requested = options.batch_size.unwrap_or(DATABASE_EXPORT_INSERT_BATCH_SIZE).max(1); if options.database_type == Some(DatabaseType::SqlServer) { requested.min(1000) } else { requested } }; let columns = insert_columns .iter() .map(|(_, column, _)| { crate::sql_dialect::quote_table_data_identifier( options.database_type, column, options.identifier_quote.as_deref(), ) }) .collect::>() .join(", "); let mut statements = Vec::new(); let needs_dameng_identity_insert = options.database_type == Some(DatabaseType::Dameng) && insert_columns.iter().any(|(index, _, _)| { is_identity_column_extra(options.column_extras.get(*index).and_then(|value| value.as_deref())) }); let statement_prefix = format!("INSERT INTO {table} ({columns}) VALUES "); let statement_overhead_bytes = export_sql_statement_bytes(options.database_type, &statement_prefix) + 1; let target_statement_bytes = DATABASE_EXPORT_TARGET_STATEMENT_BYTES; let separator_bytes = export_sql_statement_bytes(options.database_type, ", "); let mut current_values = String::new(); let mut current_values_bytes = 0usize; let mut current_row_count = 0usize; let flush_values = |statements: &mut Vec, values: &mut String, values_bytes: &mut usize, row_count: &mut usize| { if *row_count == 0 { return; } let mut insert_sql = String::with_capacity(statement_prefix.len() + values.len() + 1); insert_sql.push_str(&statement_prefix); insert_sql.push_str(values); insert_sql.push(';'); if needs_dameng_identity_insert { statements.push(wrap_dameng_identity_insert_sql_for_table(&insert_sql, &table)); } else { statements.push(insert_sql); } values.clear(); *values_bytes = 0; *row_count = 0; }; for row in options.rows { let mut rendered_row = String::with_capacity(insert_columns.len().saturating_mul(16).saturating_add(2)); rendered_row.push('('); for (column_index, (index, _, sqlserver_unicode_string)) in insert_columns.iter().enumerate() { if column_index > 0 { rendered_row.push_str(", "); } let value = row.get(*index).unwrap_or(&Value::Null); rendered_row.push_str(&format_export_sql_literal_typed( value, options.database_type, options.column_types.get(*index).and_then(|value| value.as_deref()), *sqlserver_unicode_string, )); } rendered_row.push(')'); let rendered_row_bytes = export_sql_statement_bytes(options.database_type, &rendered_row); let candidate_bytes = statement_overhead_bytes + current_values_bytes + if current_row_count == 0 { 0 } else { separator_bytes } + rendered_row_bytes; if current_row_count > 0 && (current_row_count >= batch_size || candidate_bytes > target_statement_bytes) { flush_values(&mut statements, &mut current_values, &mut current_values_bytes, &mut current_row_count); } if current_row_count > 0 { current_values.push_str(", "); current_values_bytes += separator_bytes; } current_values.push_str(&rendered_row); current_values_bytes += rendered_row_bytes; current_row_count += 1; } flush_values(&mut statements, &mut current_values, &mut current_values_bytes, &mut current_row_count); Ok(statements) } fn export_sql_statement_bytes(database_type: Option, text: &str) -> usize { if database_type == Some(DatabaseType::SqlServer) { text.encode_utf16().count() * 2 } else { text.len() } } pub(crate) fn is_internal_export_column(database_type: Option, column: &str) -> bool { // Oracle-compatible ROWID is injected only to identify editable rows. It // is not a physical table column and must never propagate into exports. crate::sql_dialect::uses_oracle_row_id(database_type) && column.eq_ignore_ascii_case(crate::sql_dialect::DBX_ROWID_COLUMN) } fn is_postgres_tsvector_export_column(database_type: Option, column_type: Option<&str>) -> bool { database_type == Some(DatabaseType::Postgres) && column_type .map(|column_type| { let normalized = column_type.trim().trim_matches('"').to_ascii_lowercase(); normalized == "tsvector" || normalized.ends_with(".tsvector") }) .unwrap_or(false) } fn is_export_insert_column( database_type: Option, column: &str, column_type: Option<&str>, column_extra: Option<&str>, ) -> bool { !is_internal_export_column(database_type, column) && !is_postgres_tsvector_export_column(database_type, column_type) && (database_type != Some(DatabaseType::Mysql) || !is_mysql_generated_column_extra(column_extra)) } fn is_postgres_json_export_column(database_type: Option, column_type: Option<&str>) -> bool { database_type == Some(DatabaseType::Postgres) && column_type .map(|column_type| { let normalized = column_type.trim().trim_matches('"').to_ascii_lowercase(); matches!(normalized.as_str(), "json" | "jsonb") || normalized.ends_with(".json") || normalized.ends_with(".jsonb") }) .unwrap_or(false) } fn is_postgres_vector_export_column(database_type: Option, column_type: Option<&str>) -> bool { database_type == Some(DatabaseType::Postgres) && column_type .map(|column_type| { let normalized = column_type.trim().trim_matches('"').to_ascii_lowercase(); let base = normalized.split(['(', ' ', '\t', '\n']).next().unwrap_or("").trim_matches('"'); matches!(base, "vector" | "halfvec") || base.ends_with(".vector") || base.ends_with(".halfvec") }) .unwrap_or(false) } pub fn build_export_sql_insert(options: BuildExportSqlInsertOptions) -> Result { build_export_insert_statements(options.insert).map(|statements| statements.join("\n")) } pub fn build_database_sql_export(options: BuildDatabaseSqlExportOptions) -> Result { let exported_at = options.exported_at.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); let row_limit = options.row_limit_per_table.unwrap_or(DATABASE_EXPORT_ROW_LIMIT); let insert_batch_size = options.insert_batch_size.unwrap_or(DATABASE_EXPORT_INSERT_BATCH_SIZE); let mut lines = vec![ "-- DBX database export".to_string(), format!("-- Database: {}", options.database_name), format!("-- Exported at: {exported_at}"), format!("-- Row limit per table: {row_limit}"), String::new(), ]; for table in options.tables { if let Some(ddl) = table.ddl.as_ref().map(|ddl| ddl.trim()).filter(|ddl| !ddl.is_empty()) { let ddl = format_export_table_ddl( ddl, table.database_type, DdlNormalizeOptions { omit_auto_increment: options.omit_auto_increment }, ); lines.push(format!("-- Structure for {}", table.display_name)); lines.push(ddl); lines.push(String::new()); } lines.push(format!("-- Data for {}", table.display_name)); if table.truncated { lines.push(format!("-- Exported rows: {} (truncated at {row_limit})", table.rows.len())); } else { lines.push(format!("-- Exported rows: {}", table.rows.len())); } let inserts = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: table.database_type, identifier_quote: table.identifier_quote.clone(), schema: table.schema, table_name: table.table_name, qualified_table_name: table.qualified_table_name, columns: table.columns, column_types: table.column_types, column_extras: table.column_extras, rows: table.rows, batch_size: Some(insert_batch_size), })?; if inserts.is_empty() { lines.push("-- No rows".to_string()); } else { lines.extend(inserts); } lines.push(String::new()); } Ok(lines.join("\n")) } fn export_qualified_table_name( database_type: Option, schema: Option<&str>, table_name: Option<&str>, qualified_name: Option<&str>, identifier_quote: Option<&str>, ) -> Result { if let Some(name) = qualified_name.filter(|name| !name.trim().is_empty()) { return Ok(name.to_string()); } let table_name = table_name .filter(|name| !name.trim().is_empty()) .ok_or_else(|| "tableName is required when qualifiedTableName is not provided".to_string())?; if crate::sql_dialect::uses_connection_identifier_quote(database_type, identifier_quote) { return Ok(crate::sql_dialect::table_data_qualified_table_name( database_type, schema, table_name, identifier_quote, )); } Ok(qualified_table_name(database_type, schema, table_name)) } fn normalize_export_table_ddl( ddl: &str, database_type: Option, opts: crate::mysql_ddl_normalize::DdlNormalizeOptions, ) -> String { if database_type != Some(DatabaseType::Mysql) { return ddl.to_string(); } crate::mysql_ddl_normalize::normalize_mysql_export_ddl(ddl, opts) } fn format_export_table_ddl(ddl: &str, database_type: Option, opts: DdlNormalizeOptions) -> String { let ddl = normalize_export_table_ddl(ddl, database_type, opts); let ddl = ddl.trim().trim_end_matches(';').trim_end(); format!("{ddl};") } fn split_postgres_export_table_triggers(ddl: &str, database_type: DatabaseType) -> (String, Vec) { if database_type != DatabaseType::Postgres { return (ddl.to_string(), Vec::new()); } let mut table_statements = Vec::new(); let mut trigger_statements = Vec::new(); for range in crate::db::ddl_scan::top_level_statement_ranges(ddl) { let statement = ddl[range].trim(); if statement.is_empty() { continue; } let mut words = statement.split_ascii_whitespace().take(3).map(str::to_ascii_uppercase); let first = words.next(); let second = words.next(); let third = words.next(); if first.as_deref() == Some("CREATE") && (second.as_deref() == Some("TRIGGER") || (second.as_deref() == Some("CONSTRAINT") && third.as_deref() == Some("TRIGGER"))) { trigger_statements.push(statement.to_string()); } else { table_statements.push(statement.to_string()); } } (table_statements.join("\n"), trigger_statements) } fn postgres_sequence_qualified_name(schema: &str, sequence_name: &str) -> String { let db_type = DatabaseType::Postgres; if schema.trim().is_empty() { quote_identifier(sequence_name, &db_type) } else { format!("{}.{}", quote_identifier(schema, &db_type), quote_identifier(sequence_name, &db_type)) } } fn generate_postgres_sequence_create_ddl(sequence: &PostgresExportSequence, schema: &str) -> String { let qualified_name = postgres_sequence_qualified_name(schema, &sequence.name); let cycle = if sequence.cycle { "CYCLE" } else { "NO CYCLE" }; format!( "CREATE SEQUENCE IF NOT EXISTS {qualified_name}\n AS {data_type}\n START WITH {start_value}\n INCREMENT BY {increment}\n MINVALUE {min_value}\n MAXVALUE {max_value}\n CACHE {cache_value}\n {cycle}", data_type = sequence.data_type, start_value = sequence.start_value, increment = sequence.increment, min_value = sequence.min_value, max_value = sequence.max_value, cache_value = sequence.cache_value, ) } fn generate_postgres_sequence_owner_ddl(sequence: &PostgresExportSequence, schema: &str) -> Option { let owner_table = sequence.owner_table.as_deref()?; let owner_column = sequence.owner_column.as_deref()?; Some(format!( "ALTER SEQUENCE {} OWNED BY {}.{}", postgres_sequence_qualified_name(schema, &sequence.name), crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres, None), quote_identifier(owner_column, &DatabaseType::Postgres) )) } fn generate_postgres_sequence_setval_sql(sequence: &PostgresExportSequence, schema: &str) -> Option { let last_value = sequence.last_value.as_deref()?.trim(); if last_value.is_empty() { return None; } let sequence_literal = quote_postgres_string_literal(&postgres_sequence_qualified_name(schema, &sequence.name)); match (sequence.owner_table.as_deref(), sequence.owner_column.as_deref()) { (Some(owner_table), Some(owner_column)) => { let owner_table = crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres, None); let owner_column = quote_identifier(owner_column, &DatabaseType::Postgres); Some(format!( "SELECT setval({sequence_literal}, GREATEST(COALESCE(MAX({owner_column}), {last_value}), {last_value}), true) FROM {owner_table}" )) } _ => Some(format!("SELECT setval({sequence_literal}, {last_value}, true)")), } } fn generate_postgres_extension_ddl(extension: &PostgresExportExtension) -> String { // Match pg_dump: omit VERSION so the target installation selects its // default compatible version, while preserving the source schema. format!( "CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {};", quote_identifier(&extension.name, &DatabaseType::Postgres), quote_identifier(&extension.schema, &DatabaseType::Postgres) ) } async fn list_postgres_extension_members( state: &crate::connection::AppState, pool_key: &str, schema: &str, ) -> Result { let pool = { let connections = state.connections.read().await; match connections.get(pool_key) { Some(crate::connection::PoolKind::Postgres(pool)) => pool.clone(), _ => return Ok(PostgresExtensionMembers::default()), } }; let mut members = PostgresExtensionMembers::default(); for (kind, name, signature) in crate::db::postgres::list_extension_member_objects(&pool, schema).await? { if kind == "RELATION" { members.relation_names.insert(name); } else if kind == "FUNCTION" { members.function_keys.insert((name, signature)); } } Ok(members) } fn is_postgres_extension_member_routine(object: &crate::types::ObjectInfo, members: &PostgresExtensionMembers) -> bool { members.function_keys.contains(&(object.name.clone(), object.signature.clone().unwrap_or_default())) } async fn list_postgres_export_sequences( state: &crate::connection::AppState, pool_key: &str, schema: &str, selected_tables: &[String], excluded_tables: &[String], include_objects: bool, fail_on_error: bool, ) -> Result, String> { let pool = { let connections = state.connections.read().await; match connections.get(pool_key) { Some(crate::connection::PoolKind::Postgres(pool)) => pool.clone(), _ => return Ok(Vec::new()), } }; let client = pool.get().await.map_err(|e| e.to_string())?; let rows = client .query( "SELECT c.relname, \ COALESCE(format_type(s.seqtypid, NULL), 'bigint'), \ COALESCE(s.seqstart::text, '1'), \ COALESCE(s.seqmin::text, '1'), \ COALESCE(s.seqmax::text, '9223372036854775807'), \ COALESCE(s.seqincrement::text, '1'), \ COALESCE(s.seqcycle, false), \ COALESCE(s.seqcache::text, '1'), \ t.relname, \ a.attname \ FROM pg_class c \ JOIN pg_namespace n ON n.oid = c.relnamespace \ LEFT JOIN pg_sequence s ON s.seqrelid = c.oid \ LEFT JOIN pg_depend d ON d.classid = 'pg_class'::regclass \ AND d.objid = c.oid \ AND d.refclassid = 'pg_class'::regclass \ AND d.deptype IN ('a', 'i') \ LEFT JOIN pg_class t ON t.oid = d.refobjid \ LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace AND tn.nspname = n.nspname \ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \ WHERE c.relkind = 'S' AND n.nspname = $1 \ ORDER BY c.relname", &[&schema], ) .await .map_err(|e| e.to_string())?; let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect(); let excluded: HashSet<&str> = excluded_tables.iter().map(String::as_str).collect(); let mut sequences = rows .iter() .map(|row| PostgresExportSequence { name: row.get::<_, String>(0), data_type: row.get::<_, String>(1), start_value: row.get::<_, String>(2), min_value: row.get::<_, String>(3), max_value: row.get::<_, String>(4), increment: row.get::<_, String>(5), cycle: row.get::<_, bool>(6), cache_value: row.get::<_, String>(7), last_value: None, owner_table: row.get::<_, Option>(8), owner_column: row.get::<_, Option>(9), }) .filter(|sequence| { sequence.owner_table.as_deref().is_none_or(|owner_table| !excluded.contains(owner_table)) && (selected.is_empty() || sequence.owner_table.as_deref().is_some_and(|owner_table| selected.contains(owner_table))) }) .filter(|sequence| sequence.owner_table.is_some() || (include_objects && selected.is_empty())) .collect::>(); if sequences.is_empty() { return Ok(sequences); } let last_values = client .query( "SELECT c.relname, pg_sequence_last_value(c.oid)::text \ FROM pg_class c \ JOIN pg_namespace n ON n.oid = c.relnamespace \ WHERE c.relkind = 'S' AND n.nspname = $1", &[&schema], ) .await; match last_values { Ok(rows) => { for row in rows { let name: String = row.get(0); let last_value: Option = row.get(1); if let Some(sequence) = sequences.iter_mut().find(|sequence| sequence.name == name) { sequence.last_value = last_value; } } } Err(error) if fail_on_error => return Err(error.to_string()), Err(_) => {} } Ok(sequences) } pub fn is_export_cancelled_now(export_id: &str) -> bool { EXPORT_CANCELLED.read().map(|cancelled| cancelled.contains(export_id)).unwrap_or(false) } pub async fn is_export_cancelled(export_id: &str) -> bool { is_export_cancelled_now(export_id) } pub async fn set_export_cancelled(export_id: &str) { if let Ok(mut cancelled) = EXPORT_CANCELLED.write() { cancelled.insert(export_id.to_string()); } } pub async fn clear_export_cancelled(export_id: &str) { if let Ok(mut cancelled) = EXPORT_CANCELLED.write() { cancelled.remove(export_id); } } /// Await one export operation while still observing the export-id cancellation /// marker. Metadata helpers ultimately await database-driver futures that do /// not accept a cancellation token; polling here keeps the export task /// responsive and dropping the pending future follows the same bounded /// prefetch cancellation behavior used below. async fn await_export_operation(export_id: &str, operation: F) -> Result where F: Future>, { tokio::pin!(operation); loop { if is_export_cancelled_now(export_id) { return Err(EXPORT_CANCELLED_ERROR.to_string()); } tokio::select! { biased; result = &mut operation => { return if is_export_cancelled_now(export_id) { Err(EXPORT_CANCELLED_ERROR.to_string()) } else { result }; }, _ = tokio::time::sleep(EXPORT_CANCEL_POLL_INTERVAL) => {} } } } pub async fn begin_database_backup_snapshot_core( state: &crate::connection::AppState, connection_id: &str, database: &str, ) -> Result { let db_type = state .configs .read() .await .get(connection_id) .map(|config| config.db_type) .ok_or_else(|| format!("Connection config not found: {connection_id}"))?; if !matches!(db_type, DatabaseType::Mysql | DatabaseType::Postgres) { return Err("Consistent database backup snapshots are only supported for MySQL and PostgreSQL".to_string()); } let session_id = crate::query::begin_database_backup_snapshot(state, connection_id, database).await?; let schemas = if matches!(db_type, DatabaseType::Postgres) { const POSTGRES_BACKUP_SCHEMAS_SQL: &str = "SELECT n.nspname FROM pg_catalog.pg_namespace n \ WHERE n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') \ AND n.nspname NOT LIKE 'pg_toast_temp_%' \ AND n.nspname NOT LIKE 'pg_temp_%' ORDER BY n.nspname"; let results = crate::query::execute_in_manual_transaction( state, &session_id, POSTGRES_BACKUP_SCHEMAS_SQL, database, None, Some(10_000), ) .await?; results .into_iter() .next() .map(|result| { result .rows .into_iter() .filter_map(|row| row.into_iter().next()) .filter_map(|value| value.as_str().map(str::to_string)) .collect() }) .unwrap_or_default() } else { vec![database.to_string()] }; if schemas.is_empty() { let _ = crate::query::rollback_manual_transaction(state, &session_id).await; return Err(format!("No schemas are available in database {database}")); } Ok(DatabaseBackupSnapshot { session_id, schemas }) } /// 只有确认底层支持并发请求的多连接池才允许并发预取导出元数据。 /// SqlServer(Arc 串行客户端)、Agent/ExternalDriver(插件请求超时覆盖 /// 排队时间且超时会终止 sidecar)、SQLite/DuckDB 等单连接类型都必须回退串行。 fn concurrent_metadata_prefetch_allowed(pool_kind: Option<&crate::connection::PoolKind>) -> bool { matches!( pool_kind, Some(crate::connection::PoolKind::Postgres(_)) | Some(crate::connection::PoolKind::Mysql(..)) | Some(crate::connection::PoolKind::ClickHouse(_)) ) } fn database_export_metadata_prefetch_concurrency(db_type: DatabaseType) -> usize { // PostgreSQL exports can hold a snapshot connection while metadata and UI // requests share the base pool. Keep enough capacity available for normal // browsing instead of filling nearly the entire ten-connection pool. if db_type == DatabaseType::Postgres { 4 } else { 8 } } fn record_export_error(file: &mut W, fail_on_error: bool, message: String) -> Result<(), String> { if fail_on_error { Err(message) } else { writeln!(file, "-- ERROR {message}").map_err(|error| format!("Failed to write file: {error}")) } } fn mysql_spatial_export_marker_expression(column: &str) -> String { let quoted = quote_identifier(column, &DatabaseType::Mysql); format!( "CASE WHEN {quoted} IS NULL THEN NULL ELSE CONCAT('DBX_WKB:', ST_SRID({quoted}), ':', HEX(ST_AsWKB({quoted}))) END AS {quoted}" ) } fn database_export_select_list(columns: &[String], column_types: &[Option], db_type: &DatabaseType) -> String { columns .iter() .enumerate() .map(|(index, column)| { if *db_type == DatabaseType::Mysql && column_types.get(index).and_then(|value| value.as_deref()).is_some_and(is_mysql_spatial_export_type) { mysql_spatial_export_marker_expression(column) } else { quote_identifier(column, db_type) } }) .collect::>() .join(", ") } pub(crate) fn replace_database_export_select_list( sql: String, columns: &[String], column_types: &[Option], db_type: &DatabaseType, ) -> String { let original = columns.iter().map(|column| quote_identifier(column, db_type)).collect::>().join(", "); let replacement = database_export_select_list(columns, column_types, db_type); if replacement == original { return sql; } let prefix = format!("SELECT {original}"); if !sql.starts_with(&prefix) { log::warn!( "MySQL spatial database export could not replace its SELECT list; geometry columns will be exported as WKT" ); return sql; } format!("SELECT {replacement}{}", &sql[prefix.len()..]) } fn database_export_select_sql( columns: &[String], column_types: &[Option], table: &str, schema: &str, db_type: &DatabaseType, ) -> String { let columns = database_export_select_list(columns, column_types, db_type); let table = crate::transfer::qualified_table(table, schema, db_type, None); format!("SELECT {columns} FROM {table}") } fn write_database_export_rows( file: &mut W, rows: &[Vec], columns: &[String], column_types: &[Option], column_extras: &[Option], table: &str, schema: &str, db_type: &DatabaseType, ) -> Result<(), String> { let insert_indices = columns .iter() .enumerate() .filter(|(index, column)| { is_export_insert_column( Some(*db_type), column, column_types.get(*index).and_then(|value| value.as_deref()), column_extras.get(*index).and_then(|value| value.as_deref()), ) }) .map(|(index, _)| index) .collect::>(); if insert_indices.is_empty() { return Ok(()); } let filtered_columns; let filtered_column_types; let filtered_column_extras; let filtered_rows; let (insert_columns, insert_column_types, insert_column_extras, insert_rows) = if insert_indices.len() == columns.len() { (columns, column_types, column_extras, rows) } else { filtered_columns = insert_indices.iter().map(|index| columns[*index].clone()).collect::>(); filtered_column_types = insert_indices.iter().map(|index| column_types.get(*index).cloned().unwrap_or(None)).collect::>(); filtered_column_extras = insert_indices.iter().map(|index| column_extras.get(*index).cloned().unwrap_or(None)).collect::>(); filtered_rows = rows .iter() .map(|row| insert_indices.iter().map(|index| row.get(*index).cloned().unwrap_or(Value::Null)).collect()) .collect::>>(); ( filtered_columns.as_slice(), filtered_column_types.as_slice(), filtered_column_extras.as_slice(), filtered_rows.as_slice(), ) }; // Database exports use the same table qualification as the SELECT/DDL // path and the legacy typed INSERT writer. In particular, MySQL uses the // selected database rather than a schema-qualified table name. let qualified_table_name = crate::transfer::qualified_table(table, schema, db_type, None); // Batch database exports do not currently thread a per-connection identifier // quote through BuildDatabaseSqlExportOptions; Kingbase MySQL-compat users // should fall back to the single-table export path which carries the quote. let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(*db_type), identifier_quote: None, schema: (!schema.is_empty()).then(|| schema.to_string()), table_name: Some(table.to_string()), qualified_table_name: Some(qualified_table_name), columns: insert_columns.to_vec(), column_types: insert_column_types.to_vec(), column_extras: insert_column_extras.to_vec(), rows: insert_rows.to_vec(), batch_size: Some(DATABASE_EXPORT_INSERT_BATCH_SIZE), })?; for statement in statements { writeln!(file, "{statement}\n").map_err(|error| format!("Failed to write file: {error}"))?; } Ok(()) } fn emit_database_export_running( on_progress: &(impl Fn(ExportProgress) + Sync), export_id: &str, current_object: impl Into, object_index: usize, total_objects: usize, rows_exported: u64, preparing: bool, ) { on_progress(ExportProgress { export_id: export_id.to_string(), current_object: current_object.into(), object_index, total_objects, rows_exported, total_rows: None, status: ExportStatus::Running, error: None, preparing, }); } fn emit_database_export_cancelled( on_progress: &(impl Fn(ExportProgress) + Sync), export_id: &str, current_object: impl Into, object_index: usize, total_objects: usize, rows_exported: u64, ) { on_progress(ExportProgress { export_id: export_id.to_string(), current_object: current_object.into(), object_index, total_objects, rows_exported, total_rows: None, status: ExportStatus::Cancelled, error: None, preparing: false, }); } fn export_destination_state_key(dir: &std::path::Path) -> String { format!("database_export_destination:{}", dir.to_string_lossy()) } /// Records the destination identity for a scheduled backup as soon as it is /// configured, not just after its first successful export. Scheduled plans /// live in the frontend and may not run for hours after being saved; without /// an eager record here, a destination whose mount disappears before its /// very first run is indistinguishable from a brand-new local folder to /// `ensure_export_destination_dir` (both have no recorded state) and gets /// silently recreated on the wrong filesystem. The schedule editor only /// accepts directories selected from the filesystem, so a missing path here /// means the destination vanished before its identity could be recorded and /// the schedule must not be saved. See #6327. pub async fn record_export_destination_identity( state: &crate::connection::AppState, dir: &std::path::Path, ) -> Result<(), String> { if !dir.is_dir() { return Err(format!( "Backup directory {} does not exist or is not a directory. Select an existing destination before saving the schedule.", dir.display() )); } save_export_destination_dir_device_id(state, dir).await } async fn save_export_destination_dir_device_id( state: &crate::connection::AppState, dir: &std::path::Path, ) -> Result<(), String> { let value = match export_destination_device_id_for_path(dir) { Some(dev) => dev.to_le_bytes().to_vec(), None => Vec::new(), }; state.storage.save_state(&export_destination_state_key(dir), &value, "application/octet-stream").await } /// Ensures `dir` exists for an export destination, without ever silently /// recreating a directory that previously produced a successful export (or /// was recorded via [`record_export_destination_identity`]) and has since /// disappeared. Auto-creating on every run is what the original fix for /// #6109 did, but that is unsafe for a destination on a removable or network /// drive: if the mount is temporarily gone when a run executes, blindly /// recreating the path resurrects it on the local root filesystem and the /// export "succeeds" while silently writing to the wrong disk. A directory /// dbx has never seen before is safe to create (normal first-time /// configuration of a local folder); a directory dbx has seen before but /// that is now missing, or that now resolves to a different filesystem than /// last time, is refused instead. See #6327. /// /// Returns the device identity that was just verified (or recorded for a /// newly created directory), if the current platform can determine one, so /// the caller can re-verify it against the file it actually opens -- the /// directory check here and the later `File::create` are separate /// operations, and the mount can change in between. async fn ensure_export_destination_dir( state: &crate::connection::AppState, dir: &std::path::Path, ) -> Result, String> { let key = export_destination_state_key(dir); let recorded_dev: Option> = state .storage .load_state(&key) .await? .map(|(bytes, _content_type)| <[u8; 8]>::try_from(bytes.as_slice()).ok().map(u64::from_le_bytes)); if dir.is_dir() { if let Some(Some(recorded_dev)) = recorded_dev { if let Some(current_dev) = export_destination_device_id_for_path(dir) { if current_dev != recorded_dev { return Err(format!( "Backup directory {} now resolves to a different filesystem than the last \ successful export to this location. Refusing to write here automatically -- \ if this directory is on a removable or network drive, make sure the correct \ drive is connected before running the backup.", dir.display() )); } } } } else { if recorded_dev.is_some() { return Err(format!( "Backup directory {} is missing. It was configured or previously used for exports to \ this location, so dbx will not recreate it automatically -- if this is on a removable \ or network drive, reconnect it and try again.", dir.display() )); } std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create backup directory: {e}"))?; } save_export_destination_dir_device_id(state, dir).await?; Ok(export_destination_device_id_for_path(dir)) } /// Whether a destination's identity, checked once via [`ensure_export_destination_dir`] /// and then again against the file dbx actually opened, indicates the mount /// changed in between. An unknown expected identity has nothing to compare, /// but once an expected identity is known, failing to identify the opened /// handle must fail closed rather than allowing an unverified write. fn export_destination_identity_mismatch(expected: Option, actual: Option) -> bool { expected.is_some_and(|expected| actual != Some(expected)) } #[cfg(unix)] fn export_destination_device_id_for_path(dir: &std::path::Path) -> Option { use std::os::unix::fs::MetadataExt; std::fs::metadata(dir).ok().map(|metadata| metadata.dev()) } #[cfg(unix)] fn export_destination_device_id_for_file(file: &std::fs::File) -> Option { use std::os::unix::fs::MetadataExt; file.metadata().ok().map(|metadata| metadata.dev()) } #[cfg(windows)] fn export_destination_device_id_for_path(dir: &std::path::Path) -> Option { windows_export_destination::device_id_for_path(dir) } #[cfg(windows)] fn export_destination_device_id_for_file(file: &std::fs::File) -> Option { windows_export_destination::device_id_for_handle(file) } #[cfg(not(any(unix, windows)))] fn export_destination_device_id_for_path(_dir: &std::path::Path) -> Option { None } #[cfg(not(any(unix, windows)))] fn export_destination_device_id_for_file(_file: &std::fs::File) -> Option { None } /// Windows has no stable `std` API for a directory or file's volume identity /// (`MetadataExt::volume_serial_number` is still gated behind the unstable /// `windows_by_handle` feature), so this queries `dwVolumeSerialNumber` from /// `BY_HANDLE_FILE_INFORMATION` directly. Using the *handle* rather than /// re-resolving the path is what makes `export_destination_device_id_for_file` /// safe to call on an already-open `File`: it reports the volume the handle /// was actually opened against, not whatever currently sits at that path. #[cfg(windows)] mod windows_export_destination { use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; use std::path::Path; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }; pub(super) fn device_id_for_path(path: &Path) -> Option { let mut wide: Vec = path.as_os_str().encode_wide().collect(); wide.push(0); let handle = unsafe { CreateFileW( wide.as_ptr(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null(), OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, std::ptr::null_mut(), ) }; if handle == INVALID_HANDLE_VALUE { return None; } let result = volume_serial_number(handle); unsafe { CloseHandle(handle) }; result } pub(super) fn device_id_for_handle(file: &std::fs::File) -> Option { volume_serial_number(file.as_raw_handle() as HANDLE) } fn volume_serial_number(handle: HANDLE) -> Option { let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; let ok = unsafe { GetFileInformationByHandle(handle, &mut info) }; (ok != 0).then_some(info.dwVolumeSerialNumber as u64) } } pub async fn export_database_sql_core( state: &crate::connection::AppState, request: &DatabaseExportRequest, on_progress: impl Fn(ExportProgress) + Sync, ) -> Result<(), String> { // Keep the large export state machine on the heap. Besides making the // caller future small, this prevents the metadata-prefetch locals from // exhausting the bounded stack used by test and runtime worker threads. let result = Box::pin(export_database_sql_core_inner(state, request, &on_progress)).await; if result.as_ref().err().is_some_and(|error| error == EXPORT_CANCELLED_ERROR) { // Every caller (Tauri and web SSE) needs a terminal Cancelled event; // returning the marker as an error would leave the web EventSource // without a terminal event and surface cancellation as Error in Tauri. emit_database_export_cancelled(&on_progress, &request.export_id, String::new(), 0, 0, 0); Ok(()) } else { result } } async fn export_database_sql_core_inner( state: &crate::connection::AppState, request: &DatabaseExportRequest, on_progress: impl Fn(ExportProgress) + Sync, ) -> Result<(), String> { let _snapshot_keep_alive = if let Some(snapshot_session_id) = request.snapshot_session_id.as_deref() { Some(crate::query::keep_manual_transaction_alive(state, snapshot_session_id).await?) } else { None }; // Emit immediately so the UI is never blank while we list schema metadata. emit_database_export_running(&on_progress, &request.export_id, "", 0, 0, 0, true); // 1. Get database type let db_type = state .configs .read() .await .get(&request.connection_id) .map(|c| c.db_type) .ok_or_else(|| format!("Connection config not found: {}", request.connection_id))?; // 2. Get pool let client_session_id = database_export_client_session_id(&request.export_id); let pool_key = Box::pin(state.get_or_create_pool_for_session( &request.connection_id, Some(&request.database), Some(&client_session_id), )) .await?; // 3. List tables let all_tables = Box::pin(crate::schema::list_tables_core( state, &request.connection_id, &request.database, &request.schema, None, None, None, None, None, )) .await?; // 4. Create file let mut expected_destination_dev = None; if let Some(parent) = std::path::Path::new(&request.file_path).parent() { if !parent.as_os_str().is_empty() { expected_destination_dev = ensure_export_destination_dir(state, parent).await?; } } let file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?; // The directory check above and this `File::create` are separate // operations: the mount can disappear and be replaced by something else // at the same path in between. Re-check the identity of the handle we // actually opened, not just the path, and refuse to keep a backup that // landed on the wrong filesystem. See #6327. if export_destination_identity_mismatch(expected_destination_dev, export_destination_device_id_for_file(&file)) { drop(file); let _ = std::fs::remove_file(&request.file_path); return Err(format!( "Backup destination for {} changed while opening the output file -- the directory now \ resolves to a different filesystem than the one just verified. If a removable or network \ drive was disconnected and reconnected, retry the backup.", request.file_path )); } let mut file = BufWriter::new(file); let create_database_preamble = if request.include_create_database && matches!(db_type, DatabaseType::Mysql) { Some(mysql_database_export_preamble_for_request(state, request).await) } else { None }; // 5. Write header let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); writeln!(file, "-- Database export: {}", request.database).map_err(|e| format!("Failed to write file: {e}"))?; writeln!(file, "-- Date: {timestamp}").map_err(|e| format!("Failed to write file: {e}"))?; writeln!(file, "-- Generated by DBX").map_err(|e| format!("Failed to write file: {e}"))?; writeln!(file).map_err(|e| format!("Failed to write file: {e}"))?; if let Some(preamble) = create_database_preamble { writeln!(file, "-- Database setup").map_err(|e| format!("Failed to write file: {e}"))?; writeln!(file, "{preamble}").map_err(|e| format!("Failed to write file: {e}"))?; } // 6. For MySQL: disable foreign key checks if matches!(db_type, DatabaseType::Mysql) { writeln!(file, "SET FOREIGN_KEY_CHECKS = 0;\n").map_err(|e| format!("Failed to write file: {e}"))?; } // 7. Separate tables and views let postgres_extension_members = if matches!(db_type, DatabaseType::Postgres) && (request.include_structure || request.include_objects) { match list_postgres_extension_members(state, &pool_key, &request.schema).await { Ok(members) => members, Err(e) => { record_export_error(&mut file, request.fail_on_error, format!("reading extension members: {e}"))?; PostgresExtensionMembers::default() } } } else { PostgresExtensionMembers::default() }; let postgres_extensions = if request.include_structure && matches!(db_type, DatabaseType::Postgres) { match crate::schema::list_extensions_core( state, &request.connection_id, &request.database, Some(&request.schema), ) .await { Ok(extensions) => extensions .into_iter() .map(|extension| PostgresExportExtension { name: extension.name, schema: extension.schema.unwrap_or_else(|| request.schema.clone()), }) .collect(), Err(e) => { record_export_error(&mut file, request.fail_on_error, format!("exporting extensions: {e}"))?; Vec::new() } } } else { Vec::new() }; let all_tables = filter_export_table_infos(all_tables, &request.selected_tables, &request.excluded_tables) .into_iter() .filter(|table| !postgres_extension_members.relation_names.contains(&table.name)) .collect::>(); let mut tables: Vec<_> = all_tables.iter().filter(|t| !t.table_type.contains("VIEW")).collect(); let mut views: Vec<_> = all_tables.iter().filter(|t| t.table_type.contains("VIEW")).collect(); if request.include_objects && db_type == DatabaseType::Mysql && views.len() > 1 { match list_mysql_export_view_dependencies(state, &request.connection_id, &request.database).await { Ok(dependencies) => views = sort_export_views_by_dependencies(&views, &dependencies), Err(error) => { log::debug!( "[database-export] failed to resolve MySQL view dependencies for {}: {error}", request.database ); } } } let postgres_sequences = if request.include_structure && matches!(db_type, DatabaseType::Postgres) { match await_export_operation( &request.export_id, list_postgres_export_sequences( state, &pool_key, &request.schema, &request.selected_tables, &request.excluded_tables, request.include_objects, request.fail_on_error, ), ) .await { Ok(sequences) => sequences, Err(e) if e == EXPORT_CANCELLED_ERROR => return Err(EXPORT_CANCELLED_ERROR.to_string()), Err(e) => { record_export_error(&mut file, request.fail_on_error, format!("exporting sequences: {e}"))?; Vec::new() } } } else { Vec::new() }; // Sort tables by foreign key dependency so referenced (parent) tables are // exported before referencing (child) tables. MySQL exports always emit // `SET FOREIGN_KEY_CHECKS = 0`, so both the generated DDL and any INSERTs // already tolerate child-before-parent ordering; reordering away from the // alphabetical listing there only makes the output look shuffled to users // without providing any correctness benefit. if tables.len() > 1 && db_type != DatabaseType::Mysql { let table_names: Vec = tables.iter().map(|t| t.name.clone()).collect(); match crate::transfer::sort_tables_by_fk_dependency( state, &request.connection_id, &request.database, &request.schema, &table_names, true, ) .await { Ok(sorted_names) => { tables.sort_by_key(|t| sorted_names.iter().position(|n| n == &t.name).unwrap_or(usize::MAX)); } Err(error) if request.fail_on_error => { return Err(format!("Failed to resolve table dependency order: {error}")); } Err(_) => {} } } // 8. Discover optional schema-wide objects before calculating workload. let mut procedures: Vec = Vec::new(); let mut functions: Vec = Vec::new(); if exports_database_routines(request) { match crate::schema::list_objects_core( state, &request.connection_id, &request.database, &request.schema, None, None, None, None, None, ) .await { Ok(objects) => { for obj in &objects { let ot = obj.object_type.to_uppercase(); if is_postgres_extension_member_routine(obj, &postgres_extension_members) { continue; } if ot.contains("PROCEDURE") { procedures.push(obj.clone()); } else if ot.contains("FUNCTION") { functions.push(obj.clone()); } } } Err(error) if request.fail_on_error => return Err(format!("Failed to list database objects: {error}")), Err(_) => {} } } // A determinate total must describe the same object categories guarded by // the execution branches below, not every object discovered in the schema. let total_objects = database_export_total_objects( request, &DatabaseExportObjectCounts { tables: tables.len(), views: views.len(), sequences: postgres_sequences.len(), extensions: postgres_extensions.len(), procedures: procedures.len(), functions: functions.len(), }, ); let mut object_index: usize = 0; let mut total_rows_exported = 0_u64; let mut deferred_postgres_triggers = Vec::new(); // total_objects is known later for the write phase; preparing updates stay // presence-only so the UI does not show a counter that later resets. emit_database_export_running(&on_progress, &request.export_id, "", 0, 0, 0, true); let batch_size = if request.batch_size == 0 { 1000 } else { request.batch_size }; // 预取各表的 DDL 与列元数据:逐表串行往返在多表数据库上是整库导出耗时的 // 主要来源(每表 1-2 次网络往返 × 表数)。有界并发预取后,下方写出循环仍按 // 原顺序消费,文件内容与逐表查询完全一致。放在写出循环之前,避免准备态与导出态来回跳。 struct PrefetchedTableMetadata { ddl: Option>, columns: Option, String>>, } let mut prefetched_table_metadata: Vec> = Vec::new(); prefetched_table_metadata.resize_with(tables.len(), || None); // 防护门按「实际连接池种类」放行,而非数据库类型的能力标记:只有确认底层 // 支持并发请求的多连接池(Postgres/MySQL/ClickHouse)才启用并发预取。反例: // 原生 SQL Server 是 Arc> 串行客户端;Agent/外部 JDBC(含 SQL Server // legacy profile、PrestoSQL 等路由结果)的插件请求超时覆盖排队时间且超时会 // 终止 sidecar;SQLite/DuckDB 等为单连接。被挡住的场景预取 Vec 保持全 None, // 写出循环内的 None 回退路径即原有的逐表串行直查行为。 let concurrent_prefetch_is_safe = match state.get_or_create_pool(&request.connection_id, Some(&request.database)).await { Ok(metadata_pool_key) => { concurrent_metadata_prefetch_allowed(state.connections.read().await.get(&metadata_pool_key)) } // 建池失败时不预取,让写出循环的直查路径按原有方式报告错误 Err(_) => false, }; if concurrent_prefetch_is_safe && exports_database_tables(request) && !tables.is_empty() && !is_export_cancelled(&request.export_id).await { use futures::StreamExt; let prefetch_targets: Vec<(usize, String)> = tables.iter().enumerate().map(|(index, table_info)| (index, table_info.name.clone())).collect(); let mut prefetch_stream = futures::stream::iter(prefetch_targets.into_iter().map(|(index, table_name)| async move { if is_export_cancelled_now(&request.export_id) { return (index, PrefetchedTableMetadata { ddl: None, columns: None }); } let ddl = if request.include_structure { Some( await_export_operation( &request.export_id, crate::schema::get_table_relation_export_ddl_core( state, &request.connection_id, &request.database, &request.schema, &table_name, None, ), ) .await, ) } else { None }; if is_export_cancelled_now(&request.export_id) { return (index, PrefetchedTableMetadata { ddl, columns: None }); } let columns = if request.include_data { Some( await_export_operation( &request.export_id, crate::schema::get_columns_core( state, &request.connection_id, &request.database, &request.schema, &table_name, ), ) .await, ) } else { None }; (index, PrefetchedTableMetadata { ddl, columns }) })) .buffer_unordered(database_export_metadata_prefetch_concurrency(db_type)); while let Some((index, metadata)) = prefetch_stream.next().await { if metadata .ddl .as_ref() .is_some_and(|result| result.as_ref().err().is_some_and(|error| error == EXPORT_CANCELLED_ERROR)) || metadata .columns .as_ref() .is_some_and(|result| result.as_ref().err().is_some_and(|error| error == EXPORT_CANCELLED_ERROR)) { return Err(EXPORT_CANCELLED_ERROR.to_string()); } prefetched_table_metadata[index] = Some(metadata); if let Some(table_info) = tables.get(index) { // Presence-only updates: no prepare counter that later resets to 0/N. emit_database_export_running( &on_progress, &request.export_id, table_info.name.clone(), 0, 0, total_rows_exported, true, ); } // 取消后不再调度新的预取任务(已在途的任务随 stream 释放而中止), // 写出循环入口的取消检查负责最终收尾 if is_export_cancelled_now(&request.export_id) { break; } } } for extension in &postgres_extensions { if is_export_cancelled(&request.export_id).await { return Err("Export cancelled".to_string()); } emit_database_export_running( &on_progress, &request.export_id, extension.name.clone(), object_index, total_objects, total_rows_exported, false, ); writeln!(file, "{}\n", generate_postgres_extension_ddl(extension)) .map_err(|e| format!("Failed to write file: {e}"))?; object_index += 1; } for sequence in postgres_sequences.iter().filter(|sequence| sequence.owner_table.is_none()) { if is_export_cancelled(&request.export_id).await { return Err("Export cancelled".to_string()); } emit_database_export_running( &on_progress, &request.export_id, sequence.name.clone(), object_index, total_objects, total_rows_exported, false, ); writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema)) .map_err(|e| format!("Failed to write file: {e}"))?; object_index += 1; } for (table_index, table_info) in tables.iter().enumerate().filter(|_| exports_database_tables(request)) { // Check cancellation if is_export_cancelled(&request.export_id).await { emit_database_export_cancelled( &on_progress, &request.export_id, table_info.name.clone(), object_index, total_objects, total_rows_exported, ); return Ok(()); } let table_name = &table_info.name; // Emit Running progress on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: table_name.clone(), object_index, total_objects, rows_exported: total_rows_exported, total_rows: None, status: ExportStatus::Running, error: None, preparing: false, }); // Export structure if request.include_structure { if request.drop_table_if_exists { writeln!(file, "{}\n", drop_table_if_exists_sql(table_name, &request.schema, &db_type)) .map_err(|e| format!("Failed to write file: {e}"))?; } for sequence in postgres_sequences .iter() .filter(|sequence| sequence.owner_table.as_deref() == Some(table_name.as_str())) { on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: sequence.name.clone(), object_index, total_objects, rows_exported: total_rows_exported, total_rows: None, status: ExportStatus::Running, error: None, preparing: false, }); writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema)) .map_err(|e| format!("Failed to write file: {e}"))?; object_index += 1; } let ddl_result = match prefetched_table_metadata .get_mut(table_index) .and_then(|m| m.as_mut()) .and_then(|m| m.ddl.take()) { Some(result) => result, None => { await_export_operation( &request.export_id, crate::schema::get_table_relation_export_ddl_core( state, &request.connection_id, &request.database, &request.schema, table_name, None, ), ) .await } }; if ddl_result.as_ref().err().is_some_and(|error| error == EXPORT_CANCELLED_ERROR) { return Err(EXPORT_CANCELLED_ERROR.to_string()); } match ddl_result { Ok(ddl) => { let (ddl, triggers) = split_postgres_export_table_triggers(&ddl, db_type); deferred_postgres_triggers.extend(triggers); let ddl = format_export_table_ddl( &ddl, Some(db_type), DdlNormalizeOptions { omit_auto_increment: request.omit_auto_increment }, ); writeln!(file, "{ddl}\n").map_err(|e| format!("Failed to write file: {e}"))?; } Err(e) => { record_export_error( &mut file, request.fail_on_error, format!("exporting table structure {table_name}: {e}"), )?; } } } // Export data if request.include_data { // Get columns let columns_result = match prefetched_table_metadata .get_mut(table_index) .and_then(|m| m.as_mut()) .and_then(|m| m.columns.take()) { Some(result) => result, None => { await_export_operation( &request.export_id, crate::schema::get_columns_core( state, &request.connection_id, &request.database, &request.schema, table_name, ), ) .await } }; if columns_result.as_ref().err().is_some_and(|error| error == EXPORT_CANCELLED_ERROR) { return Err(EXPORT_CANCELLED_ERROR.to_string()); } let columns = match columns_result { Ok(cols) => cols, Err(e) => { record_export_error( &mut file, request.fail_on_error, format!("exporting columns for table {table_name}: {e}"), )?; object_index += 1; continue; } }; let col_names = columns.iter().map(|c| c.name.clone()).collect::>(); let col_types = columns.iter().map(|c| Some(c.data_type.clone())).collect::>(); let col_extras = columns.iter().map(|c| c.extra.clone()).collect::>(); if !col_names.is_empty() { if let Some(snapshot_session_id) = request.snapshot_session_id.as_deref() { let sql = database_export_select_sql(&col_names, &col_types, table_name, &request.schema, &db_type); crate::query::stream_rows_in_manual_transaction( state, snapshot_session_id, &sql, batch_size, |rows| { if is_export_cancelled_now(&request.export_id) { return Err("Export cancelled".to_string()); } write_database_export_rows( &mut file, &rows, &col_names, &col_types, &col_extras, table_name, &request.schema, &db_type, )?; total_rows_exported += rows.len() as u64; on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: table_name.clone(), object_index, total_objects, rows_exported: total_rows_exported, total_rows: None, status: ExportStatus::Running, error: None, preparing: false, }); Ok(()) }, ) .await?; } else { // Exact COUNT(*) is deliberately skipped for manual database exports. It adds a full // table scan before the real read and does not improve correctness. Progress reports // exported rows while total_rows remains indeterminate. let total_rows = None; let primary_keys = columns .iter() .filter(|column| column.is_primary_key) .map(|column| column.name.clone()) .collect::>(); let primary_key_indices = primary_keys .iter() .filter_map(|primary_key| col_names.iter().position(|column| column == primary_key)) .collect::>(); let use_keyset = !primary_keys.is_empty() && primary_key_indices.len() == primary_keys.len(); let mut last_primary_key_values = Vec::new(); let mut offset = 0_u64; loop { if is_export_cancelled(&request.export_id).await { emit_database_export_cancelled( &on_progress, &request.export_id, table_name.clone(), object_index, total_objects, total_rows_exported, ); return Ok(()); } let sql = if use_keyset { let sql = keyset_pagination_sql_with_identifier_quote( &col_names, table_name, &request.schema, &db_type, &primary_keys, &last_primary_key_values, batch_size, None, ); replace_database_export_select_list(sql, &col_names, &col_types, &db_type) } else { let sql = crate::transfer::pagination_sql( &col_names, table_name, &request.schema, &db_type, offset, batch_size, ); replace_database_export_select_list(sql, &col_names, &col_types, &db_type) }; let result = match crate::transfer::execute_read_on_pool(state, &pool_key, &sql).await { Ok(result) => result, Err(error) => { record_export_error( &mut file, request.fail_on_error, format!("exporting data for table {table_name}: {error}"), )?; break; } }; let row_count = result.rows.len(); if row_count == 0 { break; } write_database_export_rows( &mut file, &result.rows, &col_names, &col_types, &col_extras, table_name, &request.schema, &db_type, )?; total_rows_exported += row_count as u64; if use_keyset { if let Some(last_row) = result.rows.last() { last_primary_key_values = primary_key_indices.iter().map(|&index| last_row[index].clone()).collect(); } } else { offset += row_count as u64; } on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: table_name.clone(), object_index, total_objects, rows_exported: total_rows_exported, total_rows, status: ExportStatus::Running, error: None, preparing: false, }); if row_count < batch_size { break; } } } } } object_index += 1; } if request.include_structure && !postgres_sequences.is_empty() { for sequence in &postgres_sequences { if let Some(sql) = generate_postgres_sequence_owner_ddl(sequence, &request.schema) { writeln!(file, "{};\n", sql).map_err(|e| format!("Failed to write file: {e}"))?; } } for sequence in &postgres_sequences { if let Some(sql) = generate_postgres_sequence_setval_sql(sequence, &request.schema) { writeln!(file, "{};\n", sql).map_err(|e| format!("Failed to write file: {e}"))?; } } } // Export views (if include_objects) if request.include_objects { for view_info in &views { if is_export_cancelled(&request.export_id).await { return Err("Export cancelled".to_string()); } let view_name = &view_info.name; on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: view_name.clone(), object_index, total_objects, rows_exported: total_rows_exported, total_rows: None, status: ExportStatus::Running, error: None, preparing: false, }); match crate::schema::get_object_source_core( state, &request.connection_id, &request.database, &request.schema, view_name, crate::db::ObjectSourceKind::View, None, None, ) .await { Ok(obj_source) => { let source = build_database_export_object_source_sql( db_type, &ObjectSourceKind::View, view_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 view {view_name}: {e}"))?; } } object_index += 1; } // Export procedures for procedure in &procedures { if is_export_cancelled(&request.export_id).await { return Err("Export cancelled".to_string()); } let proc_name = &procedure.name; on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: proc_name.clone(), object_index, total_objects, rows_exported: total_rows_exported, total_rows: None, status: ExportStatus::Running, error: None, preparing: false, }); match crate::schema::get_object_source_core( state, &request.connection_id, &request.database, &request.schema, proc_name, crate::db::ObjectSourceKind::Procedure, procedure.signature.as_deref(), None, ) .await { Ok(obj_source) => { let source = build_database_export_object_source_sql( db_type, &ObjectSourceKind::Procedure, proc_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 procedure {proc_name}: {e}"), )?; } } object_index += 1; } // Export functions for function in &functions { if is_export_cancelled(&request.export_id).await { return Err("Export cancelled".to_string()); } let func_name = &function.name; on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: func_name.clone(), object_index, total_objects, rows_exported: total_rows_exported, total_rows: None, status: ExportStatus::Running, error: None, preparing: false, }); match crate::schema::get_object_source_core( state, &request.connection_id, &request.database, &request.schema, func_name, crate::db::ObjectSourceKind::Function, function.signature.as_deref(), None, ) .await { Ok(obj_source) => { let source = build_database_export_object_source_sql( db_type, &ObjectSourceKind::Function, func_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 function {func_name}: {e}"), )?; } } object_index += 1; } } // PostgreSQL trigger definitions reference their trigger functions. The // table DDL builder returns both statements together, while schema-wide // routines are exported below the tables. Keep triggers part of table // structure, but write them only after routines so the resulting script // is executable in file order. for trigger in deferred_postgres_triggers { writeln!(file, "{trigger}\n").map_err(|e| format!("Failed to write file: {e}"))?; } // For MySQL: re-enable foreign key checks if matches!(db_type, DatabaseType::Mysql) { writeln!(file, "SET FOREIGN_KEY_CHECKS = 1;").map_err(|e| format!("Failed to write file: {e}"))?; } file.flush().map_err(|e| format!("Failed to finalize export file: {e}"))?; // Emit Done progress on_progress(ExportProgress { export_id: request.export_id.clone(), current_object: String::new(), object_index, total_objects, rows_exported: total_rows_exported, total_rows: None, status: ExportStatus::Done, error: None, preparing: false, }); Ok(()) } fn filter_export_table_infos( tables: Vec, selected_tables: &[String], excluded_tables: &[String], ) -> Vec { let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect(); let excluded: HashSet<&str> = excluded_tables.iter().map(String::as_str).collect(); tables .into_iter() .filter(|table| selected.is_empty() || selected.contains(table.name.as_str())) .filter(|table| !excluded.contains(table.name.as_str())) .collect() } fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseType) -> String { format!("DROP TABLE IF EXISTS {};", crate::transfer::qualified_table(table_name, schema, db_type, None)) } fn build_database_export_object_source_sql( database_type: DatabaseType, object_type: &ObjectSourceKind, object_name: &str, source: &str, drop_if_exists: bool, ) -> String { let source = build_export_object_source_sql(database_type, object_type.clone(), source); if source.is_empty() || !drop_if_exists || database_type != DatabaseType::Mysql { return source; } let object_type = match object_type { ObjectSourceKind::View => "VIEW", ObjectSourceKind::Procedure => "PROCEDURE", ObjectSourceKind::Function => "FUNCTION", _ => return source, }; let object_name = quote_identifier(object_name, &DatabaseType::Mysql); format!("DROP {object_type} IF EXISTS {object_name};\n{source}") } #[cfg(test)] mod tests { use super::{ await_export_operation, clear_export_cancelled, concurrent_metadata_prefetch_allowed, database_export_metadata_prefetch_concurrency, emit_database_export_cancelled, set_export_cancelled, ExportStatus, EXPORT_CANCELLED_ERROR, }; use super::{ build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements, 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, 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, sort_export_views_by_dependencies, split_postgres_export_table_triggers, write_database_export_rows, BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, DatabaseExportObjectCounts, DatabaseExportRequest, DdlNormalizeOptions, ExportedTableSql, PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers, DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT, }; use crate::connection::AppState; use crate::models::connection::DatabaseType; use crate::storage::Storage; use crate::types::{ObjectInfo, ObjectSourceKind, TableInfo}; use serde_json::{json, Value}; use std::sync::{Arc, Mutex}; #[tokio::test] async fn await_export_operation_drops_pending_metadata_after_cancel() { let export_id = format!("cancel-pending-{}", uuid::Uuid::new_v4()); clear_export_cancelled(&export_id).await; let task_export_id = export_id.clone(); let task = tokio::spawn(async move { await_export_operation(&task_export_id, async { tokio::time::sleep(std::time::Duration::from_secs(30)).await; Ok::<_, String>(()) }) .await }); tokio::time::sleep(std::time::Duration::from_millis(10)).await; set_export_cancelled(&export_id).await; let result = tokio::time::timeout(std::time::Duration::from_secs(1), task) .await .expect("cancellation should stop a pending metadata future") .expect("cancellation task should not panic"); assert_eq!(result, Err(EXPORT_CANCELLED_ERROR.to_string())); clear_export_cancelled(&export_id).await; } #[tokio::test] async fn await_export_operation_preserves_completed_metadata() { let export_id = format!("complete-metadata-{}", uuid::Uuid::new_v4()); clear_export_cancelled(&export_id).await; let result = await_export_operation(&export_id, async { Ok::<_, String>(42_u32) }).await; assert_eq!(result, Ok(42)); } #[test] fn cancelled_progress_is_terminal_and_has_no_error() { let events = Arc::new(Mutex::new(Vec::new())); let sink = Arc::clone(&events); emit_database_export_cancelled(&|progress| sink.lock().unwrap().push(progress), "export-1", "seq", 3, 9, 17); let progress = events.lock().unwrap().pop().expect("cancelled progress"); assert_eq!(progress.export_id, "export-1"); assert_eq!(progress.current_object, "seq"); assert_eq!(progress.object_index, 3); assert_eq!(progress.total_objects, 9); assert_eq!(progress.rows_exported, 17); assert!(matches!(progress.status, ExportStatus::Cancelled)); assert!(progress.error.is_none()); assert!(!progress.preparing); } fn table(name: &str, table_type: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: table_type.to_string(), comment: None, parent_schema: None, parent_name: None, } } fn routine(name: &str, signature: &str) -> ObjectInfo { ObjectInfo { name: name.to_string(), object_type: "FUNCTION".to_string(), schema: Some("public".to_string()), valid: None, signature: Some(signature.to_string()), custom_type_kind: None, has_members: None, comment: None, created_at: None, updated_at: None, parent_schema: None, parent_name: None, trigger: None, xugu_type_members_expandable: None, } } fn export_request( include_structure: bool, include_data: bool, include_objects: bool, selected_tables: Vec, ) -> DatabaseExportRequest { DatabaseExportRequest { export_id: "export-1".to_string(), connection_id: "connection-1".to_string(), database: "database-1".to_string(), schema: "public".to_string(), file_path: "export.sql".to_string(), selected_tables, excluded_tables: Vec::new(), include_structure, include_data, include_objects, include_create_database: false, drop_table_if_exists: false, omit_auto_increment: false, fail_on_error: false, snapshot_session_id: None, batch_size: 1000, } } #[test] fn export_progress_total_counts_only_requested_object_categories() { let counts = DatabaseExportObjectCounts { tables: 2, views: 1, sequences: 2, extensions: 1, procedures: 1, functions: 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), ("nothing", export_request(false, false, false, Vec::new()), 0), ]; for (name, request, expected) in cases { assert_eq!(database_export_total_objects(&request, &counts), expected, "{name}"); } } #[test] fn mysql_database_export_preamble_preserves_database_settings() { assert_eq!( mysql_database_export_preamble("app`db", Some("utf8mb4"), Some("utf8mb4_unicode_ci")), "CREATE DATABASE IF NOT EXISTS `app``db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\nUSE `app``db`;\n" ); assert_eq!( mysql_database_export_preamble("app", None, None), "CREATE DATABASE IF NOT EXISTS `app`;\nUSE `app`;\n" ); } #[test] fn export_progress_total_excludes_schema_routines_for_selected_tables() { // Counts are already filtered to the selected table/view set before // workload calculation; schema-wide routines remain intentionally out. let counts = DatabaseExportObjectCounts { tables: 1, views: 1, sequences: 1, extensions: 1, procedures: 4, functions: 5, }; 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 postgres_extension_ddl_uses_target_default_version_and_source_schema() { let extension = PostgresExportExtension { name: "pg_trgm".to_string(), schema: "addons".to_string() }; let ddl = generate_postgres_extension_ddl(&extension); assert_eq!(ddl, "CREATE EXTENSION IF NOT EXISTS \"pg_trgm\" WITH SCHEMA \"addons\";"); assert!(!ddl.contains("VERSION")); } #[test] fn postgres_extension_member_filter_keeps_user_overload_with_same_name() { let mut members = PostgresExtensionMembers::default(); members.function_keys.insert(("similarity".to_string(), "text, text".to_string())); assert!(is_postgres_extension_member_routine(&routine("similarity", "text, text"), &members)); assert!(!is_postgres_extension_member_routine(&routine("similarity", "integer, integer"), &members)); assert!(!is_postgres_extension_member_routine(&routine("user_similarity", "text, text"), &members)); } #[test] fn postgres_database_export_defers_table_triggers_without_splitting_function_bodies() { let ddl = "CREATE TABLE \"public\".\"work_log\" (\n \"id\" bigint,\n \"note\" text DEFAULT ';'::text\n);\n\nCREATE INDEX \"idx_work_log\" ON \"public\".\"work_log\" (\"id\");\n\nCREATE TRIGGER trg_work_log BEFORE INSERT OR UPDATE ON public.work_log FOR EACH ROW EXECUTE FUNCTION fn_work_log_update();"; let (table_ddl, triggers) = split_postgres_export_table_triggers(ddl, DatabaseType::Postgres); assert!(table_ddl.contains("CREATE TABLE")); assert!(table_ddl.contains("DEFAULT ';'::text")); assert!(table_ddl.contains("CREATE INDEX")); assert!(!table_ddl.contains("CREATE TRIGGER")); assert_eq!(triggers.len(), 1); assert!(triggers[0].starts_with("CREATE TRIGGER trg_work_log")); } #[test] fn postgres_database_export_defers_constraint_triggers_only_for_postgres() { let ddl = "CREATE TABLE \"public\".\"items\" (\"id\" bigint);\nCREATE CONSTRAINT TRIGGER items_check AFTER INSERT ON public.items DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_items();"; let (postgres_table, postgres_triggers) = split_postgres_export_table_triggers(ddl, DatabaseType::Postgres); assert!(!postgres_table.contains("CREATE CONSTRAINT TRIGGER")); assert_eq!(postgres_triggers.len(), 1); let (mysql_ddl, mysql_triggers) = split_postgres_export_table_triggers(ddl, DatabaseType::Mysql); assert_eq!(mysql_ddl, ddl); assert!(mysql_triggers.is_empty()); } #[test] fn concurrent_prefetch_only_allowed_for_multi_connection_pools() { use crate::connection::PoolKind; // ChClient::new 只构造 HTTP 客户端,不发起连接 let clickhouse = PoolKind::ClickHouse(crate::db::clickhouse_driver::ChClient::new( "http://127.0.0.1:1", None, None, std::time::Duration::from_secs(1), )); assert!(concurrent_metadata_prefetch_allowed(Some(&clickhouse))); // Agent(JDBC sidecar)请求超时覆盖排队时间,必须回退串行 let agent = PoolKind::agent(crate::db::agent_driver::AgentDriverClient::test_stub()); assert!(!concurrent_metadata_prefetch_allowed(Some(&agent))); assert!(!concurrent_metadata_prefetch_allowed(None)); } #[test] fn postgres_metadata_prefetch_reserves_pool_capacity() { assert_eq!(database_export_metadata_prefetch_concurrency(DatabaseType::Postgres), 4); assert_eq!(database_export_metadata_prefetch_concurrency(DatabaseType::Mysql), 8); } #[test] fn filters_export_tables_by_selected_names() { let tables = vec![table("users", "TABLE"), table("orders", "TABLE"), table("active_users", "VIEW")]; let filtered = filter_export_table_infos(tables, &["active_users".to_string(), "users".to_string()], &[]); assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users", "active_users"]); } #[test] fn keeps_all_export_tables_when_selection_is_empty() { let tables = vec![table("users", "TABLE"), table("orders", "TABLE")]; let filtered = filter_export_table_infos(tables.clone(), &[], &[]); assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users", "orders"]); } #[test] fn excludes_export_tables_by_name() { let tables = vec![table("users", "TABLE"), table("audit_log", "TABLE"), table("active_users", "VIEW")]; let filtered = filter_export_table_infos(tables, &[], &["audit_log".to_string(), "active_users".to_string()]); assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users"]); } #[test] fn mysql_export_orders_referenced_views_before_dependents() { let tables = [table("a_view", "VIEW"), table("z_view", "VIEW")]; let views = tables.iter().collect::>(); let dependencies = vec![("a_view".to_string(), "z_view".to_string()), ("z_view".to_string(), "base_table".to_string())]; let sorted = sort_export_views_by_dependencies(&views, &dependencies); assert_eq!(sorted.iter().map(|view| view.name.as_str()).collect::>(), vec!["z_view", "a_view"]); } #[test] fn mysql_view_dependency_metadata_is_escaped_and_parsed() { let sql = mysql_view_dependencies_sql("prod'o"); assert!(sql.contains("VIEW_SCHEMA = 'prod''o'")); assert!(sql.contains("TABLE_SCHEMA = 'prod''o'")); let rows = vec![ vec![json!(" a_view "), json!(" z_view ")], vec![Value::Null, json!("ignored")], vec![json!(""), json!("ignored")], ]; assert_eq!(mysql_view_dependencies_from_rows(&rows), vec![("a_view".to_string(), "z_view".to_string())]); } #[test] fn exclusions_take_precedence_over_selected_tables() { let tables = vec![table("users", "TABLE"), table("orders", "TABLE")]; let filtered = filter_export_table_infos(tables, &["users".to_string(), "orders".to_string()], &["orders".to_string()]); assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users"]); } #[test] fn builds_drop_table_if_exists_with_qualified_mysql_name() { let sql = drop_table_if_exists_sql("users", "app", &DatabaseType::Mysql); assert_eq!(sql, "DROP TABLE IF EXISTS `users`;"); } #[test] fn builds_drop_table_if_exists_without_empty_schema() { let sql = drop_table_if_exists_sql("users", "", &DatabaseType::Postgres); assert_eq!(sql, "DROP TABLE IF EXISTS \"users\";"); } #[test] fn mysql_export_adds_drop_if_exists_for_views_and_escapes_names() { let sql = build_database_export_object_source_sql( DatabaseType::Mysql, &ObjectSourceKind::View, "active`rows", "CREATE VIEW `active``rows` AS SELECT 1", true, ); assert_eq!(sql, "DROP VIEW IF EXISTS `active``rows`;\nCREATE VIEW `active``rows` AS SELECT 1;"); } #[test] fn mysql_export_adds_drop_if_exists_before_delimited_routines() { let procedure = build_database_export_object_source_sql( DatabaseType::Mysql, &ObjectSourceKind::Procedure, "refresh_cache", "CREATE PROCEDURE `refresh_cache`() BEGIN SELECT 1; END", true, ); let function = build_database_export_object_source_sql( DatabaseType::Mysql, &ObjectSourceKind::Function, "active_count", "CREATE FUNCTION `active_count`() RETURNS INT RETURN 1", true, ); assert_eq!( procedure, "DROP PROCEDURE IF EXISTS `refresh_cache`;\nDELIMITER //\nCREATE PROCEDURE `refresh_cache`() BEGIN SELECT 1; END//\nDELIMITER ;" ); assert_eq!( function, "DROP FUNCTION IF EXISTS `active_count`;\nDELIMITER //\nCREATE FUNCTION `active_count`() RETURNS INT RETURN 1//\nDELIMITER ;" ); } #[test] fn object_drop_option_does_not_change_disabled_or_non_mysql_exports() { let mysql_without_drop = build_database_export_object_source_sql( DatabaseType::Mysql, &ObjectSourceKind::View, "active_rows", "CREATE VIEW `active_rows` AS SELECT 1", false, ); let postgres_with_drop = build_database_export_object_source_sql( DatabaseType::Postgres, &ObjectSourceKind::View, "active_rows", "CREATE VIEW active_rows AS SELECT 1", true, ); assert_eq!(mysql_without_drop, "CREATE VIEW `active_rows` AS SELECT 1;"); assert_eq!(postgres_with_drop, "CREATE VIEW active_rows AS SELECT 1;"); } #[test] fn formats_sql_literals_for_export_inserts() { assert_eq!(format_export_sql_literal(&Value::Null), "NULL"); assert_eq!(format_export_sql_literal(&json!(42)), "42"); assert_eq!(format_export_sql_literal(&json!(true)), "TRUE"); assert_eq!(format_export_sql_literal(&json!("O'Hara")), "'O''Hara'"); } #[test] fn mysql_spatial_export_uses_wkb_constructor_and_preserves_srid() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("places".to_string()), qualified_table_name: None, columns: vec!["location".to_string(), "shape".to_string()], column_types: vec![Some("point".to_string()), Some("geometry".to_string())], column_extras: Vec::new(), rows: vec![vec![ json!("DBX_WKB:4326:0101000000AE47E17A14AE5C4052B81E85EBF34240"), json!("DBX_WKB:0:0101000000000000000000F03F0000000000000040"), ]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec!["INSERT INTO `places` (`location`, `shape`) VALUES (ST_GeomFromWKB(0x0101000000AE47E17A14AE5C4052B81E85EBF34240, 4326), ST_GeomFromWKB(0x0101000000000000000000F03F0000000000000040));"] ); } #[test] fn mysql_spatial_export_rejects_markers_for_unknown_or_nonspatial_types() { let marker = json!("DBX_WKB:4326:0101000000AE47E17A14AE5C4052B81E85EBF34240"); assert!(format_mysql_spatial_export_literal(&marker, Some(DatabaseType::Mysql), None).is_none()); assert!(format_mysql_spatial_export_literal(&marker, Some(DatabaseType::Mysql), Some("varchar")).is_none()); assert!(format_mysql_spatial_export_literal( &json!("DBX_WKB:4326:0101000000"), Some(DatabaseType::Mysql), Some("geometry"), ) .is_none()); } #[test] fn mysql_spatial_export_select_normalizes_geometry_columns_to_wkb_markers() { let sql = database_export_select_sql( &["id".to_string(), "location".to_string(), "name".to_string()], &[Some("int".to_string()), Some("point".to_string()), Some("varchar(32)".to_string())], "places", "app", &DatabaseType::Mysql, ); assert_eq!( sql, "SELECT `id`, CASE WHEN `location` IS NULL THEN NULL ELSE CONCAT('DBX_WKB:', ST_SRID(`location`), ':', HEX(ST_AsWKB(`location`))) END AS `location`, `name` FROM `places`" ); } #[test] fn mysql_spatial_export_keeps_keyset_pagination_when_replacing_select_list() { let columns = vec!["id".to_string(), "location".to_string()]; let column_types = vec![Some("bigint".to_string()), Some("geometry".to_string())]; let sql = crate::transfer::keyset_pagination_sql_with_identifier_quote( &columns, "places", "app", &DatabaseType::Mysql, &["id".to_string()], &[json!(7)], 1000, None, ); let sql = replace_database_export_select_list(sql, &columns, &column_types, &DatabaseType::Mysql); assert!(sql.starts_with( "SELECT `id`, CASE WHEN `location` IS NULL THEN NULL ELSE CONCAT('DBX_WKB:', ST_SRID(`location`), ':', HEX(ST_AsWKB(`location`))) END AS `location` FROM `places`" )); assert!(sql.contains("WHERE `id` > 7"), "sql: {sql}"); assert!(sql.contains("ORDER BY `id` ASC LIMIT 1000"), "sql: {sql}"); } #[test] fn database_specific_boolean_export_literals() { let sqlserver_statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("flags".to_string()), qualified_table_name: None, columns: vec!["typed_true".to_string(), "untyped_false".to_string(), "typed_null".to_string()], column_types: vec![Some("bit".to_string()), None, Some("bit".to_string())], column_extras: Vec::new(), rows: vec![vec![json!(true), json!(false), Value::Null]], batch_size: Some(10), }) .unwrap(); let postgres_statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("flags".to_string()), qualified_table_name: None, columns: vec!["enabled".to_string(), "disabled".to_string(), "unknown".to_string()], column_types: Vec::new(), column_extras: Vec::new(), rows: vec![vec![json!(true), json!(false), Value::Null]], batch_size: Some(10), }) .unwrap(); assert_eq!( sqlserver_statements, vec!["INSERT INTO [dbo].[flags] ([typed_true], [untyped_false], [typed_null]) VALUES (1, 0, NULL);"] ); assert_eq!( postgres_statements, vec![ "INSERT INTO \"public\".\"flags\" (\"enabled\", \"disabled\", \"unknown\") VALUES (TRUE, FALSE, NULL);" ] ); } #[test] fn sqlserver_export_prefixes_unicode_string_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("people".to_string()), qualified_table_name: None, columns: vec![ "name".to_string(), "code".to_string(), "legacy_note".to_string(), "alias_name".to_string(), "plain_text".to_string(), "missing".to_string(), ], column_types: vec![ Some("NVARCHAR(255)".to_string()), Some(" nchar (10) ".to_string()), Some("ntext".to_string()), Some("sysname".to_string()), Some("varchar(255)".to_string()), Some("nvarchar(20)".to_string()), ], column_extras: Vec::new(), rows: vec![vec![ json!("张'三"), json!("中文"), json!("旧文本"), json!("别名"), json!("plain"), Value::Null, ]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO [dbo].[people] ([name], [code], [legacy_note], [alias_name], [plain_text], [missing]) VALUES (N'张''三', N'中文', N'旧文本', N'别名', 'plain', NULL);" ] ); } #[test] fn mysql_export_inserts_escape_control_characters() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("notes".to_string()), qualified_table_name: None, columns: vec!["body".to_string()], column_types: vec![Some("text".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("line1\nline2\tcol\rend\\slash\0\x1aO'Hara")]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec!["INSERT INTO `notes` (`body`) VALUES ('line1\\nline2\\tcol\\rend\\\\slash\\0\\ZO''Hara');"] ); } #[test] fn export_insert_batches_split_on_statement_bytes() { let long_value = "x".repeat(300_000); let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("payloads".to_string()), qualified_table_name: None, columns: vec!["payload".to_string()], column_types: vec![Some("longtext".to_string())], column_extras: Vec::new(), rows: vec![vec![json!(long_value.clone())], vec![json!(long_value)]], batch_size: Some(100), }) .unwrap(); assert_eq!(statements.len(), 2); assert!(statements.iter().all(|statement| statement.matches("INSERT INTO").count() == 1)); } #[test] fn sqlserver_export_caps_multi_row_insert_at_1000_rows() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("items".to_string()), qualified_table_name: None, columns: vec!["id".to_string()], column_types: vec![Some("int".to_string())], column_extras: Vec::new(), rows: (0..1001).map(|id| vec![json!(id)]).collect(), batch_size: Some(2000), }) .unwrap(); assert_eq!(statements.len(), 2); assert_eq!(statements[0].matches("), (").count(), 999); assert_eq!(statements[1].matches("), (").count(), 0); } #[test] fn doris_export_inserts_escape_control_characters() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Doris), identifier_quote: None, schema: Some("warehouse".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, columns: vec!["message".to_string()], column_types: vec![Some("varchar(255)".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("first\nsecond\tthird")]], batch_size: Some(10), }) .unwrap(); assert_eq!(statements, vec!["INSERT INTO `warehouse`.`events` (`message`) VALUES ('first\\nsecond\\tthird');"]); } #[test] fn postgres_export_inserts_escape_control_characters() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("notes".to_string()), qualified_table_name: None, columns: vec!["body".to_string()], column_types: vec![Some("text".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("line1\nline2\tend")]], batch_size: Some(10), }) .unwrap(); assert_eq!(statements, vec!["INSERT INTO \"public\".\"notes\" (\"body\") VALUES (E'line1\\nline2\\tend');"]); } #[test] fn postgres_export_inserts_escape_quotes_and_backslashes_without_changing_plain_strings() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("notes".to_string()), qualified_table_name: None, columns: vec!["carriage_return".to_string(), "quote".to_string(), "path".to_string(), "plain".to_string()], column_types: vec![ Some("text".to_string()), Some("text".to_string()), Some("text".to_string()), Some("text".to_string()), ], column_extras: Vec::new(), rows: vec![vec![json!("line1\rline2"), json!("O'Hara"), json!(r"C:\tmp"), json!("plain")]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ r#"INSERT INTO "public"."notes" ("carriage_return", "quote", "path", "plain") VALUES (E'line1\rline2', 'O''Hara', E'C:\\tmp', 'plain');"# ] ); } #[test] fn postgres_jsonb_export_preserves_json_escape_sequences() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, columns: vec!["payload".to_string()], column_types: vec![Some("jsonb".to_string())], column_extras: Vec::new(), rows: vec![vec![json!(r#"{"text":"say \"hi\"","path":"C:\\tmp","quote":"O'Hara"}"#)]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ r#"INSERT INTO "public"."events" ("payload") VALUES (E'{"text":"say \\"hi\\"","path":"C:\\\\tmp","quote":"O''Hara"}');"# ] ); } #[test] fn postgres_vector_export_preserves_pgvector_bracket_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("items".to_string()), qualified_table_name: None, columns: vec![ "id".to_string(), "embedding".to_string(), "qualified_embedding".to_string(), "labels".to_string(), ], column_types: vec![ Some("integer".to_string()), Some("vector(2)".to_string()), Some("public.vector".to_string()), Some("text[]".to_string()), ], column_extras: Vec::new(), rows: vec![vec![json!(1), json!([1.2, 3.4]), json!(["5", "6"]), json!(["x", "y"])]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ r#"INSERT INTO "public"."items" ("id", "embedding", "qualified_embedding", "labels") VALUES (1, '[1.2,3.4]', '[5,6]', '{"x","y"}');"# ] ); } #[test] fn builds_batched_insert_statements_for_export() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("users".to_string()), qualified_table_name: None, columns: vec!["id".to_string(), "name".to_string()], column_types: Vec::new(), column_extras: Vec::new(), rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("O'Hara")], vec![json!(3), json!("Linus")]], batch_size: Some(2), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'O''Hara');", "INSERT INTO `users` (`id`, `name`) VALUES (3, 'Linus');", ] ); } #[test] fn oracle_export_inserts_use_one_statement_per_row() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, columns: vec!["ID".to_string(), "NAME".to_string()], column_types: Vec::new(), column_extras: Vec::new(), rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("Linus")]], batch_size: Some(100), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO \"APP\".\"USERS\" (\"ID\", \"NAME\") VALUES (1, 'Ada');", "INSERT INTO \"APP\".\"USERS\" (\"ID\", \"NAME\") VALUES (2, 'Linus');", ] ); } #[test] fn oracle_export_omits_synthetic_rowid_from_insert_columns() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, columns: vec!["__DBX_ROWID".to_string(), "ID".to_string(), "NAME".to_string()], column_types: vec![Some("VARCHAR2".to_string()), Some("NUMBER".to_string()), Some("VARCHAR2".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("AAAPr9AAEAAAAGfAAA"), json!(1), json!("Ada")]], batch_size: Some(100), }) .unwrap(); assert_eq!(statements, vec!["INSERT INTO \"APP\".\"USERS\" (\"ID\", \"NAME\") VALUES (1, 'Ada');"]); } #[test] fn oceanbase_oracle_export_omits_synthetic_rowid_from_insert_columns() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::OceanbaseOracle), identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, columns: vec!["__DBX_ROWID".to_string(), "ID".to_string(), "NAME".to_string()], column_types: vec![Some("VARCHAR2".to_string()), Some("NUMBER".to_string()), Some("VARCHAR2".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("*AAABk1AAEAAAAAgAAA"), json!(1), json!("Ada")]], batch_size: Some(100), }) .unwrap(); assert_eq!(statements, vec!["INSERT INTO \"APP\".\"USERS\" (\"ID\", \"NAME\") VALUES (1, 'Ada');"]); } #[test] fn non_oracle_export_preserves_dbx_rowid_named_column() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("users".to_string()), qualified_table_name: None, columns: vec!["__DBX_ROWID".to_string(), "name".to_string()], column_types: Vec::new(), column_extras: Vec::new(), rows: vec![vec![json!(7), json!("Ada")]], batch_size: Some(100), }) .unwrap(); assert_eq!(statements, vec!["INSERT INTO `users` (`__DBX_ROWID`, `name`) VALUES (7, 'Ada');"]); } #[test] fn oracle_date_columns_export_as_date_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("EVENTS".to_string()), qualified_table_name: None, columns: vec!["ID".to_string(), "CREATED_ON".to_string(), "RAW_TEXT".to_string()], column_types: vec![Some("NUMBER".to_string()), Some("DATE".to_string()), Some("VARCHAR2(64)".to_string())], column_extras: Vec::new(), rows: vec![ vec![json!(1), json!("2022-08-25T09:58:43Z"), json!("2022-08-25T09:58:43Z")], vec![json!(2), json!("2022-08-25T00:00:00Z"), json!("2022-08-25T00:00:00Z")], ], batch_size: Some(100), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO \"APP\".\"EVENTS\" (\"ID\", \"CREATED_ON\", \"RAW_TEXT\") VALUES (1, TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), '2022-08-25T09:58:43Z');", "INSERT INTO \"APP\".\"EVENTS\" (\"ID\", \"CREATED_ON\", \"RAW_TEXT\") VALUES (2, DATE '2022-08-25', '2022-08-25T00:00:00Z');", ] ); } #[test] fn oracle_timestamp_columns_export_as_explicit_literals() { for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(database_type), identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("EVENTS".to_string()), qualified_table_name: None, columns: vec![ "CREATED_AT".to_string(), "UPDATED_AT".to_string(), "RECORDED_AT".to_string(), "LOCAL_RECORDED_AT".to_string(), "DRIVER_CREATED_AT".to_string(), "DRIVER_RECORDED_AT".to_string(), "RAW_TEXT".to_string(), "UNTYPED".to_string(), "INVALID_AT".to_string(), "SHAPED_INVALID_AT".to_string(), "NULL_AT".to_string(), ], column_types: vec![ Some("TIMESTAMP(6)".to_string()), Some("TIMESTAMP(6)".to_string()), Some("TIMESTAMP(9) WITH TIME ZONE".to_string()), Some("TIMESTAMP(6) WITH LOCAL TIME ZONE".to_string()), Some("TimeStampDTY".to_string()), Some("TimeStampTZ_DTY".to_string()), Some("VARCHAR2(64)".to_string()), None, Some("TIMESTAMP(6)".to_string()), Some("TIMESTAMP(6)".to_string()), Some("TIMESTAMP".to_string()), ], column_extras: Vec::new(), rows: vec![vec![ json!("2022-08-25 09:58:43.123456"), json!("2022-08-26T10:59:44Z"), json!("2022-08-27T11:00:45.123456789+08:00"), json!("2022-08-28T12:01:46Z"), json!("2022-08-29T13:02:47.123456"), json!("2022-08-30T14:03:48-05:30"), json!("2022-08-31T15:04:49Z"), json!("2022-09-01T16:05:50Z"), json!("not-a-timestamp"), json!("2022-0'-25T09:58:43Z"), Value::Null, ]], batch_size: Some(100), }) .unwrap(); assert_eq!( statements, vec![concat!( "INSERT INTO \"APP\".\"EVENTS\" (\"CREATED_AT\", \"UPDATED_AT\", \"RECORDED_AT\", ", "\"LOCAL_RECORDED_AT\", \"DRIVER_CREATED_AT\", \"DRIVER_RECORDED_AT\", \"RAW_TEXT\", ", "\"UNTYPED\", \"INVALID_AT\", \"SHAPED_INVALID_AT\", \"NULL_AT\") VALUES ", "(TO_TIMESTAMP('2022-08-25 09:58:43.123456', 'YYYY-MM-DD HH24:MI:SS.FF'), ", "TO_TIMESTAMP('2022-08-26 10:59:44', 'YYYY-MM-DD HH24:MI:SS'), ", "TO_TIMESTAMP_TZ('2022-08-27 11:00:45.123456789 +08:00', ", "'YYYY-MM-DD HH24:MI:SS.FF TZH:TZM'), ", "TO_TIMESTAMP_TZ('2022-08-28 12:01:46 +00:00', 'YYYY-MM-DD HH24:MI:SS TZH:TZM'), ", "TO_TIMESTAMP('2022-08-29 13:02:47.123456', 'YYYY-MM-DD HH24:MI:SS.FF'), ", "TO_TIMESTAMP_TZ('2022-08-30 14:03:48 -05:30', 'YYYY-MM-DD HH24:MI:SS TZH:TZM'), ", "'2022-08-31T15:04:49Z', '2022-09-01T16:05:50Z', 'not-a-timestamp', ", "'2022-0''-25T09:58:43Z', NULL);" )] ); } } #[test] fn non_oracle_timestamp_exports_keep_existing_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, columns: vec!["created_at".to_string(), "recorded_at".to_string()], column_types: vec![Some("timestamp".to_string()), Some("timestamp with time zone".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("2022-08-25T09:58:43.123456Z"), json!("2022-08-26T10:59:44+08:00")]], batch_size: Some(100), }) .unwrap(); assert_eq!( statements, vec![concat!( "INSERT INTO \"public\".\"events\" (\"created_at\", \"recorded_at\") VALUES ", "('2022-08-25 09:58:43.123456', '2022-08-26 10:59:44+08:00');" )] ); } #[test] fn mysql_bit_columns_export_without_quoted_string_values() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("flags".to_string()), qualified_table_name: None, columns: vec!["enabled".to_string(), "mask".to_string(), "label".to_string()], column_types: vec![Some("bit(1)".to_string()), Some("BIT(4)".to_string()), Some("varchar(20)".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("1"), json!("1010"), json!("1010")], vec![json!(false), json!(3), json!("off")]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec!["INSERT INTO `flags` (`enabled`, `mask`, `label`) VALUES (b'1', b'1010', '1010'), (b'0', 3, 'off');"] ); } #[test] fn dameng_bit_columns_export_as_numeric_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Dameng), identifier_quote: None, schema: Some("DBX_TEST".to_string()), table_name: Some("FLAGS".to_string()), qualified_table_name: None, columns: vec!["ENABLED".to_string(), "DELETED".to_string(), "OPTIONAL".to_string()], column_types: vec![Some("BIT".to_string()), Some("bit".to_string()), Some("BIT".to_string())], column_extras: Vec::new(), rows: vec![vec![json!(true), json!(false), Value::Null]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec!["INSERT INTO \"DBX_TEST\".\"FLAGS\" (\"ENABLED\", \"DELETED\", \"OPTIONAL\") VALUES (1, 0, NULL);"] ); } #[test] fn dameng_strings_export_nul_as_chr_expression() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Dameng), identifier_quote: None, schema: Some("DBX_TEST".to_string()), table_name: Some("NUL_VALUES".to_string()), qualified_table_name: None, columns: vec![ "PLAIN".to_string(), "TRAILING".to_string(), "LEADING".to_string(), "MIDDLE".to_string(), "CONSECUTIVE".to_string(), "ONLY_NUL".to_string(), ], column_types: vec![Some("VARCHAR".to_string()); 6], column_extras: Vec::new(), rows: vec![vec![ json!("plain"), json!("eHall\0"), json!("\0leading"), json!("left\0right"), json!("left\0\0right"), json!("\0"), ]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![concat!( "INSERT INTO \"DBX_TEST\".\"NUL_VALUES\" ", "(\"PLAIN\", \"TRAILING\", \"LEADING\", \"MIDDLE\", \"CONSECUTIVE\", \"ONLY_NUL\") ", "VALUES ('plain', 'eHall' || CHR(0), CHR(0) || 'leading', 'left' || CHR(0) || 'right', ", "'left' || CHR(0) || CHR(0) || 'right', CHR(0));" )] ); assert!(!statements[0].contains('\0')); } #[test] fn mysql_export_uses_typed_literals_for_numeric_and_blob_columns() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("t_test_01".to_string()), qualified_table_name: None, columns: vec!["id".to_string(), "f_blob".to_string(), "note".to_string()], column_types: vec![Some("int".to_string()), Some("blob".to_string()), Some("varchar(64)".to_string())], column_extras: Vec::new(), rows: vec![ vec![json!("1"), json!("0x68656c6c6f"), json!("0x68656c6c6f")], vec![json!("2"), json!("0X"), json!("1")], ], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO `t_test_01` (`id`, `f_blob`, `note`) VALUES (1, 0x68656c6c6f, '0x68656c6c6f'), (2, X'', '1');" ] ); } #[test] fn temporal_columns_export_without_rfc3339_separator_or_utc_suffix() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("events".to_string()), qualified_table_name: None, columns: vec!["id".to_string(), "created_at".to_string(), "created_on".to_string(), "raw_text".to_string()], column_types: vec![ Some("int".to_string()), Some("timestamp".to_string()), Some("date".to_string()), Some("varchar(64)".to_string()), ], column_extras: Vec::new(), rows: vec![vec![ json!(1), json!("2026-06-12T10:11:12.123456789Z"), json!("2026-06-12T10:11:12Z"), json!("2026-06-12T10:11:12Z"), ]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO `events` (`id`, `created_at`, `created_on`, `raw_text`) VALUES (1, '2026-06-12 10:11:12.123456', '2026-06-12', '2026-06-12T10:11:12Z');" ] ); } #[test] fn postgres_timestamptz_export_keeps_timezone_without_rfc3339_t_separator() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, columns: vec!["recorded_at".to_string(), "local_at".to_string()], column_types: vec![ Some("timestamp with time zone".to_string()), Some("timestamp without time zone".to_string()), ], column_extras: Vec::new(), rows: vec![vec![json!("2026-06-12T10:11:12Z"), json!("2026-06-12T18:11:12+08:00")]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO \"public\".\"events\" (\"recorded_at\", \"local_at\") VALUES ('2026-06-12 10:11:12+00:00', '2026-06-12 18:11:12');" ] ); } #[test] fn sqlserver_rowversion_timestamp_type_is_not_treated_as_datetime() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, columns: vec!["row_version".to_string(), "created_at".to_string()], column_types: vec![Some("timestamp".to_string()), Some("datetime2(3)".to_string())], column_extras: Vec::new(), rows: vec![vec![json!("2026-06-12T10:11:12Z"), json!("2026-06-12T10:11:12.1234567Z")]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ "INSERT INTO [dbo].[events] ([row_version], [created_at]) VALUES ('2026-06-12T10:11:12Z', '2026-06-12 10:11:12.123');" ] ); } #[test] fn postgres_tsvector_columns_are_omitted_from_sql_insert_export() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, schema: Some("public".to_string()), table_name: Some("articles".to_string()), qualified_table_name: None, columns: vec!["id".to_string(), "title".to_string(), "search_vector".to_string()], column_types: vec![Some("integer".to_string()), Some("text".to_string()), Some("tsvector".to_string())], column_extras: Vec::new(), rows: vec![vec![json!(1), json!("Hello"), json!("'hello':1A")]], batch_size: Some(10), }) .unwrap(); assert_eq!(statements, vec!["INSERT INTO \"public\".\"articles\" (\"id\", \"title\") VALUES (1, 'Hello');"]); } #[test] fn mysql_generated_columns_are_omitted_from_sql_inserts_but_kept_in_ddl() { let ddl = "CREATE TABLE `orders` (`id` bigint AUTO_INCREMENT, `quantity` int, `unit_price` decimal(10,2), `virtual_total` decimal(10,2) GENERATED ALWAYS AS ((`quantity` * `unit_price`)) VIRTUAL, `stored_total` decimal(10,2) GENERATED ALWAYS AS ((`quantity` * `unit_price`)) STORED);"; let sql = build_database_sql_export(BuildDatabaseSqlExportOptions { database_name: "shop".to_string(), exported_at: Some("2026-07-30T00:00:00.000Z".to_string()), tables: vec![ExportedTableSql { display_name: "orders".to_string(), database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("orders".to_string()), qualified_table_name: None, ddl: Some(ddl.to_string()), columns: vec![ "id".to_string(), "quantity".to_string(), "unit_price".to_string(), "virtual_total".to_string(), "stored_total".to_string(), "created_at".to_string(), ], column_types: vec![ Some("bigint".to_string()), Some("int".to_string()), Some("decimal(10,2)".to_string()), Some("decimal(10,2)".to_string()), Some("decimal(10,2)".to_string()), Some("timestamp".to_string()), ], column_extras: vec![ Some("auto_increment".to_string()), None, None, Some("VIRTUAL GENERATED".to_string()), Some("stored generated".to_string()), Some("DEFAULT_GENERATED".to_string()), ], rows: vec![vec![json!(7), json!(2), json!(3.5), json!(7.0), json!(7.0), json!("2026-07-30 08:00:00")]], truncated: false, }], row_limit_per_table: Some(DATABASE_EXPORT_ROW_LIMIT), insert_batch_size: Some(DATABASE_EXPORT_INSERT_BATCH_SIZE), connection_id: None, database: None, schema: None, omit_auto_increment: false, }) .unwrap(); assert!(sql.contains(ddl)); assert!(sql.contains( "INSERT INTO `orders` (`id`, `quantity`, `unit_price`, `created_at`) VALUES (7, 2, 3.5, '2026-07-30 08:00:00');" )); assert!(!sql.contains("INSERT INTO `orders` (`id`, `quantity`, `unit_price`, `virtual_total`")); } #[test] fn mysql_database_export_file_rows_omit_generated_columns() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("orders.sql"); let mut file = std::fs::File::create(&path).unwrap(); write_database_export_rows( &mut file, &[vec![json!(7), json!(2), json!(7.0), json!("2026-07-30 08:00:00")]], &["id".to_string(), "quantity".to_string(), "virtual_total".to_string(), "created_at".to_string()], &[ Some("bigint".to_string()), Some("int".to_string()), Some("decimal(10,2)".to_string()), Some("timestamp".to_string()), ], &[ Some("auto_increment".to_string()), None, Some("VIRTUAL GENERATED".to_string()), Some("DEFAULT_GENERATED".to_string()), ], "orders", "shop", &DatabaseType::Mysql, ) .unwrap(); drop(file); assert_eq!( std::fs::read_to_string(path).unwrap(), "INSERT INTO `orders` (`id`, `quantity`, `created_at`) VALUES (7, 2, '2026-07-30 08:00:00');\n\n" ); } #[test] fn dameng_identity_export_inserts_enable_identity_insert() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Dameng), identifier_quote: None, schema: Some("SYSDBA".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, columns: vec!["ID".to_string(), "NAME".to_string()], column_types: vec![Some("INT".to_string()), Some("VARCHAR(20)".to_string())], column_extras: vec![Some("identity".to_string()), None], rows: vec![vec![json!(1), json!("Ada")]], batch_size: Some(10), }) .unwrap(); assert_eq!( statements, vec![ "SET IDENTITY_INSERT \"SYSDBA\".\"USERS\" ON;\nINSERT INTO \"SYSDBA\".\"USERS\" (\"ID\", \"NAME\") VALUES (1, 'Ada');\nSET IDENTITY_INSERT \"SYSDBA\".\"USERS\" OFF;" ] ); } #[test] fn builds_database_sql_export_with_ddl_before_data() { let sql = build_database_sql_export(BuildDatabaseSqlExportOptions { database_name: "app".to_string(), exported_at: Some("2026-05-02T00:00:00.000Z".to_string()), tables: vec![ExportedTableSql { display_name: "users".to_string(), database_type: Some(DatabaseType::Mysql), identifier_quote: None, schema: None, table_name: Some("users".to_string()), qualified_table_name: None, ddl: Some("CREATE TABLE `users` (`id` int);".to_string()), columns: vec!["id".to_string()], column_types: Vec::new(), column_extras: Vec::new(), rows: vec![vec![json!(1)]], truncated: true, }], row_limit_per_table: Some(DATABASE_EXPORT_ROW_LIMIT), insert_batch_size: Some(DATABASE_EXPORT_INSERT_BATCH_SIZE), connection_id: None, database: None, schema: None, omit_auto_increment: false, }) .unwrap(); assert_eq!( sql, [ "-- DBX database export".to_string(), "-- Database: app".to_string(), "-- Exported at: 2026-05-02T00:00:00.000Z".to_string(), format!("-- Row limit per table: {DATABASE_EXPORT_ROW_LIMIT}"), String::new(), "-- Structure for users".to_string(), "CREATE TABLE `users` (`id` int);".to_string(), String::new(), "-- Data for users".to_string(), format!("-- Exported rows: 1 (truncated at {DATABASE_EXPORT_ROW_LIMIT})"), "INSERT INTO `users` (`id`) VALUES (1);".to_string(), String::new(), ] .join("\n") ); } #[test] fn table_ddl_export_has_one_statement_terminator() { let ddl = "CREATE TABLE `users` (`id` int);;\n"; assert_eq!( format_export_table_ddl(ddl, Some(DatabaseType::Mysql), DdlNormalizeOptions::default()), "CREATE TABLE `users` (`id` int);" ); assert_eq!( format_export_table_ddl( "CREATE TABLE users (id int)", Some(DatabaseType::Postgres), DdlNormalizeOptions::default(), ), "CREATE TABLE users (id int);" ); } #[test] fn omitted_auto_increment_preserves_mysql_line_comment_boundaries() { let options = DdlNormalizeOptions { omit_auto_increment: true }; assert_eq!( format_export_table_ddl( "CREATE TABLE `users` (`id` int) ENGINE=InnoDB -- keep this comment\nAUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4", Some(DatabaseType::Mysql), options, ), "CREATE TABLE `users` (`id` int) ENGINE=InnoDB -- keep this comment\n DEFAULT CHARSET=utf8mb4;" ); assert_eq!( format_export_table_ddl( "CREATE TABLE `users` (`id` int) ENGINE=InnoDB # keep this comment\r\nAUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4", Some(DatabaseType::Mysql), options, ), "CREATE TABLE `users` (`id` int) ENGINE=InnoDB # keep this comment\r\n DEFAULT CHARSET=utf8mb4;" ); } #[test] fn omitted_auto_increment_consumes_only_horizontal_separator_whitespace() { let options = DdlNormalizeOptions { omit_auto_increment: true }; for separator in [" ", "\t"] { let ddl = format!( "CREATE TABLE `users` (`id` int) ENGINE=InnoDB{separator}AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4" ); assert_eq!( format_export_table_ddl(&ddl, Some(DatabaseType::Mysql), options), "CREATE TABLE `users` (`id` int) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" ); } } #[test] fn normalizes_legacy_mysql_row_format_for_export_compatibility() { let ddl = "CREATE TABLE `wide_table` (\n `payload` varchar(4096) DEFAULT NULL\n) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT"; let normalized = normalize_export_table_ddl(ddl, Some(DatabaseType::Mysql), DdlNormalizeOptions::default()); assert_eq!( normalized, "CREATE TABLE `wide_table` (\n `payload` varchar(4096) DEFAULT NULL\n) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC" ); } #[test] fn normalizes_lowercase_redundant_mysql_row_format_for_export_compatibility() { let ddl = "CREATE TABLE `wide_table` (`payload` varchar(4096)) engine=InnoDB row_format = redundant"; let normalized = normalize_export_table_ddl(ddl, Some(DatabaseType::Mysql), DdlNormalizeOptions::default()); assert_eq!(normalized, "CREATE TABLE `wide_table` (`payload` varchar(4096)) engine=InnoDB ROW_FORMAT=DYNAMIC"); } #[test] fn preserves_non_legacy_or_non_mysql_row_formats() { let mysql_ddl = "CREATE TABLE `ok` (`payload` text) ENGINE=InnoDB ROW_FORMAT=COMPRESSED"; let postgres_ddl = "CREATE TABLE users (payload text) ROW_FORMAT=COMPACT"; assert_eq!( normalize_export_table_ddl(mysql_ddl, Some(DatabaseType::Mysql), DdlNormalizeOptions::default()), mysql_ddl ); assert_eq!( normalize_export_table_ddl(postgres_ddl, Some(DatabaseType::Postgres), DdlNormalizeOptions::default()), postgres_ddl ); } fn postgres_sequence(name: &str) -> PostgresExportSequence { PostgresExportSequence { name: name.to_string(), data_type: "integer".to_string(), start_value: "1".to_string(), min_value: "1".to_string(), max_value: "2147483647".to_string(), increment: "1".to_string(), cycle: false, cache_value: "1".to_string(), last_value: Some("42".to_string()), owner_table: Some("permissions".to_string()), owner_column: Some("id".to_string()), } } #[test] fn postgres_sequence_create_ddl_is_importable_before_table_ddl() { let ddl = generate_postgres_sequence_create_ddl(&postgres_sequence("permissions_id_seq"), "public"); assert_eq!( ddl, [ "CREATE SEQUENCE IF NOT EXISTS \"public\".\"permissions_id_seq\"", " AS integer", " START WITH 1", " INCREMENT BY 1", " MINVALUE 1", " MAXVALUE 2147483647", " CACHE 1", " NO CYCLE", ] .join("\n") ); } #[test] fn postgres_sequence_owner_and_setval_sql_are_qualified() { let sequence = postgres_sequence("permissions_id_seq"); assert_eq!( generate_postgres_sequence_owner_ddl(&sequence, "public").as_deref(), Some("ALTER SEQUENCE \"public\".\"permissions_id_seq\" OWNED BY \"public\".\"permissions\".\"id\"") ); assert_eq!( generate_postgres_sequence_setval_sql(&sequence, "public").as_deref(), Some( "SELECT setval('\"public\".\"permissions_id_seq\"', GREATEST(COALESCE(MAX(\"id\"), 42), 42), true) FROM \"public\".\"permissions\"" ) ); } #[test] fn strict_exports_return_object_errors_instead_of_writing_error_comments() { let path = std::env::temp_dir().join(format!("dbx-strict-export-{}.sql", uuid::Uuid::new_v4())); let mut file = std::fs::File::create(&path).unwrap(); let result = record_export_error(&mut file, true, "exporting table users: permission denied".to_string()); drop(file); assert_eq!(result.unwrap_err(), "exporting table users: permission denied"); assert!(std::fs::read_to_string(&path).unwrap().is_empty()); let _ = std::fs::remove_file(path); } async fn test_app_state(scratch_dir: &std::path::Path) -> AppState { let storage = Storage::open(&scratch_dir.join("storage.db")).await.unwrap(); AppState::new(storage) } // Regression tests for #6327: create_dir_all on every export run is unsafe // when the destination is on a removable/network drive, because a mount // that is temporarily gone at write time would be silently recreated on // the local root filesystem, and the backup would "succeed" while writing // to the wrong disk. #[tokio::test] async fn ensure_export_destination_dir_creates_a_never_before_seen_local_directory() { let scratch = std::env::temp_dir().join(format!("dbx-export-dest-new-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&scratch).unwrap(); let state = test_app_state(&scratch).await; let destination = scratch.join("backups").join("mydb"); assert!(!destination.exists()); ensure_export_destination_dir(&state, &destination) .await .expect("first-time local directory should be created"); assert!(destination.is_dir()); let _ = std::fs::remove_dir_all(&scratch); } #[tokio::test] async fn ensure_export_destination_dir_refuses_to_recreate_a_destination_that_disappeared() { let scratch = std::env::temp_dir().join(format!("dbx-export-dest-vanished-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&scratch).unwrap(); let state = test_app_state(&scratch).await; // Simulates a previously configured, already-used backup destination // (e.g. on an external or network drive) by successfully exporting to // it once first. let destination = scratch.join("mounted-drive").join("mydb"); ensure_export_destination_dir(&state, &destination).await.expect("initial export should create the directory"); assert!(destination.is_dir()); // Simulates the mount disappearing (unplugged drive, unmounted share, // etc.) before the next run. std::fs::remove_dir_all(scratch.join("mounted-drive")).unwrap(); assert!(!destination.exists()); let result = ensure_export_destination_dir(&state, &destination).await; assert!(result.is_err(), "a destination that existed before should not be silently recreated"); assert!(!destination.exists(), "the backup directory must not be resurrected on the wrong filesystem"); let _ = std::fs::remove_dir_all(&scratch); } // Regression test for review feedback on #6327: `ensure_export_destination_dir` // only remembered a destination *after* a successful export, so a mount // that was present when a schedule was configured but vanished before its // very first run looked identical to a brand-new local folder (no // recorded state either way) and got silently recreated on the local // disk. `record_export_destination_identity` closes that gap by letting // the schedule-configuration flow record the destination eagerly. #[tokio::test] async fn record_export_destination_identity_protects_a_mount_that_vanishes_before_its_first_run() { let scratch = std::env::temp_dir().join(format!("dbx-export-dest-eager-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&scratch).unwrap(); let state = test_app_state(&scratch).await; // Simulates the user picking an already-mounted external/network // drive in the schedule editor and saving the schedule -- the // directory exists at configuration time, but no export has run yet. let destination = scratch.join("mounted-drive").join("mydb"); std::fs::create_dir_all(&destination).unwrap(); record_export_destination_identity(&state, &destination) .await .expect("configuring the schedule should succeed"); // The mount disappears before the scheduler ever runs this schedule // for the first time. std::fs::remove_dir_all(scratch.join("mounted-drive")).unwrap(); assert!(!destination.exists()); let result = ensure_export_destination_dir(&state, &destination).await; assert!(result.is_err(), "a mount that was recorded at configuration time must not be silently recreated"); assert!(!destination.exists(), "the backup directory must not be resurrected on the wrong filesystem"); let _ = std::fs::remove_dir_all(&scratch); } #[tokio::test] async fn record_export_destination_identity_rejects_a_directory_that_disappeared_before_save() { let scratch = std::env::temp_dir().join(format!("dbx-export-dest-eager-new-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&scratch).unwrap(); let state = test_app_state(&scratch).await; // The schedule editor only accepts an existing directory. If that // directory disappears before the save request reaches the backend, // the schedule must not be persisted without a recorded identity. let destination = scratch.join("backups").join("mydb"); assert!(!destination.exists()); let error = record_export_destination_identity(&state, &destination) .await .expect_err("a missing scheduled destination must be rejected"); assert!(error.contains("does not exist or is not a directory")); assert!(!destination.exists()); let _ = std::fs::remove_dir_all(&scratch); } // Regression test for review feedback on #6327: the directory identity // check and the later `File::create` are separate operations, so the // mount can disappear and be replaced by something else at the same path // in between. `export_destination_identity_mismatch` is the comparison // `export_database_sql_core` runs against the handle it actually opened; // exercised directly here since reproducing a real cross-filesystem swap // mid-write is not something a portable unit test can simulate. #[test] fn export_destination_identity_mismatch_detects_a_changed_device() { assert!(export_destination_identity_mismatch(Some(1), Some(2))); assert!(!export_destination_identity_mismatch(Some(1), Some(1))); assert!( !export_destination_identity_mismatch(None, Some(2)), "an unknown expected device has nothing to compare against" ); assert!( export_destination_identity_mismatch(Some(1), None), "an opened file with unknown identity must not bypass a known expected device" ); assert!(!export_destination_identity_mismatch(None, None)); } // Regression test for review feedback on #6327: the non-Unix path used // to report no device identity at all, so replacing a Windows drive or // mount at the same path went undetected. This only runs on native // Windows (this repo's CI has no Windows job that executes `cargo test`, // only `cargo check`, so it is exercised locally by Windows contributors // and by the compile-check itself). #[cfg(windows)] #[test] fn export_destination_device_id_for_path_and_open_file_agree_on_windows() { let scratch = std::env::temp_dir().join(format!("dbx-export-dest-win-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&scratch).unwrap(); let dir_dev = super::export_destination_device_id_for_path(&scratch); assert!(dir_dev.is_some(), "a real local directory should report a volume serial number"); let file_path = scratch.join("probe.txt"); let file = std::fs::File::create(&file_path).unwrap(); let file_dev = super::export_destination_device_id_for_file(&file); drop(file); assert_eq!(dir_dev, file_dev, "a file and its parent directory must resolve to the same volume"); let _ = std::fs::remove_dir_all(&scratch); } #[test] fn kingbase_mysql_compat_export_insert_uses_backtick_identifiers() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: Some("`".to_string()), schema: Some("audit-schema".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, columns: vec!["id".to_string(), "event_type".to_string()], column_types: vec![None, None], column_extras: vec![None, None], rows: vec![vec![json!(1), json!("login")]], batch_size: Some(100), }) .unwrap(); assert_eq!(statements, vec!["INSERT INTO `audit-schema`.`events` (`id`, `event_type`) VALUES (1, 'login');"]); } }