From ace100695f1d330d2e05c417f98347c860ed5a0f Mon Sep 17 00:00:00 2001
From: dienaso <89768641@qq.com>
Date: Fri, 18 Sep 2026 17:29:41 +0800
Subject: [PATCH] fix(export): preserve high-precision XLSX numbers as text
---
crates/dbx-formats/src/xlsx_export.rs | 104 +++++++++++++++++++++-----
1 file changed, 87 insertions(+), 17 deletions(-)
diff --git a/crates/dbx-formats/src/xlsx_export.rs b/crates/dbx-formats/src/xlsx_export.rs
index 2e7e1c837d..d6de3f6451 100644
--- a/crates/dbx-formats/src/xlsx_export.rs
+++ b/crates/dbx-formats/src/xlsx_export.rs
@@ -602,8 +602,8 @@ fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Op
format!("{bool_v}")
}
Some(Value::Number(n)) => {
- if n.as_f64().is_some_and(|f| f.is_finite()) {
- format!("{}", n)
+ if let Some(number) = excel_safe_number_text(n) {
+ format!("{number}")
} else {
format!(
"{}",
@@ -643,9 +643,9 @@ fn push_cell_xml(output: &mut String, value: Option<&Value>, row_index: usize, c
write!(output, ">{bool_value}").expect("writing XLSX booleans into a String cannot fail");
}
Some(Value::Number(value)) => {
- if value.as_f64().is_some_and(|number| number.is_finite()) {
+ if let Some(number) = excel_safe_number_text(value) {
push_cell_style(output, style);
- write!(output, ">{value}").expect("writing XLSX numbers into a String cannot fail");
+ write!(output, ">{number}").expect("writing XLSX numbers into a String cannot fail");
} else {
output.push_str(" t=\"inlineStr\"");
push_cell_style(output, style);
@@ -752,22 +752,37 @@ fn numeric_column_style(column_type: Option<&String>, enabled: bool) -> Option usize {
+ let trimmed = value.trim();
+ let significand = trimmed.split(['e', 'E']).next().unwrap_or(trimmed);
+ let (integer, fraction) = significand.split_once('.').unwrap_or((significand, ""));
+ integer
+ .chars()
+ .chain(fraction.trim_end_matches('0').chars())
+ .filter(|ch| ch.is_ascii_digit())
+ .skip_while(|ch| *ch == '0')
+ .count()
+}
+
fn safe_excel_number(value: &str) -> Option<&str> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.parse::().ok().is_none_or(|number| !number.is_finite()) {
return None;
}
- // Fractional trailing zeros preserve database scale but add no numeric
- // precision. Keep values with more than 15 actual digits as text.
- let significand = trimmed.split(['e', 'E']).next().unwrap_or(trimmed);
- let (integer, fraction) = significand.split_once('.').unwrap_or((significand, ""));
- let significant_digits = integer
- .chars()
- .chain(fraction.trim_end_matches('0').chars())
- .filter(|ch| ch.is_ascii_digit())
- .skip_while(|ch| *ch == '0')
- .count();
- (significant_digits <= 15).then_some(trimmed)
+ (excel_significant_digits(trimmed) <= 15).then_some(trimmed)
+}
+
+/// JSON numbers must get the same 15-digit guard as numeric strings (#9382).
+/// 18+ digit bigint/decimal values lose precision inside Excel's General
+/// format, so they fall back to text cells instead of silent corruption.
+fn excel_safe_number_text(number: &serde_json::Number) -> Option {
+ if number.as_f64().is_none_or(|value| !value.is_finite()) {
+ return None;
+ }
+ let text = number.to_string();
+ (excel_significant_digits(&text) <= 15).then_some(text)
}
fn push_typed_cell_xml(
@@ -1221,8 +1236,8 @@ fn build_xlsx_workbook_multi_with_max_rows_and_auto_filter(
mod tests {
use super::{
build_xlsx_workbook, build_xlsx_workbook_multi, build_xlsx_workbook_multi_with_auto_filter,
- build_xlsx_workbook_multi_with_max_rows, build_xlsx_workbook_with_auto_filter, is_numeric_column_type,
- start_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_max_rows,
+ build_xlsx_workbook_multi_with_max_rows, build_xlsx_workbook_with_auto_filter, finish_streaming_xlsx_workbook,
+ is_numeric_column_type, start_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_max_rows,
start_streaming_xlsx_workbook_with_options, start_streaming_xlsx_workbook_with_trailing_sheets,
write_worksheet_xml, WorksheetSegment, XlsxWorksheetData,
};
@@ -1584,6 +1599,61 @@ mod tests {
assert!(sheet.contains("123456789012345.6789000000"));
}
+ #[test]
+ fn preserves_high_precision_json_numbers_as_text() {
+ // #9382: Value::Number used to skip the 15-digit guard that strings get.
+ let workbook = build_xlsx_workbook(&XlsxWorksheetData {
+ sheet_name: Some("PrecisionNumbers".to_string()),
+ columns: vec![
+ "snowflake_id".to_string(),
+ "negative_id".to_string(),
+ "safe_id".to_string(),
+ "amount".to_string(),
+ ],
+ column_types: vec![
+ "bigint".to_string(),
+ "bigint".to_string(),
+ "bigint".to_string(),
+ "decimal(20,2)".to_string(),
+ ],
+ column_comments: vec![],
+ rows: vec![vec![
+ json!(9223372036854775807_i64),
+ json!(-9223372036854775808_i64),
+ json!(123456789012345_i64),
+ json!(12345678901234567.89_f64),
+ ]],
+ numeric_column_right_align: false,
+ })
+ .expect("build workbook");
+
+ let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
+ assert!(sheet.contains("t=\"inlineStr\"") && sheet.contains("9223372036854775807"), "sheet={sheet}");
+ assert!(sheet.contains("-9223372036854775808"), "sheet={sheet}");
+ assert!(sheet.contains(">123456789012345"), "safe 15-digit number should stay numeric, sheet={sheet}");
+ assert!(
+ !sheet.contains(">9223372036854775807"),
+ "19-digit number must not be a numeric cell, sheet={sheet}"
+ );
+ }
+
+ #[test]
+ fn streaming_xlsx_preserves_high_precision_json_numbers_as_text() {
+ let mut writer = start_streaming_xlsx_workbook(
+ Cursor::new(Vec::new()),
+ Some("StreamPrecision"),
+ &["snowflake_id".to_string()],
+ &["bigint".to_string()],
+ )
+ .expect("start streaming workbook");
+ writer.write_row(&[json!(9223372036854775807_u64)]).expect("write row");
+ let cursor = finish_streaming_xlsx_workbook(writer).expect("finish workbook");
+ let bytes = cursor.into_inner();
+ let sheet = read_zip_entry(&bytes, "xl/worksheets/sheet1.xml");
+ assert!(sheet.contains("t=\"inlineStr\"") && sheet.contains("9223372036854775807"), "sheet={sheet}");
+ assert!(!sheet.contains(">9223372036854775807"), "sheet={sheet}");
+ }
+
#[test]
fn sanitizes_invalid_sheet_name() {
let workbook = build_xlsx_workbook(&XlsxWorksheetData {