From a12f8d09b7f9dff9e25be188bc0a41c0f4bf799f Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 21 Jul 2026 17:13:50 +0800 Subject: [PATCH] fix(mysql): handle export terminators and escaped quotes --- crates/dbx-core/src/database_export.rs | 26 ++++++++++++---- crates/dbx-core/src/sql.rs | 41 ++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 17061896c..88ca3b85c 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -783,9 +783,8 @@ pub fn build_database_sql_export(options: BuildDatabaseSqlExportOptions) -> Resu for table in options.tables { if let Some(ddl) = table.ddl.as_ref().map(|ddl| ddl.trim()).filter(|ddl| !ddl.is_empty()) { - let ddl = normalize_export_table_ddl(ddl, table.database_type); lines.push(format!("-- Structure for {}", table.display_name)); - lines.push(format!("{};", ddl.trim_end_matches(';'))); + lines.push(format_export_table_ddl(ddl, table.database_type)); lines.push(String::new()); } @@ -844,6 +843,12 @@ fn normalize_export_table_ddl(ddl: &str, database_type: Option) -> LEGACY_MYSQL_ROW_FORMAT_RE.replace_all(ddl, "ROW_FORMAT=DYNAMIC").into_owned() } +fn format_export_table_ddl(ddl: &str, database_type: Option) -> String { + let ddl = normalize_export_table_ddl(ddl, database_type); + let ddl = ddl.trim().trim_end_matches(';').trim_end(); + format!("{ddl};") +} + fn postgres_sequence_qualified_name(schema: &str, sequence_name: &str) -> String { let db_type = DatabaseType::Postgres; if schema.trim().is_empty() { @@ -1534,8 +1539,8 @@ pub async fn export_database_sql_core( }; match ddl_result { Ok(ddl) => { - let ddl = normalize_export_table_ddl(&ddl, Some(db_type)); - writeln!(file, "{};\n", ddl).map_err(|e| format!("Failed to write file: {e}"))?; + let ddl = format_export_table_ddl(&ddl, Some(db_type)); + writeln!(file, "{ddl}\n").map_err(|e| format!("Failed to write file: {e}"))?; } Err(e) => { record_export_error( @@ -1952,7 +1957,7 @@ mod tests { use super::concurrent_metadata_prefetch_allowed; use super::{ build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements, - drop_table_if_exists_sql, filter_export_table_infos, format_export_sql_literal, + drop_table_if_exists_sql, filter_export_table_infos, format_export_sql_literal, format_export_table_ddl, generate_postgres_extension_ddl, generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl, generate_postgres_sequence_setval_sql, is_postgres_extension_member_routine, normalize_export_table_ddl, record_export_error, BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql, @@ -2629,6 +2634,17 @@ mod tests { ); } + #[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)), "CREATE TABLE `users` (`id` int);"); + assert_eq!( + format_export_table_ddl("CREATE TABLE users (id int)", Some(DatabaseType::Postgres)), + "CREATE TABLE users (id int);" + ); + } + #[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"; diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index 898ec6637..84ce24cdf 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -382,11 +382,11 @@ impl SqlStatementSplitter { } match ch { - '\'' if !self.in_double_quote && !self.in_backtick && self.previous != Some('\\') => { + '\'' if !self.in_double_quote && !self.in_backtick && !has_odd_trailing_backslashes(&self.buffer) => { self.in_single_quote = !self.in_single_quote; self.buffer.push(ch); } - '"' if !self.in_single_quote && !self.in_backtick && self.previous != Some('\\') => { + '"' if !self.in_single_quote && !self.in_backtick && !has_odd_trailing_backslashes(&self.buffer) => { self.in_double_quote = !self.in_double_quote; self.buffer.push(ch); } @@ -522,6 +522,10 @@ impl SqlStatementSplitter { } } +fn has_odd_trailing_backslashes(sql: &str) -> bool { + sql.as_bytes().iter().rev().take_while(|byte| **byte == b'\\').count() % 2 == 1 +} + pub fn split_sql_statements(sql: &str) -> Vec { split_sql_statements_with_options(sql, SqlParsingOptions::default()) } @@ -2457,6 +2461,39 @@ mod tests { ); } + #[test] + fn closes_mysql_string_after_even_trailing_backslashes() { + let sql = r#"CREATE TABLE paths (value varchar(100) COMMENT 'Windows path\\'); DROP TABLE paths;"#; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Mysql), + vec![r#"CREATE TABLE paths (value varchar(100) COMMENT 'Windows path\\')"#, "DROP TABLE paths"] + ); + } + + #[test] + fn keeps_mysql_string_open_after_odd_trailing_backslashes() { + let sql = r#"INSERT INTO notes VALUES ('it\'s; still one value'); SELECT 1;"#; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Mysql), + vec![r#"INSERT INTO notes VALUES ('it\'s; still one value')"#, "SELECT 1"] + ); + } + + #[test] + fn closes_mysql_string_after_even_trailing_backslashes_across_chunks() { + let mut splitter = + SqlStatementSplitter::with_options(SqlParsingOptions::for_database_type(DatabaseType::Mysql)); + + assert!(splitter.push_chunk("CREATE TABLE paths (value varchar(100) COMMENT 'Windows path\\").is_empty()); + assert_eq!( + splitter.push_chunk("\\'); DROP TABLE paths;"), + vec![r#"CREATE TABLE paths (value varchar(100) COMMENT 'Windows path\\')"#, "DROP TABLE paths"] + ); + assert!(splitter.finish().is_empty()); + } + #[test] fn emits_trailing_statement_without_semicolon() { assert_eq!(