From ef9d0b8042e514e11f75b49f6adb707023915a3d Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 25 Aug 2026 12:24:11 +0000 Subject: [PATCH] fix(export): preserve PostgreSQL bytea values Closes #7184 --- crates/dbx-core/src/database_export.rs | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 56d2c30514..945c9cd888 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -390,6 +390,9 @@ fn format_export_sql_literal_typed( column_type: Option<&str>, sqlserver_unicode_string: bool, ) -> String { + if is_postgres_bytea_export_column(database_type, column_type) { + return format_postgres_bytea_export_literal(value); + } if is_postgres_json_export_column(database_type, column_type) { return format_postgres_json_export_literal(value); } @@ -436,6 +439,21 @@ fn format_export_sql_literal_typed( format_export_sql_literal_for_database(value, database_type) } +fn format_postgres_bytea_export_literal(value: &Value) -> String { + if value.is_null() { + return "NULL".to_string(); + } + if let Some(text) = value.as_str() { + if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) { + if hex.len() % 2 == 0 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return format!("decode('{hex}','hex')"); + } + } + } + let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string); + quote_postgres_string_literal(&text) +} + fn format_postgres_json_export_literal(value: &Value) -> String { if value.is_null() { return "NULL".to_string(); @@ -1201,6 +1219,16 @@ fn is_postgres_json_export_column(database_type: Option, column_ty .unwrap_or(false) } +fn is_postgres_bytea_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(); + normalized == "bytea" || normalized.ends_with(".bytea") + }) + .unwrap_or(false) +} + fn is_postgres_vector_export_column(database_type: Option, column_type: Option<&str>) -> bool { database_type == Some(DatabaseType::Postgres) && column_type @@ -3896,6 +3924,71 @@ mod tests { assert_eq!(statements, vec!["INSERT INTO \"public\".\"notes\" (\"body\") VALUES (E'line1\\nline2\\tend');"]); } + #[test] + fn postgres_bytea_export_decodes_valid_dbx_hex_values() { + const ZIP_HEX: &str = "504b03041400080008007496195d00000000000000000000000009000900746573742e6a736f6e5554050001bd738d6a013100ceff7b0a2020227469746c65223a202254657374222c0a20202274657874223a202248656c6c6f2c20776f726c6421220a7d0a504b07083f90bb503600000031000000504b010214031400080008007496195d3f90bb503600000031000000090009000000000000000000b48100000000746573742e6a736f6e5554050001bd738d6a504b0506000000000100010040000000760000000000"; + let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { + database_type: Some(DatabaseType::Postgres), + identifier_quote: None, + schema: Some("public".to_string()), + table_name: Some("attachments".to_string()), + qualified_table_name: None, + columns: vec![ + "zip_content".to_string(), + "empty_content".to_string(), + "uppercase_content".to_string(), + "nullable_content".to_string(), + "plain_text".to_string(), + ], + column_types: vec![ + Some("bytea".to_string()), + Some("bytea".to_string()), + Some("bytea".to_string()), + Some("bytea".to_string()), + Some("text".to_string()), + ], + column_extras: Vec::new(), + spatial_columns: Vec::new(), + spatial_values: Vec::new(), + rows: vec![vec![json!(format!("0x{ZIP_HEX}")), json!("0x"), json!("0XABcd"), Value::Null, json!("0xABcd")]], + batch_size: Some(10), + }) + .unwrap(); + + assert_eq!( + statements, + vec![format!( + "INSERT INTO \"public\".\"attachments\" (\"zip_content\", \"empty_content\", \"uppercase_content\", \"nullable_content\", \"plain_text\") VALUES (decode('{ZIP_HEX}','hex'), decode('','hex'), decode('ABcd','hex'), NULL, '0xABcd');" + )] + ); + } + + #[test] + fn postgres_bytea_export_quotes_invalid_or_non_string_values() { + let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { + database_type: Some(DatabaseType::Postgres), + identifier_quote: None, + schema: Some("public".to_string()), + table_name: Some("attachments".to_string()), + qualified_table_name: None, + columns: vec!["odd_hex".to_string(), "invalid_hex".to_string(), "unexpected_number".to_string()], + column_types: vec![Some("bytea".to_string()), Some("bytea".to_string()), Some("bytea".to_string())], + column_extras: Vec::new(), + spatial_columns: Vec::new(), + spatial_values: Vec::new(), + rows: vec![vec![json!("0xabc"), json!("0xgg"), json!(7)]], + batch_size: Some(10), + }) + .unwrap(); + + assert_eq!( + statements, + vec![ + "INSERT INTO \"public\".\"attachments\" (\"odd_hex\", \"invalid_hex\", \"unexpected_number\") VALUES ('0xabc', '0xgg', '7');" + ] + ); + } + #[test] fn postgres_export_inserts_escape_quotes_and_backslashes_without_changing_plain_strings() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {