fix(schema): quote MySQL bare column defaults safely

This commit is contained in:
Dhevenddra K G
2026-08-14 00:16:05 +08:00
committed by GitHub
parent 35eab8a8b1
commit f6518ff3a0
+273 -8
View File
@@ -3108,14 +3108,22 @@ fn quote_id(name: &str, db_type: DatabaseType) -> String {
profile_for(db_type).quote_ident(name)
}
fn column_def(col: &ColumnInfo, db_type: DatabaseType) -> String {
fn column_def(col: &ColumnInfo, db_type: DatabaseType, source_dialect: Option<DialectKind>) -> String {
let profile = profile_for(db_type);
let mut definition = format!("{} {}", quote_id(&col.name, db_type), col.data_type);
if !col.is_nullable {
definition.push_str(" NOT NULL");
}
if let Some(default) = &col.column_default {
definition.push_str(&format!(" DEFAULT {default}"));
definition.push_str(&format!(
" DEFAULT {}",
default_literal(
default,
&col.data_type,
effective_source_dialect(source_dialect, db_type),
col.extra.as_deref()
)
));
}
if profile.inline_column_comment {
if let Some(comment) = &col.comment {
@@ -3247,6 +3255,120 @@ fn comment_literal(comment: &str) -> String {
format!("'{}'", comment.replace('\'', "''"))
}
/// Bare temporal keywords that are defaults in their own right and must not be quoted.
const TEMPORAL_DEFAULT_KEYWORDS: [&str; 8] =
["current_timestamp", "current_date", "current_time", "now", "localtime", "localtimestamp", "getdate", "sysdate"];
/// Prefixes that introduce an already-quoted literal: SQL Server / Sybase `N'x'`,
/// MySQL `b'1'` and `x'1f'`, Postgres `e'\n'`.
const QUOTED_LITERAL_PREFIXES: [&str; 4] = ["n'", "b'", "x'", "e'"];
/// The dialect the column metadata came from.
///
/// The caller does not always declare one. When it does not, the comparison is
/// same-dialect, so the target database is also the source.
fn effective_source_dialect(source_dialect: Option<DialectKind>, db_type: DatabaseType) -> DialectKind {
source_dialect.unwrap_or_else(|| DialectKind::from_database_type(db_type))
}
/// Render a column default as a SQL literal.
///
/// Drivers hand `column_default` back verbatim and they do not agree on its
/// shape, so the rule has to be bound to the dialect the value came from rather
/// than guessed from the value itself. The same bare token means different
/// things in different databases: `CURRENT_USER` is an expression on Postgres
/// and Oracle, while on MySQL a bare `CURRENT_USER` on a text column is the
/// literal string.
///
/// Only MySQL-family metadata strips the quotes from a string default, so it is
/// the only source that needs any repair here. Everywhere else the value already
/// arrives quoted, cast or wrapped, and is passed through untouched.
///
/// `table_structure_sql::util::format_default_for_sql` and
/// `transfer::format_mysql_default_literal` do the same job on their own paths;
/// both are private to their modules.
fn default_literal(default: &str, data_type: &str, source: DialectKind, extra: Option<&str>) -> String {
let normalized = default.trim();
// Every dialect except MySQL returns a string default already quoted, cast
// or wrapped, so a bare token there is an expression and rewriting it would
// change its meaning: Postgres `text DEFAULT CURRENT_USER`, Oracle
// `varchar2 DEFAULT USER`, SQL Server `('x')`.
if source != DialectKind::Mysql {
return normalized.to_string();
}
// MySQL 8.0.13 and later flag an expression default in `EXTRA`. That marker
// is authoritative, so consult it before looking at the value at all: a
// default is a literal unless the server says it was generated.
if extra.is_some_and(|value| value.to_ascii_uppercase().contains("DEFAULT_GENERATED")) {
return normalized.to_string();
}
// MySQL writes an expression default wrapped in parentheses, `DEFAULT
// (uuid())`, and reports it that way. The wrapping is the syntax, so it is
// a reliable marker even when `EXTRA` is not populated. Note this asks
// whether the value *is* parenthesised, not whether it merely contains a
// parenthesis: the string default `a(b)` is not wrapped and is still
// quoted.
if normalized.starts_with('(') && normalized.ends_with(')') {
return normalized.to_string();
}
// Already a literal: `'x'` or a prefixed form like `x'1f'`.
let lowered = normalized.to_ascii_lowercase();
if normalized.starts_with('\'') || QUOTED_LITERAL_PREFIXES.iter().any(|prefix| lowered.starts_with(prefix)) {
return normalized.to_string();
}
let base_type = data_type.split('(').next().unwrap_or(data_type).trim().to_ascii_lowercase();
let takes_text_literal =
["char", "text", "string", "clob", "enum", "set"].iter().any(|kind| base_type.contains(kind));
let takes_binary_literal = ["binary", "blob", "bytea"].iter().any(|kind| base_type.contains(kind));
let takes_temporal_literal = base_type.contains("date") || base_type.contains("time");
// Before 8.0.13 there is no `EXTRA` marker and a temporal column was the
// only place an expression default could appear.
if takes_temporal_literal && is_temporal_keyword_default(normalized) {
return normalized.to_string();
}
// A binary default is commonly reported as a hex literal, which is already
// valid unquoted; a bare string on the same column still needs quoting.
if takes_binary_literal && is_hex_literal(normalized) {
return normalized.to_string();
}
if takes_text_literal || takes_binary_literal || takes_temporal_literal {
// Deliberately no parenthesis check. MySQL reports the string default
// `'a(b)'` as the bare value `a(b)`, and treating a parenthesis as proof
// of a function call is what produced invalid `DEFAULT a(b)`.
return format!("'{}'", default.replace('\'', "''"));
}
normalized.to_string()
}
/// A bare temporal default, with or without a precision argument, so
/// `CURRENT_TIMESTAMP(6)` and `LOCALTIME(3)` are recognised alongside the bare
/// keywords. `transfer::is_mysql_function_default` already accepts the
/// parenthesised forms and this mirrors it.
///
/// Deliberately keyed on the known keywords rather than on the presence of a
/// parenthesis, so a string default such as `a(b)` is still quoted.
fn is_temporal_keyword_default(value: &str) -> bool {
let upper = value.to_ascii_uppercase();
TEMPORAL_DEFAULT_KEYWORDS.iter().any(|keyword| {
let keyword = keyword.to_ascii_uppercase();
upper == keyword || upper.strip_prefix(&keyword).is_some_and(|rest| rest.starts_with('('))
})
}
/// `0x61`, the shape MySQL reports a binary default in.
fn is_hex_literal(value: &str) -> bool {
let Some(digits) = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")) else {
return false;
};
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_hexdigit())
}
fn column_comment_sql(
table_name: &str,
column_name: &str,
@@ -3356,7 +3478,15 @@ fn generate_create_table_sql(
}
if !skip_default {
if let Some(default) = &col.column_default {
def.push_str(&format!(" DEFAULT {default}"));
def.push_str(&format!(
" DEFAULT {}",
default_literal(
default,
&mapped_type,
effective_source_dialect(source_dialect, db_type),
col.extra.as_deref()
)
));
}
}
if profile.inline_column_comment {
@@ -3383,7 +3513,15 @@ fn generate_create_table_sql(
}
if !skip_default {
if let Some(default) = &col.column_default {
def.push_str(&format!(" DEFAULT {default}"));
def.push_str(&format!(
" DEFAULT {}",
default_literal(
default,
&mapped_type,
effective_source_dialect(source_dialect, db_type),
col.extra.as_deref()
)
));
}
}
if profile.inline_column_comment {
@@ -3860,7 +3998,7 @@ fn generate_schema_sync_sql_inner(
};
parts.push(format!(
" ADD COLUMN {}{}",
column_def(&convert_col(source), db_type),
column_def(&convert_col(source), db_type, source_dialect),
position
));
}
@@ -3873,7 +4011,10 @@ fn generate_schema_sync_sql_inner(
let mapped = convert_col(source);
if profile.alter_uses_modify_column {
if column.changes.iter().any(|change| !change.starts_with("order:")) {
parts.push(format!(" MODIFY COLUMN {}", column_def(&mapped, db_type)));
parts.push(format!(
" MODIFY COLUMN {}",
column_def(&mapped, db_type, source_dialect)
));
}
} else {
let name = quote_id(&column.name, db_type);
@@ -3889,7 +4030,15 @@ fn generate_schema_sync_sql_inner(
}
if column.changes.iter().any(|change| change.starts_with("default:")) {
parts.push(if let Some(default) = &source.column_default {
format!(" ALTER COLUMN {name} SET DEFAULT {default}")
format!(
" ALTER COLUMN {name} SET DEFAULT {}",
default_literal(
default,
&mapped.data_type,
effective_source_dialect(source_dialect, db_type),
source.extra.as_deref()
)
)
} else {
format!(" ALTER COLUMN {name} DROP DEFAULT")
});
@@ -3907,7 +4056,7 @@ fn generate_schema_sync_sql_inner(
parts.push(format!(
" CHANGE COLUMN {} {}",
old_name,
column_def(&mapped, db_type)
column_def(&mapped, db_type, source_dialect)
));
}
RenameColumnSyntax::RenameColumn => {
@@ -6771,6 +6920,122 @@ mod tests {
assert!(sql.contains("DROP DEFAULT"), "default drop: {sql}");
}
#[test]
fn added_varchar_column_quotes_a_bare_default_mysql() {
// MySQL's information_schema returns a string default unquoted, so the
// generated DDL read `DEFAULT THE_VALUE` and the deploy failed.
let source =
vec![ColumnInfo { column_default: Some("THE_VALUE".into()), ..column("menu_type", "varchar(64)", None) }];
let target: Vec<ColumnInfo> = vec![];
let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5);
let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None);
assert!(sql.contains("DEFAULT 'THE_VALUE'"), "bare default must be quoted: {sql}");
}
#[test]
fn default_literal_only_quotes_bare_values_that_need_it() {
use DialectKind::Mysql;
// MySQL strips the quotes from a string default, which is the whole
// reason this function exists.
assert_eq!(default_literal("THE_VALUE", "varchar(64)", Mysql, None), "'THE_VALUE'");
assert_eq!(default_literal("it's", "text", Mysql, None), "'it''s'");
assert_eq!(default_literal("2024-01-01", "date", Mysql, None), "'2024-01-01'");
// `DEFAULT ''` previously emitted a bare `DEFAULT `.
assert_eq!(default_literal("", "varchar(20)", Mysql, None), "''");
// Untouched on MySQL: already quoted and numeric values.
assert_eq!(default_literal("'guest'", "varchar(50)", Mysql, None), "'guest'");
assert_eq!(default_literal("0", "bigint", Mysql, None), "0");
assert_eq!(default_literal("NULL", "varchar(10)", Mysql, None), "'NULL'");
assert_eq!(default_literal("null", "text", Mysql, None), "'null'");
assert_eq!(default_literal(" spaced ", "varchar(32)", Mysql, None), "' spaced '");
}
#[test]
fn default_literal_uses_mysql_extra_to_tell_an_expression_from_a_string() {
use DialectKind::Mysql;
// 8.0.13+ marks an expression default in EXTRA, and that marker decides
// it. Without the marker the value is a string, parentheses and all,
// so a column declared `DEFAULT 'a(b)'` stops emitting invalid
// `DEFAULT a(b)`.
assert_eq!(default_literal("a(b)", "varchar(32)", Mysql, None), "'a(b)'");
assert_eq!(default_literal("uuid()", "varchar(36)", Mysql, Some("DEFAULT_GENERATED")), "uuid()");
// MySQL wraps an expression default in parentheses and reports it that
// way, so the wrapping identifies it even when EXTRA is missing. That is
// a different question from whether the value contains a parenthesis,
// which is what `a(b)` above turns on.
assert_eq!(default_literal("(uuid())", "varchar(36)", Mysql, None), "(uuid())");
assert_eq!(default_literal("(now())", "datetime", Mysql, None), "(now())");
assert_eq!(
default_literal(
"CURRENT_TIMESTAMP",
"datetime",
Mysql,
Some("DEFAULT_GENERATED on update CURRENT_TIMESTAMP")
),
"CURRENT_TIMESTAMP"
);
// Before 8.0.13 there is no marker, and a temporal column was the only
// place an expression default could appear.
assert_eq!(default_literal("CURRENT_TIMESTAMP", "datetime", Mysql, None), "CURRENT_TIMESTAMP");
}
#[test]
fn default_literal_keeps_temporal_defaults_that_carry_a_precision() {
use DialectKind::Mysql;
// `TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6)` is valid MySQL. On a server
// older than 8.0.13 it arrives with no EXTRA marker, so the fallback has
// to accept the precision argument or the column is deployed with a
// quoted string where an expression belongs.
assert_eq!(default_literal("CURRENT_TIMESTAMP(6)", "timestamp(6)", Mysql, None), "CURRENT_TIMESTAMP(6)");
assert_eq!(default_literal("NOW()", "datetime", Mysql, None), "NOW()");
assert_eq!(default_literal("LOCALTIME(3)", "datetime(3)", Mysql, None), "LOCALTIME(3)");
assert_eq!(default_literal("LOCALTIMESTAMP(3)", "timestamp(3)", Mysql, None), "LOCALTIMESTAMP(3)");
assert_eq!(default_literal("current_timestamp(6)", "timestamp(6)", Mysql, None), "current_timestamp(6)");
// The precision form must not become a general "contains a parenthesis"
// rule again: a string default keeps its quotes.
assert_eq!(default_literal("a(b)", "varchar(32)", Mysql, None), "'a(b)'");
assert_eq!(default_literal("CURRENT_TIMESTAMPX(6)", "varchar(64)", Mysql, None), "'CURRENT_TIMESTAMPX(6)'");
}
#[test]
fn default_literal_leaves_non_mysql_expressions_alone() {
use DialectKind::{Oracle, Postgres, SqlServer};
// These dialects return a string default already quoted, so a bare token
// is an expression. Quoting it would silently turn a per-row value into
// a fixed string.
assert_eq!(default_literal("CURRENT_USER", "text", Postgres, None), "CURRENT_USER");
assert_eq!(default_literal("USER", "varchar2(30)", Oracle, None), "USER");
assert_eq!(default_literal("'new'::text", "text", Postgres, None), "'new'::text");
assert_eq!(default_literal("nextval('s'::regclass)", "integer", Postgres, None), "nextval('s'::regclass)");
assert_eq!(default_literal("N'guest'", "nvarchar(50)", SqlServer, None), "N'guest'");
assert_eq!(default_literal("('x')", "varchar(10)", SqlServer, None), "('x')");
assert_eq!(default_literal("NULL", "text", Postgres, None), "NULL");
// The same bare token on MySQL is a string, which is why the rule has to
// follow the source dialect rather than the value.
assert_eq!(default_literal("CURRENT_USER", "text", DialectKind::Mysql, None), "'CURRENT_USER'");
}
#[test]
fn default_literal_handles_set_and_binary_boundaries() {
use DialectKind::Mysql;
// A SET default is a bare comma-separated string.
assert_eq!(default_literal("a,b", "set('a','b')", Mysql, None), "'a,b'");
assert_eq!(default_literal("", "set('a','b')", Mysql, None), "''");
// Binary defaults arrive as a hex literal, which is already valid
// unquoted; a bare string on the same column still needs quoting.
assert_eq!(default_literal("0x61", "varbinary(16)", Mysql, None), "0x61");
assert_eq!(default_literal("abc", "binary(3)", Mysql, None), "'abc'");
assert_eq!(default_literal("x'1f'", "blob", Mysql, None), "x'1f'");
// Not hex, so not a hex literal.
assert_eq!(default_literal("0xzz", "varbinary(8)", Mysql, None), "'0xzz'");
}
// -- 35. Column order changes --
#[test]
fn column_order_change_only_no_type_change() {