From 2b8329f0f22f2fc1ec991d13e8798ad30606d5e7 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 25 Sep 2026 10:34:55 +0800 Subject: [PATCH] fix(export): declare utf8mb4 charset in MySQL export script --- crates/dbx-core/src/data/database_export.rs | 16 ++++++++++++++++ .../dbx-core/tests/live_mysql_database_export.rs | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/dbx-core/src/data/database_export.rs b/crates/dbx-core/src/data/database_export.rs index 0a9374a60d..be98afb649 100644 --- a/crates/dbx-core/src/data/database_export.rs +++ b/crates/dbx-core/src/data/database_export.rs @@ -30,6 +30,15 @@ static EXPORT_CANCELLED: std::sync::LazyLock>> = const EXPORT_CANCELLED_ERROR: &str = "Export cancelled"; const EXPORT_CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(50); +/// MySQL scripts written by the exporter are always UTF-8: values are decoded off the +/// wire and reformatted into Rust strings before they are written, so the file encoding +/// never follows the connection charset. Declaring that encoding in the script keeps the +/// importing client's default out of the decoding decision -- a `latin1` `mysql` CLI (a +/// docker entrypoint initializing a container, for example) would otherwise re-encode +/// every non-ASCII value into mojibake (#10242). `mysqldump` writes the same statement +/// for the same reason. +const MYSQL_EXPORT_CHARSET_STATEMENT: &str = "SET NAMES utf8mb4;"; + pub fn database_export_client_session_id(export_id: &str) -> String { task_client_session_id("database-export", export_id) } @@ -3020,6 +3029,13 @@ async fn export_database_sql_core_inner( 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}"))?; + // Declare the script's encoding before the first statement that can carry non-ASCII + // text (the database name is written by the preamble right below, and table, column + // and DEFAULT values follow). + if matches!(db_type, DatabaseType::Mysql) { + writeln!(file, "{MYSQL_EXPORT_CHARSET_STATEMENT}").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}"))?; diff --git a/crates/dbx-core/tests/live_mysql_database_export.rs b/crates/dbx-core/tests/live_mysql_database_export.rs index 3e33fe7db8..52a6dcc7e8 100644 --- a/crates/dbx-core/tests/live_mysql_database_export.rs +++ b/crates/dbx-core/tests/live_mysql_database_export.rs @@ -404,6 +404,16 @@ async fn live_mysql_database_export_creates_missing_destination_directory() { let exported = std::fs::read_to_string(&file_path).unwrap(); assert!(exported.contains("'alpha'"), "exported SQL should contain the seeded row"); + // #10242: the script must declare its own encoding before the first statement that can + // carry non-ASCII text, otherwise an importing client whose default charset is not + // utf8mb4 (a `latin1` mysql CLI in a docker entrypoint, for example) re-encodes every + // non-ASCII value the exporter wrote as UTF-8 into mojibake. + let charset = exported.find("SET NAMES utf8mb4;").expect("MySQL exports must declare their encoding"); + let create_database = exported.find("CREATE DATABASE").expect("the CREATE DATABASE preamble should be exported"); + let create_table = exported.find("CREATE TABLE").expect("the table DDL should be exported"); + assert!(charset < create_database, "SET NAMES must precede the CREATE DATABASE preamble"); + assert!(charset < create_table, "SET NAMES must precede the table DDL"); + std::fs::remove_dir_all(dir).unwrap(); }