From 9290391985088644a1854c7676679ff88ca99f5e Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Thu, 27 Aug 2026 22:24:40 +0800 Subject: [PATCH] fix(structure): emit NULL keyword for nullable MySQL timestamp columns --- .../src/table_structure_sql/column_format.rs | 21 +++ .../src/table_structure_sql/create_table.rs | 8 +- .../dbx-core/src/table_structure_sql/tests.rs | 121 +++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/crates/dbx-core/src/table_structure_sql/column_format.rs b/crates/dbx-core/src/table_structure_sql/column_format.rs index 3214829d02..643a619581 100644 --- a/crates/dbx-core/src/table_structure_sql/column_format.rs +++ b/crates/dbx-core/src/table_structure_sql/column_format.rs @@ -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::>().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") +} diff --git a/crates/dbx-core/src/table_structure_sql/create_table.rs b/crates/dbx-core/src/table_structure_sql/create_table.rs index bbab8745ed..cdbba70262 100644 --- a/crates/dbx-core/src/table_structure_sql/create_table.rs +++ b/crates/dbx-core/src/table_structure_sql/create_table.rs @@ -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); diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 919f1f2da6..e5d3f75e8e 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -1746,7 +1746,7 @@ fn mysql_add_timestamp_column_drops_invalid_precision() { assert_eq!(result.warnings, Vec::::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::::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::::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::::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::::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::::new()); + assert_eq!(result.statements, vec!["ALTER TABLE `u7_game_order_step` ADD COLUMN `updated_at` timestamp NULL;"]); +}