From d2de504248f818230a2878c23f91d59f57a4652e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 8 Sep 2026 00:54:14 +0800 Subject: [PATCH] fix(mysql): carry datetime precision into current_timestamp clauses Closes #8402 --- .../src/table_structure_sql/column_format.rs | 35 ++++- .../src/table_structure_sql/create_table.rs | 5 +- .../dbx-core/src/table_structure_sql/tests.rs | 132 ++++++++++++++++++ .../dbx-core/src/table_structure_sql/util.rs | 24 +++- 4 files changed, 192 insertions(+), 4 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 ffa7b0b4a9..83dcf0ad71 100644 --- a/crates/dbx-core/src/table_structure_sql/column_format.rs +++ b/crates/dbx-core/src/table_structure_sql/column_format.rs @@ -79,7 +79,7 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru if mysql_generated_clause.is_none() { if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) { if on_update && dialect == StructureDialect::Mysql { - parts.push("ON UPDATE CURRENT_TIMESTAMP".to_string()); + parts.push(mysql_on_update_current_timestamp_clause(&column.data_type)); } } } @@ -420,6 +420,39 @@ pub(super) fn is_valid_temporal_precision(params: &str, dialect: StructureDialec value <= max && params == value.to_string() } +/// Fractional-seconds precision of a MySQL temporal column type, e.g. `3` for +/// `datetime(3)`. MySQL requires every `CURRENT_TIMESTAMP` in a column +/// definition to carry the same precision the column type declares: a +/// `datetime(3) ... DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` +/// definition is rejected with `ERROR 1067 (42000): Invalid default value` +/// while the `(3)`-suffixed clauses succeed. The editor model keeps only an +/// on-update flag, so the precision has to be recovered from the data type. +/// Non-temporal types and invalid precisions fall back to `None` (no suffix). +pub(super) fn mysql_temporal_precision(data_type: &str) -> Option { + let trimmed = data_type.trim(); + let open_index = trimmed.find('(')?; + if !trimmed.ends_with(')') { + return None; + } + let base_type = trimmed[..open_index].trim(); + let params = trimmed[open_index + 1..trimmed.len() - 1].trim(); + if !is_temporal_precision_type(StructureDialect::Mysql, base_type) + || !is_valid_temporal_precision(params, StructureDialect::Mysql) + { + return None; + } + Some(params.to_string()) +} + +/// `ON UPDATE CURRENT_TIMESTAMP` clause for a MySQL column, carrying the +/// column's temporal precision (if any) so the clause matches the type. +pub(super) fn mysql_on_update_current_timestamp_clause(data_type: &str) -> String { + match mysql_temporal_precision(data_type) { + Some(fsp) => format!("ON UPDATE CURRENT_TIMESTAMP({fsp})"), + None => "ON UPDATE CURRENT_TIMESTAMP".to_string(), + } +} + pub(super) fn clickhouse_column_type(column: &EditableStructureColumn) -> String { let data_type = column.data_type.trim(); if column.is_nullable { 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 7e08d12687..09327fdca0 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,7 @@ 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_timestamp_type, strip_inherited_mysql_column_charsets, + is_mysql_character_data_type, is_mysql_timestamp_type, mysql_on_update_current_timestamp_clause, + strip_inherited_mysql_column_charsets, }; use super::comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql}; use super::dialect::{capabilities_for, database_label, StructureDialect}; @@ -94,7 +95,7 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr } if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) { if on_update && dialect == StructureDialect::Mysql { - parts.push("ON UPDATE CURRENT_TIMESTAMP".to_string()); + parts.push(mysql_on_update_current_timestamp_clause(&column.data_type)); } } if dialect == StructureDialect::Mysql && capabilities.comment && !clean(&column.comment).is_empty() { diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 61bc5b440a..d290bb1cb9 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -5060,6 +5060,138 @@ fn mysql_create_table_with_on_update_current_timestamp() { assert!(result.statements[0].contains("ON UPDATE CURRENT_TIMESTAMP")); } +#[test] +fn mysql_create_table_carries_temporal_precision_into_current_timestamp_clauses() { + let mut col = column("updated_at"); + col.data_type = "datetime(3)".to_string(); + col.is_nullable = false; + col.default_value = "CURRENT_TIMESTAMP".to_string(); + col.extra = Some(ColumnExtra { on_update_current_timestamp: Some(true), ..Default::default() }); + + let result = build_create_table_sql(structure_change_options(DatabaseType::Mysql, None, "users", vec![col])); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["CREATE TABLE `users` (\n `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)\n);"] + ); +} + +#[test] +fn mysql_create_table_keeps_explicit_zero_temporal_precision_in_current_timestamp_clauses() { + let mut col = column("updated_at"); + col.data_type = "datetime(0)".to_string(); + col.is_nullable = false; + col.default_value = "CURRENT_TIMESTAMP".to_string(); + col.extra = Some(ColumnExtra { on_update_current_timestamp: Some(true), ..Default::default() }); + + let result = build_create_table_sql(structure_change_options(DatabaseType::Mysql, None, "users", vec![col])); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["CREATE TABLE `users` (\n `updated_at` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0)\n);"] + ); +} + +#[test] +fn mysql_single_column_alter_carries_temporal_precision_into_current_timestamp_clauses() { + let mut col = column("updated_at"); + col.data_type = "datetime(3)".to_string(); + col.is_nullable = false; + col.default_value = "CURRENT_TIMESTAMP".to_string(); + col.extra = Some(ColumnExtra { on_update_current_timestamp: Some(true), ..Default::default() }); + col.original = Some(ColumnInfo { + name: "updated_at".to_string(), + data_type: "datetime".to_string(), + is_nullable: false, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: None, + schema: None, + table_name: "users".to_string(), + column: col, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["ALTER TABLE `users` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3);"] + ); +} + +#[test] +fn mysql_single_column_alter_resaving_precision_column_keeps_temporal_precision() { + let mut col = column("updated_at"); + col.data_type = "datetime(3)".to_string(); + col.is_nullable = false; + col.default_value = "CURRENT_TIMESTAMP(3)".to_string(); + col.comment = "updated".to_string(); + col.extra = Some(ColumnExtra { on_update_current_timestamp: Some(true), ..Default::default() }); + col.original = Some(ColumnInfo { + name: "updated_at".to_string(), + data_type: "datetime(3)".to_string(), + is_nullable: false, + column_default: Some("CURRENT_TIMESTAMP(3)".to_string()), + is_primary_key: false, + extra: Some("on update CURRENT_TIMESTAMP(3)".to_string()), + comment: None, + ..Default::default() + }); + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: None, + schema: None, + table_name: "users".to_string(), + column: col, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["ALTER TABLE `users` MODIFY COLUMN `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'updated';"] + ); +} + +#[test] +fn mysql_current_timestamp_clauses_stay_bare_without_column_precision() { + let mut col = column("updated_at"); + col.data_type = "timestamp".to_string(); + col.is_nullable = false; + col.default_value = "CURRENT_TIMESTAMP".to_string(); + col.extra = Some(ColumnExtra { on_update_current_timestamp: Some(true), ..Default::default() }); + + let result = build_create_table_sql(structure_change_options(DatabaseType::Mysql, None, "users", vec![col])); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["CREATE TABLE `users` (\n `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n);"] + ); + + let mut col = column("updated_at"); + col.data_type = "datetime(7)".to_string(); + col.is_nullable = false; + col.default_value = "CURRENT_TIMESTAMP".to_string(); + col.extra = Some(ColumnExtra { on_update_current_timestamp: Some(true), ..Default::default() }); + + let result = build_create_table_sql(structure_change_options(DatabaseType::Mysql, None, "users", vec![col])); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["CREATE TABLE `users` (\n `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n);"] + ); +} + #[test] fn postgres_create_table_with_identity() { let mut col = column("id"); diff --git a/crates/dbx-core/src/table_structure_sql/util.rs b/crates/dbx-core/src/table_structure_sql/util.rs index 55931962c2..ed038e3779 100644 --- a/crates/dbx-core/src/table_structure_sql/util.rs +++ b/crates/dbx-core/src/table_structure_sql/util.rs @@ -1,3 +1,4 @@ +use super::column_format::mysql_temporal_precision; use super::dialect::StructureDialect; use super::types::EditableStructureColumn; use crate::models::connection::DatabaseType; @@ -372,7 +373,7 @@ pub(super) fn format_default_for_sql(dialect: StructureDialect, data_type: &str, let base_type = data_type.split('(').next().unwrap_or(data_type).trim(); if is_temporal_type_for_default(dialect, base_type) { if is_temporal_expression(default_value) { - return default_value.to_string(); + return mysql_temporal_default_with_column_precision(dialect, data_type, default_value); } return quote_string(default_value); } @@ -402,6 +403,27 @@ pub(super) fn format_default_for_sql(dialect: StructureDialect, data_type: &str, default_value.to_string() } +/// MySQL requires a `CURRENT_TIMESTAMP` default on a fractional-precision +/// temporal column to spell out the column's precision (`datetime(3)` needs +/// `DEFAULT CURRENT_TIMESTAMP(3)`), otherwise the statement fails with +/// `ERROR 1067 (42000): Invalid default value`. Complete a bare +/// `CURRENT_TIMESTAMP` with the precision parsed from the data type; a value +/// that already carries parentheses is passed through unchanged. +fn mysql_temporal_default_with_column_precision( + dialect: StructureDialect, + data_type: &str, + default_value: &str, +) -> String { + let trimmed = default_value.trim(); + if dialect != StructureDialect::Mysql || !trimmed.eq_ignore_ascii_case("current_timestamp") { + return default_value.to_string(); + } + match mysql_temporal_precision(data_type) { + Some(fsp) => format!("{trimmed}({fsp})"), + None => default_value.to_string(), + } +} + pub(super) fn normalize_default(value: Option<&String>) -> String { let trimmed = value.map(|value| value.trim()).unwrap_or(""); if trimmed.eq_ignore_ascii_case("null") {