Fix:修复 SQL Server datetime 查询和导出精度

This commit is contained in:
zipg 2026-06-29 11:28:33 +08:00 committed by GitHub
parent 134116bb23
commit 3c7c1924ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 306 additions and 3 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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)) = <chrono::NaiveDateTime as FromSql>::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)) = <chrono::NaiveDate as FromSql>::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 =

View File

@ -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;

View File

@ -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<String> {
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<SqlServerTemporalKind> {
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<u8> {
let start = column_type.find('(')?;
let end = column_type[start + 1..].find(')')? + start + 1;
let scale = column_type[start + 1..end].trim().parse::<u8>().ok()?;
Some(scale.min(7))
}
struct SqlServerTemporalParts {
date: String,
time: String,
offset: Option<String>,
}
fn parse_sqlserver_temporal(value: &str) -> Option<SqlServerTemporalParts> {
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<String>)> {
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::<String>();
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())
);
}
}

View File

@ -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(