diff --git a/apps/desktop/src/lib/export/xlsxExport.ts b/apps/desktop/src/lib/export/xlsxExport.ts index 413718dead..a13bedc258 100644 --- a/apps/desktop/src/lib/export/xlsxExport.ts +++ b/apps/desktop/src/lib/export/xlsxExport.ts @@ -125,7 +125,8 @@ function estimateColumnWidths(columns: readonly string[], rows: readonly (readon function safeExcelNumber(value: string): string | undefined { const trimmed = value.trim(); if (!trimmed || !Number.isFinite(Number(trimmed))) return undefined; - const significantDigits = (trimmed.split(/[eE]/, 1)[0].match(/\d/g) || []).join("").replace(/^0+/, "").length; + const [integerPart, fractionPart = ""] = trimmed.split(/[eE]/, 1)[0].split(".", 2); + const significantDigits = `${integerPart}${fractionPart.replace(/0+$/, "")}`.replace(/\D/g, "").replace(/^0+/, "").length; return significantDigits <= 15 ? trimmed : undefined; } diff --git a/crates/dbx-core/src/xlsx_export.rs b/crates/dbx-core/src/xlsx_export.rs index 363ea74fbb..6b524fedcb 100644 --- a/crates/dbx-core/src/xlsx_export.rs +++ b/crates/dbx-core/src/xlsx_export.rs @@ -646,13 +646,13 @@ fn safe_excel_number(value: &str) -> Option<&str> { if trimmed.is_empty() || trimmed.parse::().ok().is_none_or(|number| !number.is_finite()) { return None; } - // Excel stores numbers with at most 15 significant decimal digits. Keep - // higher-precision database values as text rather than silently rounding. - let significant_digits = trimmed - .split(['e', 'E']) - .next() - .unwrap_or(trimmed) + // 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(); @@ -1141,6 +1141,41 @@ mod tests { assert!(sheet.contains("00123")); } + #[test] + fn ignores_fractional_trailing_zeros_when_checking_excel_numeric_precision() { + let workbook = build_xlsx_workbook(&XlsxWorksheetData { + sheet_name: Some("Numeric precision".to_string()), + columns: vec![ + "reported".to_string(), + "negative_boundary".to_string(), + "safe_boundary".to_string(), + "unsafe_integer".to_string(), + "precise_fraction".to_string(), + "fallback".to_string(), + ], + column_types: vec!["numeric".to_string(); 6], + column_comments: vec![], + rows: vec![vec![ + json!("-100000.0000000000"), + json!("-999999999999999.0000"), + json!("123456789012345.0000000000"), + json!("1234567890123456.0000"), + json!("100000.0000000001"), + json!("not-a-number"), + ]], + numeric_column_right_align: true, + }) + .expect("build workbook"); + + let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml"); + assert!(sheet.contains("-100000.0000000000")); + assert!(sheet.contains("-999999999999999.0000")); + assert!(sheet.contains("123456789012345.0000000000")); + assert!(sheet.contains("1234567890123456.0000")); + assert!(sheet.contains("100000.0000000001")); + assert!(sheet.contains("not-a-number")); + } + #[test] fn writes_temporal_columns_as_excel_dates_without_retyping_other_values() { let workbook = build_xlsx_workbook(&XlsxWorksheetData { diff --git a/crates/dbx-core/tests/live_postgres_query_result_export.rs b/crates/dbx-core/tests/live_postgres_query_result_export.rs index 2155980673..4355167ee9 100644 --- a/crates/dbx-core/tests/live_postgres_query_result_export.rs +++ b/crates/dbx-core/tests/live_postgres_query_result_export.rs @@ -241,6 +241,76 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() { cleanup_result.expect("cleanup temporal export fixture"); } +#[tokio::test] +#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at PostgreSQL 16.3"] +async fn live_postgres_numeric_xlsx_ignores_fractional_trailing_zeros() { + let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432); + let user = std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string()); + let password = std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default(); + let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string()); + + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let dir = std::env::temp_dir().join(format!("dbx-live-postgres-xlsx-numeric-{suffix}")); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new(storage); + let connection_id = "live-postgres-xlsx-numeric"; + let config = live_postgres_config(connection_id, &host, port, &user, &password, &database); + state.configs.write().await.insert(config.id.clone(), config); + + let file_path = dir.join("result.xlsx"); + let sql = "SELECT fee FROM (VALUES \ + (1, (-100000.0000000000)::numeric), \ + (2, (-999999999999999.0000)::numeric), \ + (3, (123456789012345.0000000000)::numeric), \ + (4, (1234567890123456.0000)::numeric), \ + (5, (100000.0000000001)::numeric)) AS v(ord, fee) ORDER BY ord" + .to_string(); + let request = QueryResultExportRequest { + export_id: format!("live-postgres-xlsx-numeric-{suffix}"), + connection_id: connection_id.to_string(), + database: database.clone(), + schema: None, + catalog: None, + sql: sql.clone(), + query_base_sql: sql, + setup_sql: Vec::new(), + database_type: DatabaseType::Postgres, + use_agent_cursor: false, + file_path: file_path.to_string_lossy().to_string(), + format: "xlsx".to_string(), + include_sql_sheet: false, + page_size: 100, + row_limit: None, + total_rows: None, + timeout_secs: Some(30), + keyset_optimization_enabled: false, + client_session_id: Some(format!("live-postgres-xlsx-numeric-{suffix}")), + execution_id: Some(format!("live-postgres-xlsx-numeric-{suffix}")), + date_time_format: None, + export_table_name: None, + export_column_types: None, + column_comments: None, + numeric_column_right_align: true, + }; + + export_query_result_core(&state, &request, None, |_| {}).await.expect("export numeric XLSX"); + + let workbook = std::fs::read(&file_path).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(workbook)).expect("open generated XLSX"); + let mut sheet = String::new(); + archive.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut sheet).unwrap(); + assert!(sheet.contains("-100000.0000000000"), "sheet={sheet}"); + assert!(sheet.contains("-999999999999999.0000"), "sheet={sheet}"); + assert!(sheet.contains("123456789012345.0000000000"), "sheet={sheet}"); + assert!(sheet.contains("1234567890123456.0000")); + assert!(sheet.contains("100000.0000000001")); + + drop(archive); + let _ = std::fs::remove_dir_all(&dir); +} + #[tokio::test] #[ignore = "requires DBX_LIVE_POSTGRES_* env vars for temporary-table CSV/XLSX export"] async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() { diff --git a/packages/app-tests/xlsxExport.test.ts b/packages/app-tests/xlsxExport.test.ts index a473b19988..6f2fee86eb 100644 --- a/packages/app-tests/xlsxExport.test.ts +++ b/packages/app-tests/xlsxExport.test.ts @@ -68,6 +68,23 @@ test("writes MySQL 5.7 numeric strings as numeric cells", () => { assert.match(text, /9007199254740992<\/t><\/is><\/c>/); }); +test("ignores fractional trailing zeros when checking Excel numeric precision", () => { + const workbook = buildXlsxWorkbook({ + sheetName: "Numeric precision", + columns: ["reported", "negative_boundary", "safe_boundary", "unsafe_integer", "precise_fraction", "fallback"], + columnTypes: ["numeric", "numeric", "numeric", "numeric", "numeric", "numeric"], + rows: [["-100000.0000000000", "-999999999999999.0000", "123456789012345.0000000000", "1234567890123456.0000", "100000.0000000001", "not-a-number"]], + }); + const text = new TextDecoder().decode(workbook); + + assert.match(text, /-100000\.0000000000<\/v><\/c>/); + assert.match(text, /-999999999999999\.0000<\/v><\/c>/); + assert.match(text, /123456789012345\.0000000000<\/v><\/c>/); + assert.match(text, /1234567890123456\.0000<\/t><\/is><\/c>/); + assert.match(text, /100000\.0000000001<\/t><\/is><\/c>/); + assert.match(text, /not-a-number<\/t><\/is><\/c>/); +}); + test("builds a result workbook with a separate SQL worksheet", () => { const sqlWorksheet = buildXlsxSqlWorksheet([{ sql: "SELECT id, name FROM users WHERE active = true" }]); assert.ok(sqlWorksheet);