1use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
7use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime};
8use nexql_policy::{ObjectRef, PII_REDACTED, column_matches_pii_policy};
9use rust_decimal::Decimal;
10use serde_json::{Value, json};
11use tokio_postgres::types::{FromSql, Kind, Type};
12use uuid::Uuid;
13
14pub fn row_to_json(row: &tokio_postgres::Row) -> Value {
16 let mut map = serde_json::Map::new();
17 for (i, col) in row.columns().iter().enumerate() {
18 map.insert(col.name().to_string(), cell_to_json(row, i));
19 }
20 Value::Object(map)
21}
22
23pub fn rows_to_json_vec(rows: &[tokio_postgres::Row]) -> Vec<Value> {
25 rows.iter().map(row_to_json).collect()
26}
27
28pub fn rows_to_json_array(rows: &[tokio_postgres::Row]) -> Value {
30 Value::Array(rows_to_json_vec(rows))
31}
32
33pub fn rows_to_json_array_with_total(
35 rows: &[tokio_postgres::Row],
36 total_col: &str,
37) -> (Option<i64>, Value) {
38 let total = rows.first().and_then(|row| {
39 row.columns()
40 .iter()
41 .enumerate()
42 .find_map(|(i, col)| {
43 if col.name() == total_col {
44 row.try_get::<_, Option<i64>>(i).ok().flatten()
45 } else {
46 None
47 }
48 })
49 });
50 let values: Vec<Value> = rows
51 .iter()
52 .map(|row| row_to_json_excluding(row, total_col))
53 .collect();
54 (total, Value::Array(values))
55}
56
57fn row_to_json_excluding(row: &tokio_postgres::Row, skip: &str) -> Value {
58 let mut map = serde_json::Map::new();
59 for (i, col) in row.columns().iter().enumerate() {
60 if col.name() == skip {
61 continue;
62 }
63 map.insert(col.name().to_string(), cell_to_json(row, i));
64 }
65 Value::Object(map)
66}
67
68fn columnar_from_object_array(rows: Vec<Value>) -> Value {
73 let Some(first) = rows.first().and_then(|r| r.as_object()) else {
74 return json!({ "columns": [], "rows": rows });
75 };
76 let column_names: Vec<String> = first.keys().cloned().collect();
77
78 let mut all_null = vec![true; column_names.len()];
79 let mut grid: Vec<Vec<Value>> = Vec::with_capacity(rows.len());
80 for row in &rows {
81 let as_obj = row.as_object();
82 let mut r = Vec::with_capacity(column_names.len());
83 for (i, name) in column_names.iter().enumerate() {
84 let v = as_obj
85 .and_then(|m| m.get(name))
86 .cloned()
87 .unwrap_or(Value::Null);
88 if !v.is_null() {
89 all_null[i] = false;
90 }
91 r.push(v);
92 }
93 grid.push(r);
94 }
95
96 let kept: Vec<usize> = (0..column_names.len()).filter(|&i| !all_null[i]).collect();
97 let dropped_names: Vec<&String> = (0..column_names.len())
98 .filter(|&i| all_null[i])
99 .map(|i| &column_names[i])
100 .collect();
101 let kept_names: Vec<&String> = kept.iter().map(|&i| &column_names[i]).collect();
102 if kept.len() != column_names.len() {
103 grid = grid
104 .into_iter()
105 .map(|row| kept.iter().map(|&i| row[i].clone()).collect())
106 .collect();
107 }
108
109 let mut out = json!({ "columns": kept_names, "rows": grid });
110 if !dropped_names.is_empty() {
111 out["allNullColumns"] = json!(dropped_names);
112 }
113 out
114}
115
116pub fn columnarize_read_payload(mut payload: Value) -> Value {
124 let Some(obj) = payload.as_object_mut() else {
125 return payload;
126 };
127 let Some(Value::Array(rows)) = obj.remove("rows") else {
128 return payload;
129 };
130 if rows.first().and_then(|r| r.as_object()).is_none() {
131 obj.insert("columns".into(), json!([]));
133 obj.insert("rows".into(), Value::Array(rows));
134 return payload;
135 }
136 let columnar = columnar_from_object_array(rows);
137 if let Some(cobj) = columnar.as_object() {
138 for (k, v) in cobj {
139 obj.insert(k.clone(), v.clone());
140 }
141 }
142 payload
143}
144
145fn is_uniform_object_array(items: &[Value]) -> bool {
149 let Some(first) = items.first().and_then(|v| v.as_object()) else {
150 return false;
151 };
152 let first_keys: std::collections::BTreeSet<&str> = first.keys().map(String::as_str).collect();
153 items.iter().all(|item| {
154 item.as_object().is_some_and(|obj| {
155 let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
156 keys == first_keys
157 })
158 })
159}
160
161pub fn columnarize_row_arrays(value: Value) -> Value {
174 match value {
175 Value::Array(items) => {
176 if is_uniform_object_array(&items) {
177 columnar_from_object_array(items)
178 } else {
179 Value::Array(items.into_iter().map(columnarize_row_arrays).collect())
180 }
181 }
182 Value::Object(mut map) => {
183 if let Some(Value::Array(rows)) = map.get("rows")
193 && is_uniform_object_array(rows)
194 {
195 let Some(Value::Array(rows)) = map.remove("rows") else {
196 unreachable!("just matched Value::Array above")
197 };
198 if let Some(cobj) = columnar_from_object_array(rows).as_object() {
199 for (k, v) in cobj {
200 map.insert(k.clone(), v.clone());
201 }
202 }
203 }
204 Value::Object(
205 map.into_iter()
206 .map(|(k, v)| (k, columnarize_row_arrays(v)))
207 .collect(),
208 )
209 }
210 other => other,
211 }
212}
213
214pub fn redact_pii_in_rows(
216 rows: Vec<Value>,
217 pii_columns: &[String],
218 tables: &[ObjectRef],
219) -> (Vec<Value>, Vec<String>) {
220 if pii_columns.is_empty() || tables.is_empty() {
221 return (rows, Vec::new());
222 }
223 let mut redacted_cols = Vec::new();
224 let out = rows
225 .into_iter()
226 .map(|row| {
227 let mut obj = match row {
228 Value::Object(map) => map,
229 other => return other,
230 };
231 for (col, val) in obj.iter_mut() {
232 if column_matches_pii_policy(pii_columns, tables, col) {
233 *val = Value::String(PII_REDACTED.into());
234 if !redacted_cols.iter().any(|c| c == col) {
235 redacted_cols.push(col.clone());
236 }
237 }
238 }
239 Value::Object(obj)
240 })
241 .collect();
242 (out, redacted_cols)
243}
244
245pub fn redact_pii_in_payload(
247 mut payload: Value,
248 pii_columns: &[String],
249 tables: &[ObjectRef],
250) -> (Value, Vec<String>) {
251 if let Some(rows) = payload.get_mut("rows").and_then(|v| v.as_array_mut()) {
252 let taken = std::mem::take(rows);
253 let (redacted, cols) = redact_pii_in_rows(taken, pii_columns, tables);
254 *rows = redacted;
255 return (payload, cols);
256 }
257 if let Value::Array(rows) = &mut payload {
258 let taken = std::mem::take(rows);
259 let (redacted, cols) = redact_pii_in_rows(taken, pii_columns, tables);
260 *rows = redacted;
261 return (payload, cols);
262 }
263 (payload, Vec::new())
264}
265
266enum SqlNullness {
268 Null,
269 Value,
270}
271
272impl<'a> FromSql<'a> for SqlNullness {
273 fn from_sql(_: &Type, _: &'a [u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
274 Ok(SqlNullness::Value)
275 }
276
277 fn from_sql_null(_: &Type) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
278 Ok(SqlNullness::Null)
279 }
280
281 fn accepts(_: &Type) -> bool {
282 true
283 }
284}
285
286fn try_cell<T, F>(row: &tokio_postgres::Row, idx: usize, map: F) -> Option<Value>
287where
288 T: for<'a> FromSql<'a>,
289 F: FnOnce(T) -> Value,
290{
291 match row.try_get::<_, Option<T>>(idx) {
292 Ok(Some(v)) => Some(map(v)),
293 Ok(None) => Some(Value::Null),
294 Err(_) => None,
295 }
296}
297
298pub fn cell_to_json(row: &tokio_postgres::Row, idx: usize) -> Value {
299 let col_type = row.columns()[idx].type_();
300 if matches!(row.try_get::<_, SqlNullness>(idx), Ok(SqlNullness::Null)) {
301 return Value::Null;
302 }
303
304 if let Kind::Array(elem) = col_type.kind() {
305 return array_cell_to_json(row, idx, elem);
306 }
307
308 if let Some(v) = match *col_type {
309 Type::BOOL => try_cell::<bool, _>(row, idx, |b| json!(b)),
310 Type::INT2 => try_cell::<i16, _>(row, idx, |n| json!(n)),
311 Type::INT4 | Type::OID => try_cell::<i32, _>(row, idx, |n| json!(n)),
312 Type::INT8 => try_cell::<i64, _>(row, idx, |n| json!(n)),
313 Type::FLOAT4 => try_cell::<f32, _>(row, idx, |n| json!(n)),
314 Type::FLOAT8 => try_cell::<f64, _>(row, idx, |n| json!(n)),
315 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
316 try_cell::<String, _>(row, idx, Value::String)
317 }
318 Type::TIMESTAMP => try_cell::<NaiveDateTime, _>(row, idx, |t| {
319 json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string())
320 }),
321 Type::TIMESTAMPTZ => {
322 try_cell::<DateTime<FixedOffset>, _>(row, idx, |t| json!(t.to_rfc3339()))
323 }
324 Type::DATE => {
325 try_cell::<NaiveDate, _>(row, idx, |d| json!(d.format("%Y-%m-%d").to_string()))
326 }
327 Type::TIME => {
328 try_cell::<NaiveTime, _>(row, idx, |t| json!(t.format("%H:%M:%S%.f").to_string()))
329 }
330 Type::UUID => try_cell::<Uuid, _>(row, idx, |u| json!(u.to_string())),
331 Type::JSON | Type::JSONB => try_cell::<Value, _>(row, idx, |j| j),
332 Type::NUMERIC => try_cell::<Decimal, _>(row, idx, |d| json!(d.to_string())),
333 Type::MONEY => try_cell::<i64, _>(row, idx, |v| json!(money_to_string(v))),
334 Type::BYTEA => try_cell::<Vec<u8>, _>(row, idx, |b| json!(BASE64.encode(b))),
335 _ => None,
336 } {
337 return v;
338 }
339
340 cell_to_json_untyped(row, idx, col_type)
341}
342
343fn array_cell_to_json(row: &tokio_postgres::Row, idx: usize, elem: &Type) -> Value {
344 let try_array = |result: Result<Option<Vec<Value>>, tokio_postgres::Error>| -> Option<Value> {
345 match result {
346 Ok(Some(items)) => Some(Value::Array(items)),
347 Ok(None) => Some(Value::Null),
348 Err(_) => None,
349 }
350 };
351
352 match *elem {
353 Type::BOOL => {
354 if let Some(v) = try_array(
355 row.try_get::<_, Option<Vec<bool>>>(idx)
356 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
357 ) {
358 return v;
359 }
360 }
361 Type::INT2 => {
362 if let Some(v) = try_array(
363 row.try_get::<_, Option<Vec<i16>>>(idx)
364 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
365 ) {
366 return v;
367 }
368 }
369 Type::INT4 | Type::OID => {
370 if let Some(v) = try_array(
371 row.try_get::<_, Option<Vec<i32>>>(idx)
372 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
373 ) {
374 return v;
375 }
376 }
377 Type::INT8 => {
378 if let Some(v) = try_array(
379 row.try_get::<_, Option<Vec<i64>>>(idx)
380 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
381 ) {
382 return v;
383 }
384 }
385 Type::FLOAT4 => {
386 if let Some(v) = try_array(
387 row.try_get::<_, Option<Vec<f32>>>(idx)
388 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
389 ) {
390 return v;
391 }
392 }
393 Type::FLOAT8 => {
394 if let Some(v) = try_array(
395 row.try_get::<_, Option<Vec<f64>>>(idx)
396 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
397 ) {
398 return v;
399 }
400 }
401 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
402 if let Some(v) = try_array(
403 row.try_get::<_, Option<Vec<String>>>(idx)
404 .map(|v| v.map(|a| a.into_iter().map(Value::String).collect())),
405 ) {
406 return v;
407 }
408 }
409 Type::UUID => {
410 if let Some(v) = try_array(
411 row.try_get::<_, Option<Vec<Uuid>>>(idx)
412 .map(|v| v.map(|a| a.into_iter().map(|u| json!(u.to_string())).collect())),
413 ) {
414 return v;
415 }
416 }
417 Type::TIMESTAMP => {
418 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDateTime>>>(idx).map(|v| {
419 v.map(|a| {
420 a.into_iter()
421 .map(|t| json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string()))
422 .collect()
423 })
424 })) {
425 return v;
426 }
427 }
428 Type::TIMESTAMPTZ => {
429 if let Some(v) = try_array(
430 row.try_get::<_, Option<Vec<DateTime<FixedOffset>>>>(idx)
431 .map(|v| v.map(|a| a.into_iter().map(|t| json!(t.to_rfc3339())).collect())),
432 ) {
433 return v;
434 }
435 }
436 Type::DATE => {
437 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDate>>>(idx).map(|v| {
438 v.map(|a| {
439 a.into_iter()
440 .map(|d| json!(d.format("%Y-%m-%d").to_string()))
441 .collect()
442 })
443 })) {
444 return v;
445 }
446 }
447 Type::JSON | Type::JSONB => {
448 if let Some(v) = try_array(row.try_get::<_, Option<Vec<Value>>>(idx)) {
449 return v;
450 }
451 }
452 Type::NUMERIC => {
453 if let Some(v) = try_array(
454 row.try_get::<_, Option<Vec<Decimal>>>(idx)
455 .map(|v| v.map(|a| a.into_iter().map(|d| json!(d.to_string())).collect())),
456 ) {
457 return v;
458 }
459 }
460 Type::MONEY => {
461 if let Some(v) = try_array(
462 row.try_get::<_, Option<Vec<i64>>>(idx)
463 .map(|v| v.map(|a| a.into_iter().map(|m| json!(money_to_string(m))).collect())),
464 ) {
465 return v;
466 }
467 }
468 Type::BYTEA => {
469 if let Some(v) = try_array(
470 row.try_get::<_, Option<Vec<Vec<u8>>>>(idx)
471 .map(|v| v.map(|a| a.into_iter().map(|b| json!(BASE64.encode(b))).collect())),
472 ) {
473 return v;
474 }
475 }
476 _ => {}
477 }
478
479 cell_to_json_untyped(row, idx, row.columns()[idx].type_())
480}
481
482fn money_to_string(v: i64) -> String {
484 let sign = if v < 0 { "-" } else { "" };
485 let abs = v.unsigned_abs();
486 format!("{}{}.{:04}", sign, abs / 10_000, abs % 10_000)
487}
488
489fn cell_to_json_untyped(row: &tokio_postgres::Row, idx: usize, pg_type: &Type) -> Value {
491 if let Ok(Some(s)) = row.try_get::<_, Option<String>>(idx) {
492 return Value::String(s);
493 }
494 json!({
495 "__untyped": true,
496 "type": pg_type.name()
497 })
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use serde_json::json;
504
505 #[test]
506 fn columnarize_read_payload_reshapes_rows_and_drops_all_null_columns() {
507 let payload = json!({
508 "rows": [
509 { "id": 1, "name": "a", "note": null },
510 { "id": 2, "name": "b", "note": null },
511 ],
512 "truncated": true,
513 "maxRows": 500,
514 });
515 let out = columnarize_read_payload(payload);
516 assert_eq!(out["columns"], json!(["id", "name"]));
517 assert_eq!(out["rows"], json!([[1, "a"], [2, "b"]]));
518 assert_eq!(out["allNullColumns"], json!(["note"]));
519 assert_eq!(out["truncated"], json!(true));
521 assert_eq!(out["maxRows"], json!(500));
522 }
523
524 #[test]
525 fn columnarize_read_payload_empty_rows_is_noop_shape() {
526 let payload = json!({ "rows": [] });
527 let out = columnarize_read_payload(payload);
528 assert_eq!(out["columns"], json!([]));
529 assert_eq!(out["rows"], json!([]));
530 }
531
532 #[test]
533 fn columnarize_row_arrays_reshapes_nested_uniform_arrays() {
534 let value = json!({
537 "table": "public.orders",
538 "columns": [
539 { "column_name": "id", "data_type": "integer" },
540 { "column_name": "status", "data_type": "text" },
541 ],
542 "constraints": [
543 { "name": "orders_pkey", "definition": "PRIMARY KEY (id)" },
544 ],
545 });
546 let out = columnarize_row_arrays(value);
547 assert_eq!(
548 out["columns"]["columns"],
549 json!(["column_name", "data_type"])
550 );
551 assert_eq!(
552 out["columns"]["rows"],
553 json!([["id", "integer"], ["status", "text"]])
554 );
555 assert_eq!(out["constraints"]["columns"], json!(["definition", "name"]));
556 }
557
558 #[test]
559 fn columnarize_row_arrays_leaves_heterogeneous_arrays_alone() {
560 let value = json!({
563 "items": [
564 { "a": 1, "b": 2 },
565 { "a": 1, "c": 3 },
566 ]
567 });
568 let out = columnarize_row_arrays(value.clone());
569 assert_eq!(out, value);
570 }
571
572 #[test]
573 fn columnarize_row_arrays_leaves_primitive_and_empty_arrays_alone() {
574 let value = json!({ "warnings": ["a", "b"], "empty": [] });
575 let out = columnarize_row_arrays(value.clone());
576 assert_eq!(out, value);
577 }
578
579 #[test]
580 fn columnarize_row_arrays_is_idempotent_on_already_columnar_shape() {
581 let value = json!({ "columns": ["n"], "rows": [[1], [2]] });
582 let out = columnarize_row_arrays(value.clone());
583 assert_eq!(out, value);
584 }
585
586 #[test]
587 fn redact_pii_replaces_matching_columns() {
588 let rows = vec![json!({"id": 1, "ssn": "123-45-6789"})];
589 let tables = vec![ObjectRef::new("public", "users")];
590 let pii = vec!["public.users.ssn".into()];
591 let (out, cols) = redact_pii_in_rows(rows, &pii, &tables);
592 assert_eq!(cols, vec!["ssn"]);
593 assert_eq!(out[0]["ssn"], json!(PII_REDACTED));
594 assert_eq!(out[0]["id"], json!(1));
595 }
596}