diff --git a/apps/desktop/src/components/import/TableImportDialog.vue b/apps/desktop/src/components/import/TableImportDialog.vue index e1ad6316f..7220cc995 100644 --- a/apps/desktop/src/components/import/TableImportDialog.vue +++ b/apps/desktop/src/components/import/TableImportDialog.vue @@ -15,6 +15,7 @@ import { useToast } from "@/composables/useToast"; import { autoMapImportColumns, buildTableImportParseOptions, + defaultTableImportEmptyStringAsNull, formatTableImportElapsed, nextTableImportWizardStep, previousTableImportWizardStep, @@ -90,7 +91,7 @@ const titleRow = ref(1); const dataStartRow = ref(2); const lastDataRow = ref(0); const trimValues = ref(false); -const emptyStringAsNull = ref(true); +const emptyStringAsNull = ref(defaultTableImportEmptyStringAsNull(sourceFormat.value)); const selectedSheet = ref(""); const jsonShape = ref("auto"); const previewLimit = ref(50); @@ -250,7 +251,7 @@ function resetState() { dataStartRow.value = 2; lastDataRow.value = 0; trimValues.value = false; - emptyStringAsNull.value = true; + emptyStringAsNull.value = defaultTableImportEmptyStringAsNull(sourceFormat.value); selectedSheet.value = ""; jsonShape.value = "auto"; previewLimit.value = 50; @@ -519,6 +520,7 @@ function assignSelectedSource(source: string | File) { errorMessage.value = ""; const name = typeof source === "string" ? source : source.name; sourceFormat.value = detectFormat(name); + emptyStringAsNull.value = defaultTableImportEmptyStringAsNull(sourceFormat.value); if (!newTableName.value.trim()) { newTableName.value = suggestedTableName(name); } @@ -554,9 +556,11 @@ async function prepareBatchSources(sources: ImportSource[]) { errorMessage.value = ""; const tasks: BatchImportTask[] = []; const usedNames = new Set(); + const formats = sources.map((source) => detectFormat(sourceName(source))); + emptyStringAsNull.value = formats.length && formats.every((format) => format === formats[0]) ? defaultTableImportEmptyStringAsNull(formats[0]!) : true; try { - for (const source of sources) { - const format = detectFormat(sourceName(source)); + for (const [index, source] of sources.entries()) { + const format = formats[index]!; const initialPreview = await api.previewTableImportFile(source, { sourceFormat: format, parseOptions: taskParseOptions(format), diff --git a/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts b/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts index 1d64a95d8..d2c28f76d 100644 --- a/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { autoMapImportColumns, buildTableImportParseOptions, + defaultTableImportEmptyStringAsNull, formatTableImportElapsed, nextTableImportWizardStep, previousTableImportWizardStep, @@ -13,6 +14,14 @@ import { } from "@/lib/table/tableImport"; describe("tableImport", () => { + it("preserves explicit empty strings by default for Excel only", () => { + expect(defaultTableImportEmptyStringAsNull("excel")).toBe(false); + expect(defaultTableImportEmptyStringAsNull("csv")).toBe(true); + expect(defaultTableImportEmptyStringAsNull("tsv")).toBe(true); + expect(defaultTableImportEmptyStringAsNull("delimited")).toBe(true); + expect(defaultTableImportEmptyStringAsNull("json")).toBe(true); + }); + it("formats import elapsed time for progress and terminal summaries", () => { expect(formatTableImportElapsed(0)).toBe("0 ms"); expect(formatTableImportElapsed(999)).toBe("999 ms"); diff --git a/apps/desktop/src/lib/table/tableImport.ts b/apps/desktop/src/lib/table/tableImport.ts index ad73e12c8..f8a0d56e3 100644 --- a/apps/desktop/src/lib/table/tableImport.ts +++ b/apps/desktop/src/lib/table/tableImport.ts @@ -88,6 +88,10 @@ export interface TableImportParseSettings { jsonShape: TableImportJsonShape; } +export function defaultTableImportEmptyStringAsNull(format: TableImportSourceFormat): boolean { + return format !== "excel"; +} + export function buildTableImportParseOptions(settings: TableImportParseSettings): TableImportParseOptions { const isDelimited = settings.format === "csv" || settings.format === "tsv" || settings.format === "delimited"; return { diff --git a/crates/dbx-core/src/table_import.rs b/crates/dbx-core/src/table_import.rs index 7774d2c2b..410ffe7da 100644 --- a/crates/dbx-core/src/table_import.rs +++ b/crates/dbx-core/src/table_import.rs @@ -6719,6 +6719,42 @@ mod tests { assert_xlsx_empty_string_option(TableImportParseOptions::default(), vec![serde_json::Value::Null; 5]); } + #[test] + fn dbx_exported_xlsx_round_trip_preserves_empty_strings_when_configured() { + let path = + std::env::temp_dir().join(format!("dbx-table-import-empty-round-trip-{}.xlsx", uuid::Uuid::new_v4())); + let workbook = build_xlsx_workbook_multi(&[XlsxWorksheetData { + sheet_name: Some("Data".to_string()), + columns: vec!["empty_text".to_string(), "missing_value".to_string()], + column_types: vec!["VARCHAR(255)".to_string(), "VARCHAR(255)".to_string()], + column_comments: vec![], + rows: vec![vec![serde_json::json!(""), serde_json::Value::Null]], + numeric_column_right_align: false, + }]) + .unwrap(); + std::fs::write(&path, workbook).unwrap(); + let options = + TableImportParseOptions { empty_string_as_null: Some(false), ..TableImportParseOptions::default() }; + + let parsed = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 10).unwrap(); + let (preview, _) = parse_xlsx_preview_file_with_options(&path.to_string_lossy(), &options, 10).unwrap(); + let (sender, mut receiver) = tokio::sync::mpsc::channel(16); + stream_xlsx_rows_to_channel(&path.to_string_lossy(), &options, 500, None, HashSet::new(), false, sender) + .unwrap(); + let mut streamed_rows = Vec::new(); + while let Some(message) = receiver.blocking_recv() { + if let XlsxStreamMessage::Rows(rows) = message.unwrap() { + streamed_rows.extend(rows); + } + } + + let expected = vec![vec![serde_json::json!(""), serde_json::Value::Null]]; + assert_eq!(parsed.rows, expected); + assert_eq!(preview.rows, parsed.rows); + assert_eq!(streamed_rows, parsed.rows); + let _ = std::fs::remove_file(path); + } + #[test] fn retains_only_temporal_and_text_target_xlsx_styles() { let styles = vec![