diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 5d5af30ef..7dae193da 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1902,7 +1902,7 @@ const visibleColumnTypes = computed(() => visibleColumnIndexes.value.map((index) => { const resultColumn = props.result.columns[index]?.toLocaleLowerCase(); const sourceColumn = props.sourceColumns?.[index]?.toLocaleLowerCase(); - return (sourceColumn ? tableColumnTypesByName.value.get(sourceColumn) : undefined) || (resultColumn ? tableColumnTypesByName.value.get(resultColumn) : undefined); + return (sourceColumn ? tableColumnTypesByName.value.get(sourceColumn) : undefined) || (resultColumn ? tableColumnTypesByName.value.get(resultColumn) : undefined) || props.result.column_types?.[index]; }), ); const visibleColumnCount = computed(() => visibleColumnIndexes.value.length); diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 42d9cc41f..86b8eb26e 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -1053,6 +1053,12 @@ pub fn format_grid_sql_literal( } let literal_text = if database_type == Some(DatabaseType::Tdengine) { format_tdengine_timestamp_literal_text(&text) + } else if database_type == Some(DatabaseType::SqlServer) { + crate::sqlserver_temporal::normalize_sqlserver_temporal_literal( + &text, + column_info.map(|column| column.data_type.as_str()), + ) + .unwrap_or(text) } else if is_mysql_datetime_literal_database(database_type) && column_info.map(|column| is_temporal_column_type(&column.data_type)).unwrap_or(true) { @@ -2156,6 +2162,38 @@ mod tests { ); } + #[test] + fn formats_sqlserver_datetime_copy_literals_with_supported_precision() { + let datetime = column("date1", "datetime", true, None); + let datetime2 = column("date2", "datetime2(7)", true, None); + let raw_text = column("note", "nvarchar(64)", true, None); + + assert_eq!( + format_grid_sql_literal( + &json!("2026-06-29 10:11:12.896666666"), + Some(DatabaseType::SqlServer), + Some(&datetime) + ), + "N'2026-06-29 10:11:12.897'" + ); + assert_eq!( + format_grid_sql_literal( + &json!("2026-06-29 10:11:12.8966666"), + Some(DatabaseType::SqlServer), + Some(&datetime2) + ), + "N'2026-06-29 10:11:12.8966666'" + ); + assert_eq!( + format_grid_sql_literal( + &json!("2026-06-29 10:11:12.896666666"), + Some(DatabaseType::SqlServer), + Some(&raw_text) + ), + "N'2026-06-29 10:11:12.896666666'" + ); + } + #[test] fn formats_oracle_temporal_literals_without_nls_parsing() { let timestamp = column("created_at", "TIMESTAMP(6)", true, None); diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index c5d1e603e..262728d82 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -598,7 +598,11 @@ fn sqlserver_cell_to_json(cell: &ColumnData<'static>) -> serde_json::Value { return serde_json::Value::String(v.to_string()); } if let Ok(Some(v)) = ::from_sql(cell) { - return serde_json::Value::String(v.to_string()); + let value = match cell { + ColumnData::DateTime(_) => crate::sqlserver_temporal::format_sqlserver_datetime_display(&v), + _ => v.to_string(), + }; + return serde_json::Value::String(value); } if let Ok(Some(v)) = ::from_sql(cell) { return serde_json::Value::String(v.to_string()); @@ -2125,6 +2129,13 @@ mod tests { assert_eq!(sqlserver_cell_to_json(&cell), serde_json::json!("2026-05-13 09:08:07.123")); } + #[test] + fn sqlserver_datetime_cells_display_millisecond_precision() { + let cell = ColumnData::DateTime(Some(tiberius::time::DateTime::new(46_200, 11_001_869))); + + assert_eq!(sqlserver_cell_to_json(&cell), serde_json::json!("2026-06-29 10:11:12.897")); + } + #[test] fn sqlserver_binary_cells_are_json_hex_strings() { let cell = diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index c74c4801a..225e0708b 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -49,6 +49,7 @@ pub mod sql_editability; pub mod sql_file_import; pub mod sql_risk; pub mod sqlite_backup; +pub(crate) mod sqlserver_temporal; pub mod storage; pub mod table_export; pub mod table_import; diff --git a/crates/dbx-core/src/sqlserver_temporal.rs b/crates/dbx-core/src/sqlserver_temporal.rs new file mode 100644 index 000000000..a562c5677 --- /dev/null +++ b/crates/dbx-core/src/sqlserver_temporal.rs @@ -0,0 +1,223 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SqlServerTemporalKind { + Date, + Time { scale: u8 }, + SmallDateTime, + DateTime, + DateTime2 { scale: u8 }, + DateTimeOffset { scale: u8 }, +} + +pub fn format_sqlserver_datetime_display(value: &chrono::NaiveDateTime) -> String { + let rounded = value.checked_add_signed(chrono::Duration::microseconds(500)).unwrap_or(*value); + let base = rounded.format("%Y-%m-%d %H:%M:%S").to_string(); + let millis = rounded.and_utc().timestamp_subsec_millis(); + if millis == 0 { + base + } else { + format!("{base}.{millis:03}") + } +} + +pub fn normalize_sqlserver_temporal_literal(value: &str, column_type: Option<&str>) -> Option { + let kind = sqlserver_temporal_kind(column_type?)?; + let parts = parse_sqlserver_temporal(value)?; + match kind { + SqlServerTemporalKind::Date => Some(parts.date), + SqlServerTemporalKind::Time { scale } => Some(format_sqlserver_time_text(&parts.time, scale)), + SqlServerTemporalKind::SmallDateTime => { + Some(format!("{} {}", parts.date, format_sqlserver_time_text(&parts.time, 0))) + } + SqlServerTemporalKind::DateTime => Some(format_sqlserver_datetime_parts(&parts.date, &parts.time)), + SqlServerTemporalKind::DateTime2 { scale } => { + Some(format!("{} {}", parts.date, format_sqlserver_time_text(&parts.time, scale))) + } + SqlServerTemporalKind::DateTimeOffset { scale } => { + let offset = parts.offset?; + Some(format!("{} {}{}", parts.date, format_sqlserver_time_text(&parts.time, scale), offset)) + } + } +} + +fn sqlserver_temporal_kind(column_type: &str) -> Option { + let lower = column_type.trim().to_ascii_lowercase(); + let base = lower.split(['(', ' ', '\t', '\n']).next().unwrap_or(""); + match base { + "date" | "daten" => Some(SqlServerTemporalKind::Date), + "smalldatetime" => Some(SqlServerTemporalKind::SmallDateTime), + "datetime" | "datetimen" => Some(SqlServerTemporalKind::DateTime), + "datetime4" => Some(SqlServerTemporalKind::SmallDateTime), + "datetime2" => Some(SqlServerTemporalKind::DateTime2 { scale: temporal_scale(&lower).unwrap_or(7) }), + "time" | "timen" => Some(SqlServerTemporalKind::Time { scale: temporal_scale(&lower).unwrap_or(7) }), + "datetimeoffset" | "datetimeoffsetn" => { + Some(SqlServerTemporalKind::DateTimeOffset { scale: temporal_scale(&lower).unwrap_or(7) }) + } + _ => None, + } +} + +fn temporal_scale(column_type: &str) -> Option { + let start = column_type.find('(')?; + let end = column_type[start + 1..].find(')')? + start + 1; + let scale = column_type[start + 1..end].trim().parse::().ok()?; + Some(scale.min(7)) +} + +struct SqlServerTemporalParts { + date: String, + time: String, + offset: Option, +} + +fn parse_sqlserver_temporal(value: &str) -> Option { + let trimmed = value.trim(); + let bytes = trimmed.as_bytes(); + if bytes.len() >= 10 && is_date_prefix(bytes) { + let date = trimmed[..10].to_string(); + let mut rest = trimmed[10..].trim_start(); + if rest.starts_with('T') || rest.starts_with('t') { + rest = rest[1..].trim_start(); + } + if rest.is_empty() { + return Some(SqlServerTemporalParts { date, time: "00:00:00".to_string(), offset: None }); + } + let (time, offset) = split_time_and_offset(rest)?; + return Some(SqlServerTemporalParts { date, time, offset }); + } + + let (time, offset) = split_time_and_offset(trimmed)?; + Some(SqlServerTemporalParts { date: "1900-01-01".to_string(), time, offset }) +} + +fn is_date_prefix(bytes: &[u8]) -> bool { + bytes.len() >= 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[8..10].iter().all(u8::is_ascii_digit) +} + +fn split_time_and_offset(value: &str) -> Option<(String, Option)> { + if value.len() < 8 { + return None; + } + let bytes = value.as_bytes(); + if bytes.get(2) != Some(&b':') || bytes.get(5) != Some(&b':') { + return None; + } + if !bytes[0..2].iter().all(u8::is_ascii_digit) + || !bytes[3..5].iter().all(u8::is_ascii_digit) + || !bytes[6..8].iter().all(u8::is_ascii_digit) + { + return None; + } + + let mut end = 8; + if bytes.get(end) == Some(&b'.') { + end += 1; + let fraction_start = end; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + } + if end == fraction_start { + return None; + } + } + + let time = value[..end].to_string(); + let suffix = value[end..].trim(); + if suffix.is_empty() { + return Some((time, None)); + } + if suffix.eq_ignore_ascii_case("z") { + return Some((time, Some("+00:00".to_string()))); + } + if is_timezone_offset(suffix) { + return Some((time, Some(suffix.to_string()))); + } + None +} + +fn is_timezone_offset(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 6 + && matches!(bytes[0], b'+' | b'-') + && bytes[3] == b':' + && bytes[1].is_ascii_digit() + && bytes[2].is_ascii_digit() + && bytes[4].is_ascii_digit() + && bytes[5].is_ascii_digit() +} + +fn format_sqlserver_time_text(value: &str, scale: u8) -> String { + let (base, fraction) = value.split_once('.').unwrap_or((value, "")); + if scale == 0 { + return base.to_string(); + } + let mut digits = fraction.chars().take_while(|ch| ch.is_ascii_digit()).take(scale as usize).collect::(); + while digits.len() < scale as usize { + digits.push('0'); + } + if digits.chars().all(|ch| ch == '0') { + base.to_string() + } else { + format!("{base}.{digits}") + } +} + +fn format_sqlserver_datetime_parts(date: &str, time: &str) -> String { + let value = format!("{date} {time}"); + chrono::NaiveDateTime::parse_from_str(&value, "%Y-%m-%d %H:%M:%S%.f") + .map(|value| format_sqlserver_datetime_display(&value)) + .unwrap_or_else(|_| format!("{date} {}", format_sqlserver_time_text(time, 3))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_sqlserver_datetime_display_with_millisecond_precision() { + let value = + chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap().and_hms_nano_opt(10, 11, 12, 896_666_666).unwrap(); + + assert_eq!(format_sqlserver_datetime_display(&value), "2026-06-29 10:11:12.897"); + + let boundary = + chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap().and_hms_nano_opt(23, 59, 59, 999_600_000).unwrap(); + assert_eq!(format_sqlserver_datetime_display(&boundary), "2026-06-30 00:00:00"); + } + + #[test] + fn normalizes_sqlserver_datetime_literals_by_column_type() { + assert_eq!( + normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.896666666", Some("datetime")), + Some("2026-06-29 10:11:12.897".to_string()) + ); + assert_eq!( + normalize_sqlserver_temporal_literal("2026-06-29T10:11:12.8966666Z", Some("datetime2(7)")), + Some("2026-06-29 10:11:12.8966666".to_string()) + ); + assert_eq!( + normalize_sqlserver_temporal_literal("2026-06-29T10:11:12.8966666Z", Some("datetime2(3)")), + Some("2026-06-29 10:11:12.896".to_string()) + ); + assert_eq!( + normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.8966666+08:00", Some("datetimeoffset(4)")), + Some("2026-06-29 10:11:12.8966+08:00".to_string()) + ); + assert_eq!( + normalize_sqlserver_temporal_literal("10:11:12.8966666", Some("time(2)")), + Some("10:11:12.89".to_string()) + ); + assert_eq!( + normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.8966666", Some("date")), + Some("2026-06-29".to_string()) + ); + assert_eq!( + normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.8966666", Some("smalldatetime")), + Some("2026-06-29 10:11:12".to_string()) + ); + } +} diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 1644a2c7c..ab543771f 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -926,7 +926,10 @@ fn format_ch_array_element(val: &serde_json::Value) -> String { } fn format_literal_string(value: &str, db_type: &DatabaseType, column_type: Option<&str>) -> String { - if is_mysql_datetime_literal_database(db_type) && column_type.map(is_temporal_column_type).unwrap_or(true) { + if *db_type == DatabaseType::SqlServer { + crate::sqlserver_temporal::normalize_sqlserver_temporal_literal(value, column_type) + .unwrap_or_else(|| value.to_string()) + } else if is_mysql_datetime_literal_database(db_type) && column_type.map(is_temporal_column_type).unwrap_or(true) { normalize_mysql_temporal_literal(value, column_type).unwrap_or_else(|| value.to_string()) } else { value.to_string() @@ -4768,6 +4771,33 @@ mod tests { assert_eq!(sql, "INSERT INTO [dbo].[customers] ([name], [note]) VALUES\n(N'Tiếng Việt', N'O''Brien')"); } + #[test] + fn sqlserver_insert_formats_datetime_literals_with_supported_precision() { + let sql = generate_insert_typed( + &[String::from("id"), String::from("date1"), String::from("date2"), String::from("note")], + &[ + Some(String::from("int")), + Some(String::from("datetime")), + Some(String::from("datetime2(7)")), + Some(String::from("nvarchar(100)")), + ], + &[vec![ + json!(1), + json!("2026-06-29 10:11:12.896666666"), + json!("2026-06-29T10:11:12.8966666Z"), + json!("2026-06-29 10:11:12.896666666"), + ]], + "test", + "dbo", + &DatabaseType::SqlServer, + ); + + assert_eq!( + sql, + "INSERT INTO [dbo].[test] ([id], [date1], [date2], [note]) VALUES\n(1, N'2026-06-29 10:11:12.897', N'2026-06-29 10:11:12.8966666', N'2026-06-29 10:11:12.896666666')" + ); + } + #[test] fn sqlserver_insert_formats_bit_booleans_as_numeric_literals() { let sql = generate_insert_typed(