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") }}
+
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,