fix(sqlserver): handle high-precision numeric values

This commit is contained in:
vrustx 2026-07-16 17:44:01 +08:00 committed by GitHub
parent ef76b137bb
commit 5d2a363786
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 68 additions and 10 deletions

View File

@ -5,7 +5,6 @@ use crate::types::{
TriggerInfo,
};
use futures::{FutureExt, TryStreamExt};
use rust_decimal::Decimal;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::time::{Duration, Instant};
@ -696,7 +695,32 @@ pub async fn stream_first_result_set(
Ok(SqlServerStreamExportSummary { columns, rows_exported })
}
// rust_decimal (behind tiberius's `Decimal: FromSql`) only supports scale <= 28 and a
// 96-bit mantissa, while SQL Server NUMERIC allows precision/scale up to 38; converting
// such values panics and aborts the app (issue #3648). Format the raw i128 value and
// scale manually instead.
fn format_sqlserver_numeric(value: i128, scale: u8) -> String {
if scale == 0 {
return value.to_string();
}
let digits = value.unsigned_abs().to_string();
let scale = scale as usize;
let sign = if value < 0 { "-" } else { "" };
if digits.len() > scale {
let (int_part, frac_part) = digits.split_at(digits.len() - scale);
format!("{}{}.{}", sign, int_part, frac_part)
} else {
format!("{}0.{:0>width$}", sign, digits, width = scale)
}
}
fn sqlserver_cell_to_json(cell: &ColumnData<'static>) -> serde_json::Value {
if let ColumnData::Numeric(numeric) = cell {
return match numeric {
Some(n) => serde_json::Value::String(format_sqlserver_numeric(n.value(), n.scale())),
None => serde_json::Value::Null,
};
}
if let Ok(Some(v)) = <&tiberius::xml::XmlData as FromSql>::from_sql(cell) {
return serde_json::Value::String(v.as_ref().to_string());
}
@ -719,9 +743,6 @@ fn sqlserver_cell_to_json(cell: &ColumnData<'static>) -> serde_json::Value {
if let Ok(Some(v)) = <chrono::DateTime<chrono::FixedOffset> as FromSql>::from_sql(cell) {
return serde_json::Value::String(v.to_rfc3339());
}
if let Ok(Some(v)) = <Decimal as FromSql>::from_sql(cell) {
return serde_json::Value::String(v.to_string());
}
if let Ok(Some(v)) = <u8 as FromSql>::from_sql(cell) {
return serde_json::Value::Number(v.into());
}
@ -1913,12 +1934,13 @@ fn first_sql_tokens(sql: &str, limit: usize) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
build_sqlserver_unsafe_type_query, is_sqlserver_spatial_column, is_sqlserver_variant_column,
requires_simple_query_batch, sqlserver_batch_can_use_execute, sqlserver_cell_to_json, sqlserver_columns_sql,
sqlserver_completion_assistant_sql, sqlserver_dml_output_returns_rows, sqlserver_hidden_schema_names,
sqlserver_indexes_sql, sqlserver_list_objects_sql, sqlserver_list_schemas_sql, sqlserver_list_tables_sql,
sqlserver_table_comment_sql, sqlserver_visible_object_predicate, strip_dbx_sqlserver_row_number_column,
SqlServerDescribedColumn, SqlServerResultSet,
build_sqlserver_unsafe_type_query, format_sqlserver_numeric, is_sqlserver_spatial_column,
is_sqlserver_variant_column, requires_simple_query_batch, sqlserver_batch_can_use_execute,
sqlserver_cell_to_json, sqlserver_columns_sql, sqlserver_completion_assistant_sql,
sqlserver_dml_output_returns_rows, sqlserver_hidden_schema_names, sqlserver_indexes_sql,
sqlserver_list_objects_sql, sqlserver_list_schemas_sql, sqlserver_list_tables_sql, sqlserver_table_comment_sql,
sqlserver_visible_object_predicate, strip_dbx_sqlserver_row_number_column, SqlServerDescribedColumn,
SqlServerResultSet,
};
use crate::types::{
CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, QueryResult,
@ -2378,6 +2400,42 @@ mod tests {
assert_eq!(sqlserver_cell_to_json(&ColumnData::U8(Some(7))), serde_json::json!(7));
}
#[test]
fn sqlserver_numeric_cells_with_scale_over_28_do_not_panic() {
// NUMERIC(38, 29) with data used to abort the app: rust_decimal caps scale at 28 (issue #3648).
let cell = ColumnData::Numeric(Some(tiberius::numeric::Numeric::new_with_scale(5, 29)));
assert_eq!(sqlserver_cell_to_json(&cell), serde_json::json!("0.00000000000000000000000000005"));
}
#[test]
fn sqlserver_numeric_cells_beyond_96_bit_mantissa_do_not_panic() {
// Precision-38 values overflow rust_decimal's 96-bit mantissa even at low scale.
let value: i128 = 12_345_678_901_234_567_890_123_456_789_012_345_678;
let cell = ColumnData::Numeric(Some(tiberius::numeric::Numeric::new_with_scale(value, 10)));
assert_eq!(sqlserver_cell_to_json(&cell), serde_json::json!("1234567890123456789012345678.9012345678"));
}
#[test]
fn sqlserver_null_numeric_cells_are_json_null() {
assert_eq!(sqlserver_cell_to_json(&ColumnData::Numeric(None)), serde_json::Value::Null);
}
#[test]
fn format_sqlserver_numeric_covers_sign_scale_and_padding() {
assert_eq!(format_sqlserver_numeric(42, 0), "42");
assert_eq!(format_sqlserver_numeric(-42, 0), "-42");
assert_eq!(format_sqlserver_numeric(12345, 2), "123.45");
// Trailing zeros are kept, matching the previous rust_decimal display.
assert_eq!(format_sqlserver_numeric(1500, 3), "1.500");
assert_eq!(format_sqlserver_numeric(-15, 1), "-1.5");
assert_eq!(format_sqlserver_numeric(-5, 2), "-0.05");
assert_eq!(format_sqlserver_numeric(0, 2), "0.00");
// digits.len() == scale must keep the leading "0." (guards `>` vs `>=` in the split).
assert_eq!(format_sqlserver_numeric(123, 3), "0.123");
// Largest scale tiberius can deliver (its decoder asserts scale < 38).
assert_eq!(format_sqlserver_numeric(9, 37), format!("0.{}9", "0".repeat(36)));
}
#[test]
fn sqlserver_datetime2_cells_are_json_strings() {
let datetime = NaiveDate::from_ymd_opt(2026, 5, 13).unwrap().and_hms_milli_opt(9, 8, 7, 123).unwrap();