Skip to main content

nexql_tools/
cell_json.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 NexQL-OSS Team
3
4//! Typed Postgres cell → JSON conversion shared by read and write tools.
5
6use 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
14/// Convert one query row to a JSON object keyed by column name.
15pub 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
23/// Convert query rows to a JSON array of objects.
24pub fn rows_to_json_vec(rows: &[tokio_postgres::Row]) -> Vec<Value> {
25    rows.iter().map(row_to_json).collect()
26}
27
28/// Convert query rows to a JSON array value (read-tool shape).
29pub fn rows_to_json_array(rows: &[tokio_postgres::Row]) -> Value {
30    Value::Array(rows_to_json_vec(rows))
31}
32
33/// Like [`rows_to_json_array`] but strips the pagination helper column and returns total count.
34pub 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
68/// Build the columnar envelope `{"columns": [...], "rows": [[...], ...],
69/// "allNullColumns": [...]}` from a homogeneous array of row objects (all
70/// same key set — caller guarantees this). All-null columns are dropped from
71/// the row arrays and listed once instead of repeating null per row.
72fn 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
116/// Reshape a `{"rows": [{...}, ...], ...}` read-tool payload (already
117/// PII-redacted — this is a pure reshape, it knows nothing about policy) from
118/// per-row objects into columnar form: `{"columns": [...], "rows": [[...],
119/// ...], ...}`. Typically 3–5x fewer tokens at higher row/column counts,
120/// since column names aren't repeated per row. Sibling keys on the payload
121/// object (e.g. `truncated`, `maxRows`) are left untouched. A no-op on
122/// anything that isn't `{"rows": [{...}]}` shaped.
123pub 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        // Empty result set or already non-object rows — put back untouched.
132        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
145/// Every object in `items` has the exact same key set (order-insensitive) —
146/// the "these are genuinely rows of one result set" check. An empty slice or
147/// any non-object element fails this.
148fn 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
161/// Recursively reshape every non-empty, uniform (identical key set) array of
162/// objects found anywhere in `value` into the columnar envelope — the
163/// global-default half of Issue 5. Arrays of non-objects, empty arrays, and
164/// arrays of objects with differing key sets (heterogeneous data — nothing
165/// downstream should assume they're "rows") are left exactly as they are.
166///
167/// This is intentionally more aggressive than [`columnarize_read_payload`]
168/// (which only ever looks at the top-level `"rows"` key): call sites that
169/// build genuinely non-tabular arrays of uniform-shaped objects (a hand
170/// -curated summary list, a route/path) should exclude themselves at the
171/// caller rather than rely on this function to know the difference — see
172/// `ToolRouter::call`'s exclusion list for `orient` / `get_join_path`.
173pub 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            // A `"rows"` key holding a uniform array-of-objects is the
184            // common bare-{"rows": [...]}-payload case (list_extensions,
185            // list_running_queries, ...) — flatten its columnar envelope
186            // into this object directly (top-level "columns"/"rows"),
187            // matching columnarize_read_payload's shape for run_select,
188            // instead of nesting under "rows.rows". Must happen *before*
189            // the generic per-value recursion below, which would otherwise
190            // already have turned it into a plain object by the time this
191            // check runs.
192            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
214/// Redact configured PII columns in row objects. Returns redacted JSON and column names touched.
215pub 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
245/// Redact PII inside a structured read payload (`{ "rows": [...] }` or a bare array).
246pub 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
266/// Detect SQL NULL for any column type without committing to a concrete `FromSql` type.
267enum 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
482/// PostgreSQL `money` is int64 in ten-thousandths of the base currency unit.
483fn 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
489/// Last-resort decoding for unknown or composite Postgres types — never silent null for non-null cells.
490fn 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        // Sibling keys survive the reshape untouched.
520        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        // Mirrors get_ddl's "table" branch shape: a top-level object with
535        // several independent nested row arrays.
536        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        // Different key sets per element — not a result set, must not be
561        // grid-ified (would silently drop/misalign fields).
562        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}