fix(csv): unwrap export force-text markers

This commit is contained in:
Thanadon Kongkanun authored and GitHub committed 2026-09-12 09:28:17 +08:00
1 parent 083f81bc03
commit 076c39bc21
1 file changed
+75
+75
View File
@@ -492,8 +492,29 @@ pub fn effective_delimited_config(
})
}
/// Undo the `="..."` force-text wrapper that CSV export writes around temporal
/// cells (see `temporal_format::wrap_csv_force_text`). Without this, exporting a
/// table with a datetime column and importing the file straight back stores the
/// literal `="2026-06-24 02:00:07"` instead of the timestamp, which every
/// temporal column type then rejects.
///
/// Only the exact wrapper shape is unwrapped: a leading `="`, a trailing `"`,
/// and no `"` in between. A genuine value that merely starts with `=` (or
/// contains quotes of its own) is left untouched, so this cannot corrupt CSV
/// files that dbx did not produce.
fn unwrap_csv_force_text(value: &str) -> &str {
let Some(inner) = value.strip_prefix("=\"").and_then(|rest| rest.strip_suffix('"')) else {
return value;
};
if inner.contains('"') {
return value;
}
inner
}
pub fn csv_value_with_config(value: &str, config: DelimitedParseConfig) -> serde_json::Value {
let value = if config.trim_values { value.trim() } else { value };
let value = unwrap_csv_force_text(value);
if config.empty_string_as_null && value.is_empty() {
serde_json::Value::Null
} else {
@@ -9690,6 +9711,60 @@ mod tests {
assert_eq!(text_batches[0].sql, "INSERT INTO \"issue_6491\" (\"amount\") VALUES\n('1,234.56'),\n('12,345')");
}
#[test]
fn csv_import_unwraps_the_force_text_wrapper_written_by_csv_export() {
// Export wraps temporal cells as `="..."` so spreadsheets stop re-typing
// them; re-importing that file has to produce the plain timestamp again.
let parsed = parse_csv_bytes(b"insert_time,id\n\"=\"\"2026-06-24 02:00:07\"\"\",695350\n", 10).unwrap();
assert_eq!(parsed.rows[0][0], serde_json::Value::String("2026-06-24 02:00:07".to_string()));
assert_eq!(parsed.rows[0][1], serde_json::Value::String("695350".to_string()));
let mappings = vec![
TableImportColumnMapping {
source_column: "insert_time".to_string(),
target_column: "insert_time".to_string(),
target_data_type: None,
},
TableImportColumnMapping {
source_column: "id".to_string(),
target_column: "id".to_string(),
target_data_type: None,
},
];
let batches = build_import_insert_batches(
&parsed,
&mappings,
&[("insert_time".to_string(), "datetime".to_string()), ("id".to_string(), "bigint".to_string())],
"issue_8803",
"",
&DatabaseType::Mysql,
500,
)
.unwrap();
assert_eq!(
batches[0].sql,
"INSERT INTO `issue_8803` (`insert_time`, `id`) VALUES\n('2026-06-24 02:00:07', 695350)"
);
}
#[test]
fn csv_import_keeps_values_that_only_look_like_the_force_text_wrapper() {
// Formula-looking data that dbx did not write must survive untouched,
// otherwise importing a third-party CSV silently rewrites its cells.
let parsed = parse_csv_bytes(
b"expr\n\"=\"\"a\"\"&\"\"b\"\"\"\n=SUM(A1:A2)\n\"=\"\"unterminated\"\n\"just \"\"quoted\"\" text\"\n",
10,
)
.unwrap();
assert_eq!(parsed.rows[0][0], serde_json::Value::String("=\"a\"&\"b\"".to_string()));
assert_eq!(parsed.rows[1][0], serde_json::Value::String("=SUM(A1:A2)".to_string()));
assert_eq!(parsed.rows[2][0], serde_json::Value::String("=\"unterminated".to_string()));
assert_eq!(parsed.rows[3][0], serde_json::Value::String("just \"quoted\" text".to_string()));
}
#[test]
fn formats_only_excel_columns_mapped_to_text_targets() {
let path = std::env::temp_dir().join(format!("dbx-table-import-display-formats-{}.xlsx", uuid::Uuid::new_v4()));