diff --git a/Cargo.lock b/Cargo.lock index 9f07790589..a8d103eca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1923,6 +1923,7 @@ dependencies = [ "tracing-subscriber", "uuid", "webpki-roots 0.26.11", + "winnow 1.0.3", "zip 4.6.1", ] diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 622911741e..7c9b95c9f0 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -60,6 +60,10 @@ const includeStructure = ref(true); const includeData = ref(true); const includeObjects = ref(true); const dropTableIfExists = ref(false); +const omitAutoIncrement = ref(false); +// `AUTO_INCREMENT` stripping is a MySQL-only DDL transform (backend gates on +// db_type == mysql, which also covers MariaDB / TiDB / OceanBase-MySQL-mode). +const isMysqlFamily = computed(() => store.getConfig(connectionId.value)?.db_type === "mysql"); // Export state const isExporting = ref(false); @@ -273,6 +277,7 @@ async function startExport() { includeData: includeData.value, includeObjects: includeObjects.value, dropTableIfExists: dropTableIfExists.value, + omitAutoIncrement: omitAutoIncrement.value, batchSize: 1000, }; @@ -366,6 +371,7 @@ async function startAllDatabasesExport() { includeData: includeData.value, includeObjects: includeObjects.value, dropTableIfExists: dropTableIfExists.value, + omitAutoIncrement: omitAutoIncrement.value, batchSize: 1000, }; @@ -454,6 +460,7 @@ function resetState() { includeData.value = true; includeObjects.value = true; dropTableIfExists.value = false; + omitAutoIncrement.value = false; isExporting.value = false; exportProgress.value = null; exportDone.value = false; @@ -677,6 +684,11 @@ watch( {{ t("databaseExport.dropTableIfExists") }} +
+ + + {{ t("databaseExport.omitAutoIncrement") }} +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f27e89748c..dee4df77b6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -4167,6 +4167,7 @@ export default { options: "Options", includeStructure: "Table structure (DDL)", dropTableIfExists: "Add DROP TABLE IF EXISTS before DDL", + omitAutoIncrement: "Omit AUTO_INCREMENT (for fresh-install scripts)", includeData: "Table data (INSERT)", includeObjects: "Views / Procedures / Functions / Sequences", databaseSelection: "Databases", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 55f62dbb93..c67285c796 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3933,6 +3933,7 @@ export default withEnglishFallback({ options: "Opciones", includeStructure: "Estructura de tablas (DDL)", dropTableIfExists: "Agregar DROP TABLE IF EXISTS antes del DDL", + omitAutoIncrement: "Omitir AUTO_INCREMENT (para scripts de instalación nueva)", includeData: "Datos de tablas (INSERT)", includeObjects: "Vistas / Procedimientos / Funciones / Secuencias", databaseSelection: "Bases de datos", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 71915b51c5..9db331ee14 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3931,6 +3931,7 @@ export default withEnglishFallback({ options: "Opzioni", includeStructure: "Struttura tabella (DDL)", dropTableIfExists: "Aggiungi DROP TABLE IF EXISTS prima del DDL", + omitAutoIncrement: "Ometti AUTO_INCREMENT (per script di installazione pulita)", includeData: "Dati tabella (INSERT)", includeObjects: "Viste / Procedure / Funzioni / Sequenze", databaseSelection: "Database", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 4979c2614a..4fffab020b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3932,6 +3932,7 @@ export default withEnglishFallback({ options: "オプション", includeStructure: "テーブル構造 (DDL)", dropTableIfExists: "DDLの前にDROP TABLE IF EXISTSを追加", + omitAutoIncrement: "AUTO_INCREMENT を省略(新規インストール用スクリプト向け)", includeData: "テーブルデータ (INSERT)", includeObjects: "ビュー / プロシージャ / 関数 / シーケンス", databaseSelection: "データベース", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ef6d6c62da..96b3b681db 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3933,6 +3933,7 @@ export default withEnglishFallback({ options: "Opções", includeStructure: "Estrutura da tabela (DDL)", dropTableIfExists: "Adicionar DROP TABLE IF EXISTS antes do DDL", + omitAutoIncrement: "Omitir AUTO_INCREMENT (para scripts de instalação do zero)", includeData: "Dados da tabela (INSERT)", includeObjects: "Views / Procedures / Funções / Sequências", databaseSelection: "Bancos de dados", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2fa73dd610..b7754d4266 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4157,6 +4157,7 @@ export default withEnglishFallback({ options: "选项", includeStructure: "表结构 (DDL)", dropTableIfExists: "导出前添加 DROP TABLE IF EXISTS", + omitAutoIncrement: "省略 AUTO_INCREMENT(用于全新安装的初始化脚本)", includeData: "表数据 (INSERT)", includeObjects: "视图 / 存储过程 / 函数 / 序列", databaseSelection: "选择数据库", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index fec1632dee..4074a77aa9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3749,6 +3749,7 @@ export default withEnglishFallback({ options: "選項", includeStructure: "資料表結構 (DDL)", dropTableIfExists: "匯出前新增 DROP TABLE IF EXISTS", + omitAutoIncrement: "省略 AUTO_INCREMENT(用於全新安裝的初始化腳本)", includeData: "資料表資料 (INSERT)", includeObjects: "檢視 / 預存程序 / 函式 / 序列", databaseSelection: "選擇資料庫", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index af4ee6920e..0a98f75c83 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2330,6 +2330,7 @@ export interface DatabaseExportRequest { includeData: boolean; includeObjects: boolean; dropTableIfExists?: boolean; + omitAutoIncrement?: boolean; failOnError?: boolean; snapshotSessionId?: string; batchSize: number; diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 0eecc300e4..fb96459148 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -42,6 +42,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] } json5 = "0.4" regex = "1" +winnow = "1.0" rayon = "1" percent-encoding = "2" encoding_rs = "0.8" diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index f26893bcab..b339ed6dc3 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -6,6 +6,7 @@ use std::sync::RwLock; 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, quote_table_identifier, uses_single_row_insert_statements}; use crate::transfer::{ @@ -39,6 +40,11 @@ pub struct DatabaseExportRequest { pub include_objects: 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)] @@ -183,6 +189,10 @@ pub struct BuildDatabaseSqlExportOptions { 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 { @@ -802,8 +812,13 @@ 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 = 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(format_export_table_ddl(ddl, table.database_type)); + lines.push(ddl); lines.push(String::new()); } @@ -851,19 +866,20 @@ fn export_qualified_table_name( Ok(qualified_table_name(database_type, schema, table_name)) } -fn normalize_export_table_ddl(ddl: &str, database_type: Option) -> String { +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(); } - static LEGACY_MYSQL_ROW_FORMAT_RE: std::sync::LazyLock = - std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)\bROW_FORMAT\s*=\s*(COMPACT|REDUNDANT)\b").unwrap()); - - LEGACY_MYSQL_ROW_FORMAT_RE.replace_all(ddl, "ROW_FORMAT=DYNAMIC").into_owned() + crate::mysql_ddl_normalize::normalize_mysql_export_ddl(ddl, opts) } -fn format_export_table_ddl(ddl: &str, database_type: Option) -> String { - let ddl = normalize_export_table_ddl(ddl, database_type); +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};") } @@ -1558,7 +1574,11 @@ pub async fn export_database_sql_core( }; match ddl_result { Ok(ddl) => { - let ddl = format_export_table_ddl(&ddl, Some(db_type)); + 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) => { @@ -1979,9 +1999,9 @@ mod tests { 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, - PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers, DATABASE_EXPORT_INSERT_BATCH_SIZE, - DATABASE_EXPORT_ROW_LIMIT, + record_export_error, BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, DdlNormalizeOptions, + ExportedTableSql, PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers, + DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT, }; use crate::models::connection::DatabaseType; use crate::types::{ObjectInfo, ObjectSourceKind, TableInfo}; @@ -2671,6 +2691,7 @@ mod tests { connection_id: None, database: None, schema: None, + omit_auto_increment: false, }) .unwrap(); @@ -2698,18 +2719,62 @@ mod tests { 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)), + 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)); + let normalized = normalize_export_table_ddl(ddl, Some(DatabaseType::Mysql), DdlNormalizeOptions::default()); assert_eq!( normalized, @@ -2721,7 +2786,7 @@ mod tests { 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)); + 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"); } @@ -2731,8 +2796,14 @@ mod tests { 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)), mysql_ddl); - assert_eq!(normalize_export_table_ddl(postgres_ddl, Some(DatabaseType::Postgres)), postgres_ddl); + 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 { diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index a7c2c81e2e..f778a125a7 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -34,6 +34,7 @@ pub mod mongo_ops; pub mod mongo_shell; #[cfg(feature = "mq-admin")] pub mod mq; +pub(crate) mod mysql_ddl_normalize; pub mod nacos; pub mod object_source_sql; pub mod path_utils; diff --git a/crates/dbx-core/src/mysql_ddl_normalize.rs b/crates/dbx-core/src/mysql_ddl_normalize.rs new file mode 100644 index 0000000000..2fdfc8edd3 --- /dev/null +++ b/crates/dbx-core/src/mysql_ddl_normalize.rs @@ -0,0 +1,377 @@ +//! Post-processing of MySQL `SHOW CREATE TABLE` DDL for database export. +//! +//! Two transforms, both applied to the table-options tail (the text after the +//! column list's closing `)`): +//! - **opt-in** strip of the table-level `AUTO_INCREMENT=N` clause. The value `N` +//! is the source table's current sequence position — correct for the source +//! DB, but noise (and misleading) in a fresh-install script. +//! - **always-on** rewrite of legacy `ROW_FORMAT=COMPACT|REDUNDANT` to +//! `ROW_FORMAT=DYNAMIC`, for MySQL 8+ export compatibility. +//! +//! The options tail is parsed with [`winnow`] into structured `(key, value, span)` +//! records. Edits are applied to the *original* string by byte span, so every +//! byte that is not an edited option is preserved verbatim (parse-to-locate, +//! not re-serialize — no fidelity drift). Any parse failure **fails open**: the +//! original DDL is returned unchanged, because the correctness of exported DDL +//! matters more than this cosmetic option. +//! +//! Why winnow and not a regex: the value of `AUTO_INCREMENT`/`COMMENT` options +//! can contain the literal text `AUTO_INCREMENT=N` inside a quoted string (e.g. +//! `COMMENT='starts at AUTO_INCREMENT=1000'`). A quote-aware parser skips those +//! correctly; a blind regex cannot. winnow 1.x is already in the build graph +//! (transitively, via `toml_edit`), so adding it as a direct dependency compiles +//! no new code. + +use std::ops::Range; + +use winnow::ascii::multispace1; +use winnow::combinator::{alt, delimited, opt, preceded, repeat}; +use winnow::prelude::*; +use winnow::stream::LocatingSlice; +use winnow::token::{none_of, take_till, take_until, take_while}; +use winnow::ModalResult; + +/// Knobs for [`normalize_mysql_export_ddl`]. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct DdlNormalizeOptions { + /// When true, drop the table-level `AUTO_INCREMENT=N` option from the DDL. + pub omit_auto_increment: bool, +} + +/// One parsed MySQL table option, e.g. `ENGINE=InnoDB`, `AUTO_INCREMENT=121`, +/// `COMMENT='...'`, or a bare keyword such as `DEFAULT`. +#[derive(Debug, Clone)] +struct ParsedOption { + /// Byte span of the option *core* (`KEY[=VALUE]`, excluding surrounding + /// whitespace), relative to the options tail. + span: Range, + /// Upper-cased key, for case-insensitive matching. + key: String, + /// Raw value (without surrounding quotes), if the option had `= value`. + value: Option, +} + +const AUTO_INCREMENT: &str = "AUTO_INCREMENT"; +const ROW_FORMAT: &str = "ROW_FORMAT"; + +/// Entry point. Returns the (possibly edited) DDL. On any structural surprise +/// the input is returned unchanged. +pub(crate) fn normalize_mysql_export_ddl(ddl: &str, opts: DdlNormalizeOptions) -> String { + // One winnow pass over the whole DDL (wrapped in `LocatingSlice`), so every + // option carries an absolute byte span into `ddl` — no offset math. + let Some(options) = parse_create_table_options(ddl) else { + return ddl.to_string(); // fail-open: unparseable DDL returned verbatim + }; + + // Compute edits as absolute byte ranges in `ddl`. An empty replacement means + // "delete this range". + let bytes = ddl.as_bytes(); + let mut edits: Vec<(Range, &'static str)> = Vec::new(); + for opt in &options { + let Range { start, end } = opt.span; + if opts.omit_auto_increment && opt.key == AUTO_INCREMENT { + // Preserve line endings because they may terminate a preceding line comment. + let start = if start > 0 && matches!(bytes[start - 1], b' ' | b'\t') { start - 1 } else { start }; + edits.push((start..end, "")); + } else if opt.key == ROW_FORMAT { + if let Some(value) = opt.value.as_deref() { + let value = value.to_ascii_uppercase(); + if value == "COMPACT" || value == "REDUNDANT" { + edits.push((start..end, "ROW_FORMAT=DYNAMIC")); + } + } + } + } + + if edits.is_empty() { + return ddl.to_string(); + } + + // Apply edits to the original string. Distinct options never overlap; sort + // by start and stitch the kept slices around each edit. + edits.sort_by_key(|(range, _)| range.start); + let mut out = String::with_capacity(ddl.len()); + let mut cursor = 0usize; + for (range, replacement) in &edits { + if range.start < cursor { + continue; // defensive: never expected for distinct options + } + out.push_str(&ddl[cursor..range.start]); + out.push_str(replacement); + cursor = range.end; + } + out.push_str(&ddl[cursor..]); + out +} + +// --------------------------------------------------------------------------- +// winnow parser. +// +// One pass over the whole DDL (wrapped in `LocatingSlice`), so every parsed +// option carries an absolute byte span into `ddl`. The column-definition body +// is skipped with a recursive balanced-`(...)` parser — it never inspects +// column content, only tracks nesting and quoted spans so a `(` inside a type +// like `decimal(30,6)` or inside a backtick-quoted table name doesn't fool it. +// Any parse failure fails open (the caller returns the original DDL). +// --------------------------------------------------------------------------- + +/// Parse `CREATE TABLE ( ) ` into the table options, +/// each with an absolute byte span. The column body is skipped, not modeled. +fn parse_create_table_options(ddl: &str) -> Option> { + // `skip_create_table_prefix` + `skip_column_body` position the stream at the + // options tail; `preceded` discards them. `parse_peek` allows a trailing `;` + // or an unmodeled option shape without failing the whole parse. + let mut parser = preceded( + (skip_create_table_prefix, skip_column_body), + repeat(0.., preceded(skip_ws_and_comments, table_option.with_span())).fold( + Vec::new, + |mut acc: Vec, (mut option, span)| { + option.span = span; + acc.push(option); + acc + }, + ), + ); + match parser.parse_peek(LocatingSlice::new(ddl)) { + Ok((_remaining, options)) => Some(options), + Err(_) => None, + } +} + +/// Consume the `CREATE TABLE [IF NOT EXISTS] ` prefix, stopping just before +/// the column-list opening `(`. Quoted identifiers and comments are consumed +/// whole, so a `(` inside e.g. `` `weird(name)` `` is not mistaken for the +/// column-list opener. +fn skip_create_table_prefix(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + repeat(0.., alt((sql_quoted, sql_line_comment, sql_block_comment, none_of('(').void()))) + .fold(|| (), |(), _| ()) + .parse_next(input) +} + +/// Consume one balanced `(...)` group (the column body), skipping nested parens, +/// quoted strings, and comments. Produces no value. +fn skip_column_body(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + delimited('(', repeat(0.., column_body_token).fold(|| (), |(), _| ()), ')').parse_next(input) +} + +/// One token inside the column body: a nested balanced group, a quoted string, +/// a comment, or any single non-`)` character. `skip_column_body` is tried first +/// so nested `(` is handled recursively, not as a plain character. +fn column_body_token(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + alt((skip_column_body, sql_quoted, sql_line_comment, sql_block_comment, none_of(')').void())).parse_next(input) +} + +/// A `-- …` or `# …` line comment (to end of line). Produces no value. +fn sql_line_comment(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + alt((("--", take_till(0.., ['\n', '\r'])).void(), ('#', take_till(0.., ['\n', '\r'])).void())).parse_next(input) +} + +/// A `/* … */` block comment (MySQL block comments do not nest). Produces no value. +fn sql_block_comment(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + ("/*", take_until(0.., "*/"), "*/").void().parse_next(input) +} + +/// Parses one table option: `KEY`, `KEY=VALUE`, or `KEY = VALUE`. +fn table_option(input: &mut LocatingSlice<&str>) -> ModalResult { + let key = option_key.parse_next(input)?; + let value = + opt(preceded(delimited(skip_ws_and_comments, '=', skip_ws_and_comments), option_value)).parse_next(input)?; + Ok(ParsedOption { span: 0..0, key, value }) +} + +/// An option key: run of ASCII alphanumerics and `_`, upper-cased for matching. +fn option_key(input: &mut LocatingSlice<&str>) -> ModalResult { + take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_') + .map(|s: &str| s.to_ascii_uppercase()) + .parse_next(input) +} + +/// An option value: either a quoted SQL token (whose content we do not need — +/// only its byte span, to keep the tail parse moving past `COMMENT='...'`) or a +/// bare token (identifier/number). +fn option_value(input: &mut LocatingSlice<&str>) -> ModalResult { + alt((sql_quoted.value(String::new()), bare_value.map(|s: &str| s.to_string()))).parse_next(input) +} + +fn bare_value<'i>(input: &mut LocatingSlice<&'i str>) -> ModalResult<&'i str> { + take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').parse_next(input) +} + +/// Skip any run of whitespace and/or SQL comments. Used as the separator between +/// table options so that a comment sitting between options (or between `)` and +/// the first option, or around `=`) does not stop the parse short of later +/// options like `AUTO_INCREMENT`. Matches zero or more chunks (so it also acts +/// like `multispace0` when there is nothing but whitespace). +fn skip_ws_and_comments(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + repeat(0.., alt((multispace1.void(), sql_line_comment, sql_block_comment))) + .fold(|| (), |(), _| ()) + .parse_next(input) +} + +/// Any single-, double-, or backtick-quoted SQL token (content discarded). +fn sql_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + alt((sql_single_quoted, sql_double_quoted, sql_backtick_quoted)).parse_next(input) +} + +/// Consumes a `'…'` token honoring `''` doubling, producing no value. The +/// `repeat(...).fold((), ..)` discards fragments while pinning the accumulator +/// type (a bare `repeat(...).void()` cannot infer it). +fn sql_single_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + delimited('\'', repeat(0.., alt(("''", take_till(1.., '\'')))).fold(|| (), |(), _| ()), '\'').parse_next(input) +} + +fn sql_double_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + delimited('"', repeat(0.., alt(("\"\"", take_till(1.., '"')))).fold(|| (), |(), _| ()), '"').parse_next(input) +} + +fn sql_backtick_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> { + delimited('`', repeat(0.., alt(("``", take_till(1.., '`')))).fold(|| (), |(), _| ()), '`').parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(omit_auto_increment: bool) -> DdlNormalizeOptions { + DdlNormalizeOptions { omit_auto_increment } + } + + #[test] + fn strips_table_level_auto_increment() { + let ddl = "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=121 DEFAULT CHARSET=utf8mb4"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + + #[test] + fn preserves_column_level_auto_increment() { + let ddl = "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=5"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + // Table-level gone, column-level bare AUTO_INCREMENT untouched. + assert!(out.contains("`id` bigint NOT NULL AUTO_INCREMENT")); + assert!(!out.contains("AUTO_INCREMENT=5")); + } + + #[test] + fn keeps_auto_increment_when_option_off() { + let ddl = "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=121"; + assert_eq!(normalize_mysql_export_ddl(ddl, opts(false)), ddl); + } + + #[test] + fn no_double_space_left_behind() { + let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=7 COLLATE=utf8mb4_bin"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB COLLATE=utf8mb4_bin"); + assert!(!out.contains(" ")); + } + + #[test] + fn auto_increment_inside_comment_is_not_stripped() { + let ddl = + "CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=9 COMMENT='see AUTO_INCREMENT=1000 in docs'"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + // Table-level `AUTO_INCREMENT=9` is removed; the COMMENT literal (including + // its inner `AUTO_INCREMENT=1000` text) is preserved verbatim — this is the + // quote-awareness a blind regex could not provide. + assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB COMMENT='see AUTO_INCREMENT=1000 in docs'"); + } + + #[test] + fn comment_between_close_paren_and_first_option() { + let ddl = "CREATE TABLE `t` (`id` int) /* generated by tool x */ ENGINE=InnoDB AUTO_INCREMENT=5"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` int) /* generated by tool x */ ENGINE=InnoDB"); + } + + #[test] + fn block_comment_between_options_ignores_inner_parens() { + // The `(` and `)` inside the block comment must stay opaque; they must + // not be mistaken for column-list delimiters or option boundaries. + let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* see (a) and )b( */ AUTO_INCREMENT=5"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* see (a) and )b( */"); + } + + #[test] + fn auto_increment_inside_block_comment_not_stripped() { + // Only the real table-level option is removed; the one inside the block + // comment is opaque and preserved verbatim. + let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* AUTO_INCREMENT=999 */ AUTO_INCREMENT=5"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* AUTO_INCREMENT=999 */"); + } + + #[test] + fn line_comment_between_options() { + let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB -- remember to bump\nAUTO_INCREMENT=5"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB -- remember to bump\n"); + } + + #[test] + fn comment_around_equals_sign_in_option() { + let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT/*c*/=/*c*/5"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB"); + } + + #[test] + fn rewrites_legacy_row_format() { + let ddl = "CREATE TABLE `t` (`p` varchar(9)) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT"; + let out = normalize_mysql_export_ddl(ddl, opts(false)); + assert_eq!(out, "CREATE TABLE `t` (`p` varchar(9)) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC"); + } + + #[test] + fn row_format_rewrite_is_case_insensitive_and_normalizes_output() { + let ddl = "CREATE TABLE `t` (`p` text) engine=InnoDB row_format = redundant"; + let out = normalize_mysql_export_ddl(ddl, opts(false)); + assert_eq!(out, "CREATE TABLE `t` (`p` text) engine=InnoDB ROW_FORMAT=DYNAMIC"); + } + + #[test] + fn non_legacy_row_format_untouched() { + let ddl = "CREATE TABLE `t` (`p` text) ENGINE=InnoDB ROW_FORMAT=COMPRESSED"; + assert_eq!(normalize_mysql_export_ddl(ddl, opts(false)), ddl); + } + + #[test] + fn both_transforms_compose() { + let ddl = "CREATE TABLE `t` (`p` text) ENGINE=InnoDB AUTO_INCREMENT=42 ROW_FORMAT=COMPACT DEFAULT CHARSET=utf8"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`p` text) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8"); + } + + #[test] + fn nested_parens_in_column_body_do_not_confuse_tail_location() { + let ddl = "CREATE TABLE `t` (`amount` decimal(30,6) NOT NULL, `st` enum('a','b') NOT NULL, CHECK (amount > 0)) ENGINE=InnoDB AUTO_INCREMENT=3"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert!(out.contains("decimal(30,6)")); + assert!(out.contains("enum('a','b')")); + assert!(out.contains("CHECK (amount > 0)")); + assert!(!out.contains("AUTO_INCREMENT=3")); + } + + #[test] + fn table_name_containing_paren_does_not_mislocate_tail() { + // The `(` inside the backtick-quoted table name must not be mistaken for + // the column-list opener; the real options tail is after the column list. + let ddl = "CREATE TABLE `weird(name)` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=7"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `weird(name)` (`id` int) ENGINE=InnoDB"); + } + + #[test] + fn multibyte_comment_survives_byte_offset_edit() { + let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=11 COMMENT='每日收益表'"; + let out = normalize_mysql_export_ddl(ddl, opts(true)); + assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB COMMENT='每日收益表'"); + } + + #[test] + fn malformed_ddl_fails_open_unchanged() { + let ddl = "CREATE TABLE `t` (`id` int"; // unbalanced paren + assert_eq!(normalize_mysql_export_ddl(ddl, opts(true)), ddl); + } +} diff --git a/crates/dbx-core/tests/database_export_prefetch.rs b/crates/dbx-core/tests/database_export_prefetch.rs index d982ca97a2..c3c4e829bf 100644 --- a/crates/dbx-core/tests/database_export_prefetch.rs +++ b/crates/dbx-core/tests/database_export_prefetch.rs @@ -177,6 +177,7 @@ async fn database_export_writes_structure_and_data_for_all_tables() { include_data: true, include_objects: false, drop_table_if_exists: true, + omit_auto_increment: false, fail_on_error: true, snapshot_session_id: None, batch_size: 1000,