fix(structure): emit NULL keyword for nullable MySQL timestamp columns

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-27 22:24:40 +08:00
1 parent 1016ccc352
commit 9290391985
3 files changed
+147 -3

No files matched your search

@@ -26,6 +26,12 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru
}
if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse {
parts.push("NOT NULL".to_string());
} else if column.is_nullable
&& dialect == StructureDialect::Mysql
&& mysql_generated_clause.is_none()
&& is_mysql_timestamp_type(&column.data_type)
{
parts.push("NULL".to_string());
}
if mysql_generated_clause.is_none() {
if let Some(extra_clause) = column_extra_clause(dialect, column) {
@@ -438,3 +444,18 @@ pub(super) fn is_mysql_character_data_type(data_type: &str) -> bool {
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
matches!(normalized.as_str(), "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext" | "enum" | "set")
}
/// MySQL silently rewrites a nullable `TIMESTAMP` column to `NOT NULL` when the
/// generated DDL omits an explicit `NULL` keyword — regardless of whether a
/// `DEFAULT` is present. With the (still common) `explicit_defaults_for_timestamp`
/// server default of `OFF`, this can outright fail with `ERROR 1067 (42000):
/// Invalid default value` for any non-first TIMESTAMP column. `DATETIME` is not
/// affected and must not be touched.
pub(super) fn is_mysql_timestamp_type(data_type: &str) -> bool {
let trimmed = data_type.trim();
let base_type = match trimmed.find('(') {
Some(open_index) => trimmed[..open_index].trim(),
None => trimmed,
};
base_type.eq_ignore_ascii_case("timestamp")
}
@@ -1,6 +1,6 @@
use super::column_format::{
column_data_type, column_extra_clause, has_dameng_identity, is_dameng_identity_compatible_type,
is_mysql_character_data_type,
is_mysql_character_data_type, is_mysql_timestamp_type,
};
use super::comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
use super::dialect::{capabilities_for, database_label, StructureDialect};
@@ -75,6 +75,12 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
&& !matches!(dialect, StructureDialect::ClickHouse | StructureDialect::ManticoreSearch)
{
parts.push("NOT NULL".to_string());
} else if column.is_nullable
&& !column.is_primary_key
&& dialect == StructureDialect::Mysql
&& is_mysql_timestamp_type(&column.data_type)
{
parts.push("NULL".to_string());
}
if let Some(extra_clause) = column_extra_clause(dialect, column) {
parts.push(extra_clause);
@@ -1746,7 +1746,7 @@ fn mysql_add_timestamp_column_drops_invalid_precision() {
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE `users` ADD COLUMN `created_at` timestamp DEFAULT CURRENT_TIMESTAMP;"]
vec!["ALTER TABLE `users` ADD COLUMN `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP;"]
);
}
@@ -1774,7 +1774,7 @@ fn mysql_add_timestamp_column_preserves_valid_precision() {
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE `users` ADD COLUMN `created_at` timestamp(3) DEFAULT CURRENT_TIMESTAMP(3);"]
vec!["ALTER TABLE `users` ADD COLUMN `created_at` timestamp(3) NULL DEFAULT CURRENT_TIMESTAMP(3);"]
);
}
@@ -6551,3 +6551,120 @@ fn gaussdb_m_create_table_with_primary_key() {
let sql = result.statements.join("\n");
assert!(sql.contains("PRIMARY KEY (`id`)"));
}
#[test]
fn mysql_create_table_nullable_timestamp_without_default_gets_explicit_null() {
// A second (or later) MySQL TIMESTAMP column that is nullable but has no
// DEFAULT must carry an explicit NULL keyword — otherwise MySQL either
// silently rewrites it to NOT NULL or, with the still-common
// explicit_defaults_for_timestamp=OFF server default, rejects it outright
// with ERROR 1067 (42000): Invalid default value. See issue #7416.
let mut created_at = column("created_at");
created_at.data_type = "timestamp".to_string();
created_at.is_nullable = false;
let mut updated_at = column("updated_at");
updated_at.data_type = "timestamp".to_string();
updated_at.is_nullable = true;
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "u7_game_order_step".to_string(),
columns: vec![created_at, updated_at],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
mysql_engine: None,
partitioned: false,
is_gaussdb_m_mode: false,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert!(result.statements[0].contains("`updated_at` timestamp NULL"));
assert!(!result.statements[0].contains("`updated_at` timestamp NULL DEFAULT"));
}
#[test]
fn mysql_create_table_nullable_timestamp_with_default_still_gets_explicit_null() {
// Even with an explicit DEFAULT, MySQL still needs the NULL keyword to
// keep the column nullable — omitting it silently produces NOT NULL.
let mut updated_at = column("updated_at");
updated_at.data_type = "timestamp".to_string();
updated_at.is_nullable = true;
updated_at.default_value = "CURRENT_TIMESTAMP".to_string();
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "events".to_string(),
columns: vec![updated_at],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
mysql_engine: None,
partitioned: false,
is_gaussdb_m_mode: false,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert!(result.statements[0].contains("`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP"));
}
#[test]
fn mysql_create_table_nullable_datetime_does_not_gain_null_keyword() {
// DATETIME is not subject to MySQL's TIMESTAMP-specific implicit-default
// quirk; the fix must not touch it.
let mut updated_at = column("updated_at");
updated_at.data_type = "datetime".to_string();
updated_at.is_nullable = true;
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "events".to_string(),
columns: vec![updated_at],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
mysql_engine: None,
partitioned: false,
is_gaussdb_m_mode: false,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert!(!result.statements[0].contains("NULL"));
}
#[test]
fn mysql_add_column_nullable_timestamp_without_default_gets_explicit_null() {
// The ADD COLUMN / MODIFY COLUMN / CHANGE COLUMN path shares
// column_definition() with CREATE TABLE and must carry the same fix.
let mut updated_at = column("updated_at");
updated_at.data_type = "timestamp".to_string();
updated_at.is_nullable = true;
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "u7_game_order_step".to_string(),
columns: vec![updated_at],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
mysql_engine: None,
partitioned: false,
is_gaussdb_m_mode: false,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(result.statements, vec!["ALTER TABLE `u7_game_order_step` ADD COLUMN `updated_at` timestamp NULL;"]);
}