diff --git a/crates/dbx-core/src/table_import.rs b/crates/dbx-core/src/table_import.rs index 51adda4de6..070b1598f6 100644 --- a/crates/dbx-core/src/table_import.rs +++ b/crates/dbx-core/src/table_import.rs @@ -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()));