From d725face37ff87ee4d5d52b5b11f425c38cdf6d0 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 9 Jul 2026 20:34:45 +0800 Subject: [PATCH] fix(transfer): map ClickHouse timestamps to DateTime64 --- crates/dbx-core/src/transfer.rs | 65 +++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 065a93d5df..1c6c6f5ac0 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -394,6 +394,9 @@ fn postgres_column_type_sql( source_db: &DatabaseType, target_db: &DatabaseType, ) -> String { + if let Some(mapped_type) = clickhouse_temporal_column_type(column, source_db, target_db) { + return mapped_type; + } if is_postgres_compat_transfer(source_db, target_db) { let trimmed = column.data_type.trim(); if !trimmed.is_empty() { @@ -403,6 +406,43 @@ fn postgres_column_type_sql( map_column_type(&column.data_type, source_db, target_db) } +fn clickhouse_temporal_column_type( + column: &db::ColumnInfo, + source_db: &DatabaseType, + target_db: &DatabaseType, +) -> Option { + if !matches!(target_db, DatabaseType::ClickHouse) || source_db == target_db { + return None; + } + + let source_type = column.data_type.trim(); + let lower = source_type.to_ascii_lowercase(); + let base = lower.split(['(', ' ', '\t', '\n']).next().unwrap_or("").trim(); + if !matches!(base, "datetime" | "timestamp" | "timestamptz") { + return None; + } + + let scale = clickhouse_datetime64_scale(column); + // ClickHouse DateTime stores whole seconds, and older versions reject + // fractional timestamp strings such as Dameng's TIMESTAMP(6) output. + Some(format!("DateTime64({scale})")) +} + +fn clickhouse_datetime64_scale(column: &db::ColumnInfo) -> u8 { + let scale = parse_temporal_type_scale(&column.data_type).or(column.numeric_scale).unwrap_or(6); + scale.clamp(0, 9) as u8 +} + +fn parse_temporal_type_scale(source_type: &str) -> Option { + let start = source_type.find('(')? + 1; + let rest = &source_type[start..]; + let digits = rest.bytes().take_while(|byte| byte.is_ascii_digit()).collect::>(); + if digits.is_empty() { + return None; + } + std::str::from_utf8(&digits).ok()?.parse::().ok() +} + fn postgres_default_clause( column: &db::ColumnInfo, source_schema: &str, @@ -1506,11 +1546,13 @@ pub fn map_column_type(source_type: &str, _source_db: &DatabaseType, target_db: "time" => "TIME".into(), "datetime" => match target_db { DatabaseType::Postgres => "TIMESTAMP".into(), + DatabaseType::ClickHouse => "DateTime64(6)".into(), _ => "DATETIME".into(), }, "timestamp" | "timestamptz" | "timestamp with time zone" | "timestamp without time zone" => match target_db { DatabaseType::Mysql => "DATETIME".into(), DatabaseType::SqlServer => "DATETIME2".into(), + DatabaseType::ClickHouse => "DateTime64(6)".into(), _ => "TIMESTAMP".into(), }, "longblob" => match target_db { @@ -5096,6 +5138,29 @@ mod tests { assert!(!ddl.contains("PRIMARY KEY")); } + #[test] + fn clickhouse_transfer_maps_fractional_timestamp_to_datetime64() { + let cols = vec![db::ColumnInfo { numeric_scale: Some(6), ..test_column("created_at", "TIMESTAMP") }]; + + let ddl = generate_create_table_ddl( + &cols, + "events", + "SYSDBA", + "", + &DatabaseType::ClickHouse, + &DatabaseType::Dameng, + None, + ); + + assert!(ddl.contains("`created_at` DateTime64(6)"), "ddl: {ddl}"); + } + + #[test] + fn clickhouse_transfer_uses_datetime64_fallback_for_timestamp_types() { + assert_eq!(map_column_type("datetime", &DatabaseType::Dameng, &DatabaseType::ClickHouse), "DateTime64(6)"); + assert_eq!(map_column_type("timestamp", &DatabaseType::Dameng, &DatabaseType::ClickHouse), "DateTime64(6)"); + } + #[test] fn transfer_reuses_source_table_ddl_only_when_target_shape_matches() { assert!(!can_reuse_source_table_ddl(&DatabaseType::ClickHouse, &DatabaseType::ClickHouse, true));