diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 13cf5fc42..90cae43b0 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -1774,6 +1774,9 @@ pub fn format_grid_sql_literal( return number.to_string(); } if let Some(arr) = value.as_array() { + if let Some(element_type) = postgres_json_array_element_type(database_type, column_info) { + return format_postgres_json_array_sql_literal(arr, element_type); + } if matches!(database_type, Some(DatabaseType::ClickHouse) | Some(DatabaseType::Databend)) { return format_ch_array_sql_literal(arr); } @@ -1855,6 +1858,44 @@ pub fn format_grid_sql_literal( escaped } +fn postgres_json_array_element_type( + database_type: Option, + column_info: Option<&DataGridColumnInfo>, +) -> Option<&'static str> { + if database_type != Some(DatabaseType::Postgres) { + return None; + } + match column_info?.data_type.trim().to_ascii_lowercase().as_str() { + "json[]" | "_json" => Some("json"), + "jsonb[]" | "_jsonb" => Some("jsonb"), + _ => None, + } +} + +fn format_postgres_json_array_sql_literal(arr: &[Value], element_type: &str) -> String { + if arr.is_empty() { + return format!("ARRAY[]::{element_type}[]"); + } + let elements = arr + .iter() + .map(|value| { + if value.is_null() { + return "NULL".to_string(); + } + let json = match value { + Value::String(text) => serde_json::from_str::(text) + .map(|value| value.to_string()) + .unwrap_or_else(|_| Value::String(text.clone()).to_string()), + _ => value.to_string(), + }; + let escaped = json.replace('\\', "\\\\").replace('\'', "''"); + format!("E'{escaped}'::{element_type}") + }) + .collect::>() + .join(", "); + format!("ARRAY[{elements}]") +} + fn format_sqlserver_unicode_literal(text: &str) -> String { let mut parts = Vec::new(); let mut segment = String::new(); @@ -2959,6 +3000,78 @@ mod tests { } } + #[test] + fn postgres_keyless_update_preserves_jsonb_array_elements() { + let endpoints = + json!([r#"{"port":10031,"type":"admin_web"}"#, r#""quoted""#, r#"[1,true,{"nested":null}]"#, null]); + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Postgres), + identifier_quote: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("public".to_string()), + table_name: "services".to_string(), + primary_keys: vec![], + columns: Some(vec![ + column("id", "integer", false, None), + column("name", "text", false, None), + column("endpoints", "jsonb[]", true, None), + ]), + }, + columns: vec!["id".to_string(), "name".to_string(), "endpoints".to_string()], + source_columns: None, + rows: vec![vec![json!(1), json!("before"), endpoints]], + dirty_rows: vec![(0, vec![(1, json!("after"))])], + deleted_rows: vec![], + new_rows: vec![], + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements.len(), 1); + let statement = &result.statements[0]; + assert!(statement.starts_with("UPDATE \"public\".\"services\" SET \"name\" = 'after' WHERE ")); + assert!(statement.contains("\"endpoints\" = ARRAY[")); + assert!(statement.contains("admin_web")); + assert!(statement.contains(r#"E'"quoted"'::jsonb"#), "{statement}"); + assert!(statement.contains("NULL]")); + assert!(!statement.contains('\u{1}')); + } + + #[test] + fn postgres_json_array_literals_preserve_json_documents_and_nulls() { + let value = json!([r#"{"object":true}"#, r#""text""#, "[1,2]", "plain text", "null", null]); + let json_column = column("payload", "json[]", true, None); + let jsonb_column = column("payload", "jsonb[]", true, None); + let text_column = column("payload", "text[]", true, None); + let integer_column = column("payload", "integer[]", true, None); + + assert_eq!( + format_grid_sql_literal(&value, Some(DatabaseType::Postgres), Some(&json_column)), + r#"ARRAY[E'{"object":true}'::json, E'"text"'::json, E'[1,2]'::json, E'"plain text"'::json, E'null'::json, NULL]"# + ); + assert_eq!( + format_grid_sql_literal(&value, Some(DatabaseType::Postgres), Some(&jsonb_column)), + r#"ARRAY[E'{"object":true}'::jsonb, E'"text"'::jsonb, E'[1,2]'::jsonb, E'"plain text"'::jsonb, E'null'::jsonb, NULL]"# + ); + assert_eq!( + format_grid_sql_literal(&json!([]), Some(DatabaseType::Postgres), Some(&jsonb_column)), + "ARRAY[]::jsonb[]" + ); + assert_eq!( + format_grid_sql_literal( + &json!(["first", null, "second"]), + Some(DatabaseType::Postgres), + Some(&text_column) + ), + r#"'{"first",NULL,"second"}'"# + ); + assert_eq!( + format_grid_sql_literal(&json!([1, null, 2]), Some(DatabaseType::Postgres), Some(&integer_column)), + "'{1,NULL,2}'" + ); + } + #[test] fn builds_copy_update_statements() { let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 67f43e884..7dbebf7c7 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -230,6 +230,10 @@ fn pg_optional_array_to_json( ) } +fn pg_json_array_values_to_json(values: Vec>) -> serde_json::Value { + pg_optional_array_to_json(values, |value| serde_json::Value::String(value.to_string())) +} + fn pg_float_number(v: f64) -> serde_json::Value { serde_json::Number::from_f64(v).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null) } @@ -347,6 +351,9 @@ fn pg_bit_string_array_to_json_value(row: &Row, idx: usize) -> Option Option { + if let Ok(values) = row.try_get::<_, Vec>>(idx) { + return Some(pg_json_array_values_to_json(values)); + } if let Ok(values) = row.try_get::<_, Vec>>(idx) { return Some(pg_optional_array_to_json(values, serde_json::Value::String)); } @@ -4064,6 +4071,32 @@ mod tests { use std::time::Instant; use tokio_postgres::types::FromSql; + fn pg_array_binary(element_oid: u32, elements: &[Option>]) -> Vec { + let mut raw = Vec::new(); + raw.extend_from_slice(&1_i32.to_be_bytes()); + raw.extend_from_slice(&i32::from(elements.iter().any(Option::is_none)).to_be_bytes()); + raw.extend_from_slice(&element_oid.to_be_bytes()); + raw.extend_from_slice(&(elements.len() as i32).to_be_bytes()); + raw.extend_from_slice(&1_i32.to_be_bytes()); + for element in elements { + match element { + Some(bytes) => { + raw.extend_from_slice(&(bytes.len() as i32).to_be_bytes()); + raw.extend_from_slice(bytes); + } + None => raw.extend_from_slice(&(-1_i32).to_be_bytes()), + } + } + raw + } + + fn pg_jsonb_binary(value: &[u8]) -> Vec { + let mut raw = Vec::with_capacity(value.len() + 1); + raw.push(1); + raw.extend_from_slice(value); + raw + } + #[test] fn gaussdb_compatibility_mode_selects_identifier_quote() { for mode in ["M", "B", "mysql", " MYSQL "] { @@ -4076,6 +4109,55 @@ mod tests { assert_eq!(gaussdb_identifier_quote_for_compatibility_mode(""), None); } + #[test] + fn postgres_json_arrays_decode_elements_without_jsonb_version_bytes() { + let json_raw = pg_array_binary( + Type::JSON.oid(), + &[ + Some(br#"{"kind":"json"}"#.to_vec()), + Some(br#""text""#.to_vec()), + Some(br#"[1,true,null]"#.to_vec()), + None, + ], + ); + let jsonb_raw = pg_array_binary( + Type::JSONB.oid(), + &[ + Some(pg_jsonb_binary(br#"{"port":10031,"type":"admin_web"}"#)), + Some(pg_jsonb_binary(br#""quoted""#)), + Some(pg_jsonb_binary(br#"[2,false,{"nested":true}]"#)), + None, + ], + ); + + let json_values = Vec::>::from_sql(&Type::JSON_ARRAY, &json_raw).unwrap(); + let jsonb_values = Vec::>::from_sql(&Type::JSONB_ARRAY, &jsonb_raw).unwrap(); + + assert_eq!( + pg_json_array_values_to_json(json_values), + serde_json::json!([r#"{"kind":"json"}"#, r#""text""#, "[1,true,null]", null]) + ); + let decoded = pg_json_array_values_to_json(jsonb_values); + assert_eq!( + decoded, + serde_json::json!([ + r#"{"port":10031,"type":"admin_web"}"#, + r#""quoted""#, + r#"[2,false,{"nested":true}]"#, + null + ]) + ); + assert!(!decoded.to_string().contains('\u{1}')); + } + + #[test] + fn postgres_json_array_decoder_is_limited_to_json_element_types() { + assert!(Vec::>::accepts(&Type::JSON_ARRAY)); + assert!(Vec::>::accepts(&Type::JSONB_ARRAY)); + assert!(!Vec::>::accepts(&Type::TEXT_ARRAY)); + assert!(!Vec::>::accepts(&Type::INT4_ARRAY)); + } + fn pg_interval_bytes(microseconds: i64, days: i32, months: i32) -> [u8; 16] { let mut raw = [0_u8; 16]; raw[0..8].copy_from_slice(µseconds.to_be_bytes());