From adedde8e46598698191ead980f04f92b70a6c31f Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:14:50 +0800 Subject: [PATCH] fix(export): export decimal columns as numeric in full XLSX export --- .../__tests__/useDataGridExport.xlsx.spec.ts | 31 +++++++ .../src/composables/useDataGridExport.ts | 2 +- .../src/lib/dataGrid/dataGridColumnType.ts | 6 ++ .../dbx-core/src/data/query_result_export.rs | 81 ++++++++++++++++++- crates/dbx-formats/src/xlsx_export.rs | 26 ++++++ 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts index 393fafb929..5243848010 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts @@ -7,6 +7,7 @@ import type { QueryResult } from "@/types/database"; const mocks = vi.hoisted(() => ({ exportQueryResultsXlsx: vi.fn(), + startQueryResultExport: vi.fn().mockResolvedValue({ exportId: "export-1", tableName: "Query Result", rowsExported: 1, totalRows: 1, status: "Done" }), toast: vi.fn(), })); @@ -65,6 +66,8 @@ vi.mock("@/stores/settingsStore", () => ({ vi.mock("@/lib/backend/api", () => ({ exportQueryResultsXlsx: mocks.exportQueryResultsXlsx, + startQueryResultExport: mocks.startQueryResultExport, + cancelQueryResultExport: vi.fn(), })); function queryResult(columns: string[], rows: QueryResult["rows"]): QueryResult { @@ -144,4 +147,32 @@ describe("useDataGridExport XLSX headers", () => { expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Joined identifier)"], autoFilter: false })]), false, undefined); }); + + it("passes exportColumnTypes for backend full xlsx query result export", async () => { + const options = createOptions(); + options.allColumns = computed(() => ["id", "amount"]); + options.allColumnTypes = computed(() => ["int", "decimal(18, 3)"]); + options.queryResultExportRequest = vi.fn(async (request) => ({ + ...request, + connectionId: "conn", + database: "dbx", + databaseType: "duckdb", + sql: "SELECT id, amount FROM tab1", + queryBaseSql: "SELECT id, amount FROM tab1", + useAgentCursor: false, + pageSize: 1000, + keysetOptimizationEnabled: true, + })); + const state = useDataGridExport(options); + + await state.exportXlsx(); + + expect(mocks.startQueryResultExport).toHaveBeenCalledWith( + expect.objectContaining({ + format: "xlsx", + exportColumnTypes: ["int", "decimal(18, 3)"], + }), + expect.any(Function), + ); + }); }); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 119bbf9363..8959d04c94 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -1601,7 +1601,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { includeSqlSheet, exportTableName: insertTarget?.tableName, exportSchema: insertTarget?.tableName ? sqlInsertTargetSchema(insertTarget) : undefined, - exportColumnTypes: format === "sql" ? allColumnTypes.value?.map((type) => type ?? null) : undefined, + exportColumnTypes: format === "sql" || format === "xlsx" ? allColumnTypes.value?.map((type) => type ?? null) : undefined, exportColumnExtras: format === "sql" ? sqlExportColumnExtras(allColumns.value) : undefined, ...(format === "sql" && sqlExportOptions ? { insertMode: sqlExportOptions.insertMode } : {}), }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts index 6f720ed92b..bc6d8b7da2 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts @@ -101,6 +101,12 @@ const NUMERIC_COLUMN_TYPE_BASES = new Set([ "int", "integer", "bigint", + "hugeint", + "uhugeint", + "utinyint", + "usmallint", + "uinteger", + "ubigint", "serial", "smallserial", "bigserial", diff --git a/crates/dbx-core/src/data/query_result_export.rs b/crates/dbx-core/src/data/query_result_export.rs index fbde86e79b..8bebeb2f33 100644 --- a/crates/dbx-core/src/data/query_result_export.rs +++ b/crates/dbx-core/src/data/query_result_export.rs @@ -262,6 +262,27 @@ fn query_sql_worksheets(request: &QueryResultExportRequest) -> Vec]>, +) -> Vec { + let overrides = overrides.unwrap_or(&[]); + columns + .iter() + .enumerate() + .map(|(index, _col)| { + if let Some(Some(override_type)) = overrides.get(index) { + let trimmed = override_type.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + inferred_types.get(index).map(|t| t.trim().to_string()).unwrap_or_default() + }) + .collect() +} + fn start_query_result_xlsx_workbook( writer: W, request: &QueryResultExportRequest, @@ -270,11 +291,13 @@ fn start_query_result_xlsx_workbook( ) -> Result, String> { let trailing_sheets = query_sql_worksheets(request); let column_comments: &[Option] = request.column_comments.as_deref().unwrap_or(&[]); + let effective_column_types = + resolve_export_column_types(columns, column_types, request.export_column_types.as_deref()); start_streaming_xlsx_workbook_with_options( writer, Some("Result"), columns, - column_types, + &effective_column_types, column_comments, &trailing_sheets, request.date_time_format.as_deref(), @@ -1085,7 +1108,8 @@ async fn export_query_result_core_inner( let page_column_types = if columns.is_empty() { columns = result.columns.clone(); - column_types = result.column_types.clone(); + column_types = + resolve_export_column_types(&columns, &result.column_types, request.export_column_types.as_deref()); if let Some(writer) = sql_writer.as_mut() { writer.set_columns(columns.clone(), &column_types, &result.spatial_columns, request)?; } @@ -1097,7 +1121,9 @@ async fn export_query_result_core_inner( // must be aligned by column name; writing page rows positionally // dropped/duplicated values into the wrong columns. let remap = export_column_remap(&result.columns, &columns); - let page_column_types = realign_page_cells(&remap, result.column_types.clone()); + let page_inferred = realign_page_cells(&remap, result.column_types.clone()); + let page_column_types = + resolve_export_column_types(&columns, &page_inferred, request.export_column_types.as_deref()); result.rows = realign_page_rows(&remap, mem::take(&mut result.rows)); if !result.spatial_values.is_empty() { result.spatial_values = realign_page_rows(&remap, mem::take(&mut result.spatial_values)); @@ -3184,4 +3210,53 @@ mod tests { assert_eq!(task.await.unwrap(), Err(QUERY_CANCELED.to_string())); } + + #[test] + fn resolve_export_column_types_handles_overrides_and_inferred() { + let cols = vec!["id".to_string(), "amount".to_string(), "name".to_string()]; + + // Overrides take precedence when non-empty + let overrides = Some(vec![Some("bigint".to_string()), Some("decimal(18, 2)".to_string()), None]); + let inferred = vec!["int".to_string(), "".to_string(), "varchar".to_string()]; + let resolved = resolve_export_column_types(&cols, &inferred, overrides.as_deref()); + assert_eq!(resolved, vec!["bigint", "decimal(18, 2)", "varchar"]); + + // Empty inferred (e.g. DuckDB) with overrides + let duckdb_overrides = Some(vec![Some("int".to_string()), Some("decimal".to_string()), None]); + let resolved = resolve_export_column_types(&cols, &[], duckdb_overrides.as_deref()); + assert_eq!(resolved, vec!["int", "decimal", ""]); + + // No overrides + let resolved = resolve_export_column_types(&cols, &inferred, None); + assert_eq!(resolved, vec!["int", "", "varchar"]); + } + + #[test] + fn start_query_result_xlsx_workbook_uses_export_column_types_override() { + use std::io::Read; + + let mut req = request("xlsx", None, None); + req.export_column_types = Some(vec![Some("decimal(18, 2)".to_string())]); + + let mut cursor = std::io::Cursor::new(Vec::new()); + // Inferred column_types is empty (like DuckDB query result) + let mut writer = + start_query_result_xlsx_workbook(&mut cursor, &req, &["amount".to_string()], &[]).expect("start workbook"); + writer.write_row(&[serde_json::Value::String("123.45".to_string())]).expect("write row"); + finish_streaming_xlsx_workbook(writer).expect("finish workbook"); + let bytes = cursor.into_inner(); + + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("open zip"); + let mut sheet_file = zip.by_name("xl/worksheets/sheet1.xml").expect("sheet1.xml"); + let mut sheet_xml = String::new(); + sheet_file.read_to_string(&mut sheet_xml).expect("read sheet1"); + + // The decimal value must be exported as a numeric cell (123.45) rather than text + assert!(sheet_xml.contains("123.45"), "expected numeric cell in sheet XML, got: {sheet_xml}"); + assert!( + !sheet_xml.contains(r#"123.45"), "did not expect 123.45 to be exported as text, got: {sheet_xml}"); + } } diff --git a/crates/dbx-formats/src/xlsx_export.rs b/crates/dbx-formats/src/xlsx_export.rs index 96b0298228..fc38b732c8 100644 --- a/crates/dbx-formats/src/xlsx_export.rs +++ b/crates/dbx-formats/src/xlsx_export.rs @@ -858,6 +858,12 @@ fn is_numeric_column_type(column_type: Option<&String>) -> bool { | "int" | "integer" | "bigint" + | "hugeint" + | "uhugeint" + | "utinyint" + | "usmallint" + | "uinteger" + | "ubigint" | "serial" | "smallserial" | "bigserial" @@ -2137,6 +2143,26 @@ mod tests { } } + #[test] + fn duckdb_numeric_types_recognized_by_classifier() { + for duckdb_type in [ + "DECIMAL(18, 3)", + "decimal", + "NUMERIC(10, 2)", + "hugeint", + "uhugeint", + "utinyint", + "usmallint", + "uinteger", + "ubigint", + ] { + assert!( + is_numeric_column_type(Some(&duckdb_type.to_string())), + "expected DuckDB numeric type: {duckdb_type}" + ); + } + } + // ----------------------------------------------------------------------- // Multi-sheet splitting tests // -----------------------------------------------------------------------