fix(import): preserve empty strings in Excel by default

Closes #5329
This commit is contained in:
t8y2 2026-08-10 09:54:30 +08:00
parent a1918ec8c2
commit 0c9b6c7e1d
No known key found for this signature in database
4 changed files with 57 additions and 4 deletions

View File

@ -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<api.TableImportJsonShape>("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<string>();
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),

View File

@ -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");

View File

@ -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 {

View File

@ -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![