diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index d4e8a8d92..69bb8c63e 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -179,6 +179,9 @@ fn format_export_sql_literal_typed( database_type: Option, column_type: Option<&str>, ) -> String { + if is_postgres_json_export_column(database_type, column_type) { + return format_postgres_json_export_literal(value); + } if matches!(database_type, Some(DatabaseType::Mysql)) && column_type.is_some_and(is_mysql_bit_type) { return format_mysql_bit_literal(value); } @@ -205,6 +208,16 @@ fn format_export_sql_literal_typed( format_export_sql_literal_for_database(value, database_type) } +fn format_postgres_json_export_literal(value: &Value) -> String { + if value.is_null() { + return "NULL".to_string(); + } + let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string); + // PostgreSQL standard strings keep backslashes literal; JSON text needs its + // own escape sequences, so only SQL-escape the surrounding string delimiter. + postgres_string_literal(&text) +} + fn quote_export_sql_string(text: &str) -> String { format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''")) } @@ -567,6 +580,18 @@ fn is_postgres_tsvector_export_column(database_type: Option, colum .unwrap_or(false) } +fn is_postgres_json_export_column(database_type: Option, column_type: Option<&str>) -> bool { + database_type == Some(DatabaseType::Postgres) + && column_type + .map(|column_type| { + let normalized = column_type.trim().trim_matches('"').to_ascii_lowercase(); + matches!(normalized.as_str(), "json" | "jsonb") + || normalized.ends_with(".json") + || normalized.ends_with(".jsonb") + }) + .unwrap_or(false) +} + pub fn build_export_sql_insert(options: BuildExportSqlInsertOptions) -> Result { build_export_insert_statements(options.insert).map(|statements| statements.join("\n")) } @@ -1450,6 +1475,29 @@ mod tests { assert_eq!(statements, vec!["INSERT INTO \"public\".\"notes\" (\"body\") VALUES ('line1\nline2\tend');"]); } + #[test] + fn postgres_jsonb_export_preserves_json_escape_sequences() { + let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { + database_type: Some(DatabaseType::Postgres), + schema: Some("public".to_string()), + table_name: Some("events".to_string()), + qualified_table_name: None, + columns: vec!["payload".to_string()], + column_types: vec![Some("jsonb".to_string())], + column_extras: Vec::new(), + rows: vec![vec![json!(r#"{"text":"say \"hi\"","path":"C:\\tmp","quote":"O'Hara"}"#)]], + batch_size: Some(10), + }) + .unwrap(); + + assert_eq!( + statements, + vec![ + r#"INSERT INTO "public"."events" ("payload") VALUES ('{"text":"say \"hi\"","path":"C:\\tmp","quote":"O''Hara"}');"# + ] + ); + } + #[test] fn builds_batched_insert_statements_for_export() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {