Files
dbx/crates/dbx-sql/src/mysql_ddl_normalize.rs
T

378 lines
17 KiB
Rust

//! 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 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<usize>,
/// Upper-cased key, for case-insensitive matching.
key: String,
/// Raw value (without surrounding quotes), if the option had `= value`.
value: Option<String>,
}
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 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<usize>, &'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 <name> ( <columns> ) <options…>` 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<Vec<ParsedOption>> {
// `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<ParsedOption>, (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] <name>` 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<ParsedOption> {
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<String> {
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<String> {
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);
}
}