fix(mysql): carry datetime precision into current_timestamp clauses

Closes #8402
This commit is contained in:
t8y2 committed 2026-09-08 01:53:50 +08:00
1 parent eb87c9e837
commit d2de504248
4 files changed
+192 -4

No files matched your search

@@ -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<String> {
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 {
@@ -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() {
@@ -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::<String>::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::<String>::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::<String>::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::<String>::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::<String>::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::<String>::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");
@@ -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") {