Skip to main content

faucet_core/
zip_columns.rs

1//! Inbuilt `zip_columns` transform (#551): turn a **columnar payload** —
2//! `{ columns: [{name}, …], rows: [[v0, v1, …], …] }` — into one object per row,
3//! keyed by column name.
4//!
5//! Analytics / report APIs (e.g. a query-language `tableData` payload) return results
6//! positionally: a list of column descriptors plus a list of value-arrays. This
7//! transform zips each row against the column names so downstream stages and
8//! sinks see ordinary `{col: value}` records. It is expressible today via the
9//! DuckDB `sql` transform, but a small declarative transform is cleaner and
10//! needs no embedded engine.
11//!
12//! The whole module is gated by `#[cfg(feature = "transform-zip-columns")]` at
13//! the `mod` site in `lib.rs`. It routes through
14//! [`TransformStage::PageFn`](crate::stage::TransformStage) (page-level, 1→0..N,
15//! fallible) so a row whose width doesn't match the column count fails loudly
16//! rather than silently dropping or misaligning fields.
17
18use crate::FaucetError;
19use crate::stage::TransformStage;
20use crate::util::extract_records;
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value};
24use std::sync::Arc;
25
26/// User-facing `zip_columns` config.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
28#[serde(deny_unknown_fields)]
29pub struct ZipColumnsSpec {
30    /// JSONPath to the column **names**. Point it at the name of each column
31    /// descriptor (`columns[*].name`) or at a plain array of strings
32    /// (`columns`). Every matched value must be a string. Set exactly one of
33    /// `columns_path` or `groups`.
34    #[serde(default, skip_serializing_if = "String::is_empty")]
35    pub columns_path: String,
36    /// JSONPath to the **rows**. With `columns_path`, an array of positional
37    /// value-arrays (`rows` or `rows[*]`), each exactly as wide as the column
38    /// list. With `groups`, the row objects (`rows[*]`).
39    pub rows_path: String,
40    /// Column groups (#746): each row object holds several positional cell
41    /// arrays, each named by its own header list (a `runReport`-style
42    /// `dimensionValues` / `metricValues` pair). Every group is zipped on its own and
43    /// the results are merged into one record; two groups naming the same
44    /// column is an error.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub groups: Vec<ColumnGroupSpec>,
47}
48
49/// One positional column group of a [`ZipColumnsSpec`].
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52pub struct ColumnGroupSpec {
53    /// Field (dot path) of each row holding this group's cell array
54    /// (`dimensionValues`). A row without it fails the page.
55    pub from: String,
56    /// JSONPath, evaluated against the **record**, to this group's header list
57    /// (`$.dimensionHeaders[*].name`, or `$.dimensionHeaders[*]` with
58    /// `header_label`).
59    pub header: String,
60    /// Field of each header object to use as the column name, when `header`
61    /// matches objects rather than strings.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub header_label: Option<String>,
64    /// Field (dot path) of each cell holding the value (`value`). Omit when
65    /// the cells are the values themselves. A cell without it yields `null`.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub value: Option<String>,
68}
69
70impl ZipColumnsSpec {
71    /// Validate the spec, returning a reusable [`CompiledZipColumns`].
72    pub fn compile(&self) -> Result<CompiledZipColumns, FaucetError> {
73        CompiledZipColumns::compile(self)
74    }
75
76    /// Compile and wrap as a [`TransformStage::PageFn`] (1→0..N per record,
77    /// fallible on a row/column width mismatch).
78    pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
79        let compiled = self.compile()?;
80        Ok(TransformStage::PageFn(Arc::new(move |page: Vec<Value>| {
81            let mut out = Vec::with_capacity(page.len());
82            for rec in page {
83                out.extend(compiled.apply(&rec)?);
84            }
85            Ok(out)
86        })))
87    }
88}
89
90/// Validated [`ZipColumnsSpec`] — apply per record with [`CompiledZipColumns::apply`].
91#[derive(Debug, Clone)]
92pub struct CompiledZipColumns {
93    columns_path: String,
94    rows_path: String,
95    groups: Vec<CompiledGroup>,
96}
97
98#[derive(Debug, Clone)]
99struct CompiledGroup {
100    from: String,
101    header: String,
102    header_label: Option<String>,
103    value: Option<String>,
104}
105
106impl CompiledZipColumns {
107    fn compile(spec: &ZipColumnsSpec) -> Result<Self, FaucetError> {
108        let has_columns = !spec.columns_path.trim().is_empty();
109        if has_columns != spec.groups.is_empty() {
110            return Err(FaucetError::Config(
111                "zip_columns: set exactly one of `columns_path` or `groups`".into(),
112            ));
113        }
114        if spec.rows_path.trim().is_empty() {
115            return Err(FaucetError::Config(
116                "zip_columns: `rows_path` must not be empty".into(),
117            ));
118        }
119        let blank = |s: &str| s.trim().is_empty();
120        let mut groups = Vec::with_capacity(spec.groups.len());
121        for (i, g) in spec.groups.iter().enumerate() {
122            if blank(&g.from) || blank(&g.header) {
123                return Err(FaucetError::Config(format!(
124                    "zip_columns: group {i} needs a non-empty `from` and `header`"
125                )));
126            }
127            if g.header_label.as_deref().is_some_and(blank) || g.value.as_deref().is_some_and(blank)
128            {
129                return Err(FaucetError::Config(format!(
130                    "zip_columns: group '{}' has an empty `header_label` or `value`",
131                    g.from
132                )));
133            }
134            groups.push(CompiledGroup {
135                from: g.from.trim().to_string(),
136                header: normalize_path(&g.header),
137                header_label: g.header_label.clone(),
138                value: g.value.clone(),
139            });
140        }
141        Ok(Self {
142            columns_path: if has_columns {
143                normalize_path(&spec.columns_path)
144            } else {
145                String::new()
146            },
147            rows_path: normalize_path(&spec.rows_path),
148            groups,
149        })
150    }
151
152    /// Zip one columnar record into one object per row. A record that carries no
153    /// rows produces zero output records; a row whose width differs from the
154    /// column count is a hard error (never silently misaligned).
155    pub fn apply(&self, rec: &Value) -> Result<Vec<Value>, FaucetError> {
156        if !self.groups.is_empty() {
157            return self.apply_groups(rec);
158        }
159        let columns = self.column_names(rec)?;
160        let rows = row_candidates(&extract_records(rec, Some(&self.rows_path))?);
161        let mut out = Vec::with_capacity(rows.len());
162        for (i, row) in rows.into_iter().enumerate() {
163            let Value::Array(values) = row else {
164                return Err(FaucetError::Transform(format!(
165                    "zip_columns: row {i} at `{}` is not an array",
166                    self.rows_path
167                )));
168            };
169            if values.len() != columns.len() {
170                return Err(FaucetError::Transform(format!(
171                    "zip_columns: row {i} has {} value(s) but there are {} column(s)",
172                    values.len(),
173                    columns.len()
174                )));
175            }
176            let obj: Map<String, Value> = columns.iter().cloned().zip(values).collect();
177            out.push(Value::Object(obj));
178        }
179        Ok(out)
180    }
181
182    /// Resolve the column names, requiring every matched value to be a string.
183    fn column_names(&self, rec: &Value) -> Result<Vec<String>, FaucetError> {
184        let matched = extract_records(rec, Some(&self.columns_path))?;
185        // A single match that is itself an array (`columns_path: columns` where
186        // columns is already a string array) is unwrapped to its elements.
187        let candidates = column_candidates(&matched);
188        let mut names = Vec::with_capacity(candidates.len());
189        for c in candidates {
190            match c {
191                Value::String(s) => names.push(s),
192                other => {
193                    return Err(FaucetError::Transform(format!(
194                        "zip_columns: column name at `{}` is not a string: {other}",
195                        self.columns_path
196                    )));
197                }
198            }
199        }
200        if names.is_empty() {
201            return Err(FaucetError::Transform(format!(
202                "zip_columns: `columns_path` `{}` matched no column names",
203                self.columns_path
204            )));
205        }
206        let mut seen = std::collections::HashMap::with_capacity(names.len());
207        for (i, n) in names.iter().enumerate() {
208            if let Some(first) = seen.insert(n.as_str(), i) {
209                return Err(FaucetError::Transform(format!(
210                    "zip_columns: duplicate column '{n}' at positions {first} and {i}"
211                )));
212            }
213        }
214        Ok(names)
215    }
216
217    /// The `groups` form: every group's cells are zipped against its own
218    /// header list and the groups are merged into one record per row.
219    fn apply_groups(&self, rec: &Value) -> Result<Vec<Value>, FaucetError> {
220        let mut headers: Vec<Vec<String>> = Vec::with_capacity(self.groups.len());
221        let mut owner: Map<String, Value> = Map::new();
222        for g in &self.groups {
223            let names = g.header_names(rec)?;
224            for n in &names {
225                if let Some(Value::String(prev)) =
226                    owner.insert(n.clone(), Value::String(g.from.clone()))
227                {
228                    let which = if prev == g.from {
229                        format!("group '{prev}' names it twice")
230                    } else {
231                        format!("groups '{prev}' and '{}' both name it", g.from)
232                    };
233                    return Err(FaucetError::Transform(format!(
234                        "zip_columns: duplicate column '{n}': {which}"
235                    )));
236                }
237            }
238            headers.push(names);
239        }
240        let matched = extract_records(rec, Some(&self.rows_path))?;
241        let rows = match matched.as_slice() {
242            [Value::Array(inner)] => inner.clone(),
243            _ => matched,
244        };
245        let mut out = Vec::with_capacity(rows.len());
246        for (i, row) in rows.iter().enumerate() {
247            if !row.is_object() {
248                return Err(FaucetError::Transform(format!(
249                    "zip_columns: row {i} at `{}` is not an object",
250                    self.rows_path
251                )));
252            }
253            let mut obj = Map::new();
254            for (g, names) in self.groups.iter().zip(&headers) {
255                let Some(Value::Array(cells)) = path_get(row, &g.from) else {
256                    return Err(FaucetError::Transform(format!(
257                        "zip_columns: row {i} has no `{}` array (group '{}')",
258                        g.from, g.from
259                    )));
260                };
261                if cells.len() != names.len() {
262                    return Err(FaucetError::Transform(format!(
263                        "zip_columns: row {i}, group '{}': {} cell(s) but {} header(s)",
264                        g.from,
265                        cells.len(),
266                        names.len()
267                    )));
268                }
269                for (name, cell) in names.iter().zip(cells) {
270                    let v = match &g.value {
271                        Some(field) => path_get(cell, field).cloned().unwrap_or(Value::Null),
272                        None => cell.clone(),
273                    };
274                    obj.insert(name.clone(), v);
275                }
276            }
277            out.push(Value::Object(obj));
278        }
279        Ok(out)
280    }
281}
282
283impl CompiledGroup {
284    fn header_names(&self, rec: &Value) -> Result<Vec<String>, FaucetError> {
285        let matched = extract_records(rec, Some(&self.header))?;
286        let mut names = Vec::new();
287        for h in column_candidates(&matched) {
288            let name = match (&self.header_label, &h) {
289                (Some(label), Value::Object(_)) => path_get(&h, label).cloned(),
290                (None, _) => Some(h.clone()),
291                (Some(_), _) => None,
292            };
293            match name {
294                Some(Value::String(s)) => names.push(s),
295                _ => {
296                    return Err(FaucetError::Transform(format!(
297                        "zip_columns: group '{}': header at `{}` is not a string: {h}",
298                        self.from, self.header
299                    )));
300                }
301            }
302        }
303        Ok(names)
304    }
305}
306
307/// Resolve a dot path (`a.b`) inside a value.
308fn path_get<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
309    path.split('.').try_fold(root, |cur, seg| cur.get(seg))
310}
311
312/// Accept a bare path (`rows`, `columns[*].name`) by rooting it at `$`, while
313/// leaving an already-`$`-rooted expression untouched.
314fn normalize_path(path: &str) -> String {
315    let p = path.trim();
316    if p.starts_with('$') {
317        p.to_string()
318    } else {
319        format!("$.{p}")
320    }
321}
322
323/// Column candidates: a single array match (`columns_path: columns` pointing at
324/// a string array) is unwrapped to its elements; a `columns[*].name`-style match
325/// already yields the names directly.
326fn column_candidates(matched: &[Value]) -> Vec<Value> {
327    match matched {
328        [Value::Array(inner)] => inner.clone(),
329        other => other.to_vec(),
330    }
331}
332
333/// Row candidates: `rows` matches the rows array (one match, an array *of
334/// arrays*) → unwrap to the rows; `rows[*]` yields each row directly. Unwrapping
335/// only when every element is itself an array disambiguates a single-row
336/// `rows[*]` (one array of scalars) from the whole rows container.
337fn row_candidates(matched: &[Value]) -> Vec<Value> {
338    if let [Value::Array(inner)] = matched
339        && inner.iter().all(|v| matches!(v, Value::Array(_)))
340    {
341        return inner.clone();
342    }
343    matched.to_vec()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use serde_json::json;
350
351    fn spec() -> CompiledZipColumns {
352        ZipColumnsSpec {
353            columns_path: "columns[*].name".into(),
354            rows_path: "rows".into(),
355            groups: vec![],
356        }
357        .compile()
358        .unwrap()
359    }
360
361    #[test]
362    fn zips_columns_into_row_objects() {
363        let rec = json!({
364            "columns": [{"name": "day"}, {"name": "sessions"}],
365            "rows": [["2026-01-01", 12], ["2026-01-02", 7]],
366        });
367        let out = spec().apply(&rec).unwrap();
368        assert_eq!(out.len(), 2);
369        assert_eq!(out[0], json!({"day": "2026-01-01", "sessions": 12}));
370        assert_eq!(out[1], json!({"day": "2026-01-02", "sessions": 7}));
371    }
372
373    #[test]
374    fn duplicate_column_names_are_refused() {
375        let rec = json!({
376            "columns": [{"name": "id"}, {"name": "v"}, {"name": "id"}],
377            "rows": [[1, 2, 3]],
378        });
379        let err = spec().apply(&rec).unwrap_err().to_string();
380        assert!(
381            err.contains("duplicate column 'id' at positions 0 and 2"),
382            "{err}"
383        );
384    }
385
386    #[test]
387    fn direct_string_array_columns_and_rows_star() {
388        let compiled = ZipColumnsSpec {
389            columns_path: "columns".into(),
390            rows_path: "rows[*]".into(),
391            groups: vec![],
392        }
393        .compile()
394        .unwrap();
395        let rec = json!({"columns": ["a", "b"], "rows": [[1, 2]]});
396        let out = compiled.apply(&rec).unwrap();
397        assert_eq!(out, vec![json!({"a": 1, "b": 2})]);
398    }
399
400    #[test]
401    fn no_rows_yields_no_records() {
402        let rec = json!({"columns": [{"name": "a"}], "rows": []});
403        assert!(spec().apply(&rec).unwrap().is_empty());
404    }
405
406    #[test]
407    fn width_mismatch_errors_clearly() {
408        let rec = json!({"columns": [{"name": "a"}, {"name": "b"}], "rows": [[1]]});
409        let err = spec().apply(&rec).unwrap_err();
410        let msg = err.to_string();
411        assert!(msg.contains("1 value") && msg.contains("2 column"), "{msg}");
412    }
413
414    #[test]
415    fn non_string_column_name_errors() {
416        let rec = json!({"columns": [{"name": 7}], "rows": [[1]]});
417        assert!(spec().apply(&rec).is_err());
418    }
419
420    #[test]
421    fn empty_paths_rejected_at_compile() {
422        assert!(
423            ZipColumnsSpec {
424                columns_path: "".into(),
425                rows_path: "rows".into(),
426                groups: vec![],
427            }
428            .compile()
429            .is_err()
430        );
431        assert!(
432            ZipColumnsSpec {
433                columns_path: "columns".into(),
434                rows_path: " ".into(),
435                groups: vec![],
436            }
437            .compile()
438            .is_err()
439        );
440    }
441
442    #[test]
443    fn into_stage_is_pagefn_and_flat_maps() {
444        let stage = spec_spec().into_stage().unwrap();
445        match stage {
446            TransformStage::PageFn(f) => {
447                let page = vec![json!({
448                    "columns": [{"name": "a"}],
449                    "rows": [[1], [2]],
450                })];
451                let out = f(page).unwrap();
452                assert_eq!(out, vec![json!({"a": 1}), json!({"a": 2})]);
453            }
454            other => panic!("expected PageFn, got {other:?}"),
455        }
456    }
457
458    fn spec_spec() -> ZipColumnsSpec {
459        ZipColumnsSpec {
460            columns_path: "columns[*].name".into(),
461            rows_path: "rows".into(),
462            groups: vec![],
463        }
464    }
465
466    fn grouped_report() -> ZipColumnsSpec {
467        serde_json::from_value(json!({
468            "rows_path": "$.rows[*]",
469            "groups": [
470                {"from": "dimensionValues", "header": "$.dimensionHeaders[*].name", "value": "value"},
471                {"from": "metricValues", "header": "$.metricHeaders[*]", "header_label": "name", "value": "value"}
472            ]
473        }))
474        .unwrap()
475    }
476
477    fn report() -> Value {
478        json!({
479            "dimensionHeaders": [{"name": "date"}, {"name": "country"}],
480            "metricHeaders": [{"name": "sessions", "type": "TYPE_INTEGER"}, {"name": "bounceRate", "type": "TYPE_FLOAT"}],
481            "rows": [
482                {"dimensionValues": [{"value": "20260901"}, {"value": "DE"}],
483                 "metricValues": [{"value": "1204"}, {"value": "0.41"}]},
484                {"dimensionValues": [{"value": "20260902"}, {}],
485                 "metricValues": [{"value": "9"}, null]}
486            ]
487        })
488    }
489
490    #[test]
491    fn groups_zipped_report_rows() {
492        let out = grouped_report()
493            .compile()
494            .unwrap()
495            .apply(&report())
496            .unwrap();
497        assert_eq!(
498            out,
499            vec![
500                json!({"date": "20260901", "country": "DE", "sessions": "1204", "bounceRate": "0.41"}),
501                json!({"date": "20260902", "country": null, "sessions": "9", "bounceRate": null}),
502            ]
503        );
504    }
505
506    #[test]
507    fn groups_with_scalar_cells_and_a_rows_container() {
508        let spec: ZipColumnsSpec = serde_json::from_value(json!({
509            "rows_path": "rows",
510            "groups": [{"from": "a.cells", "header": "names"}]
511        }))
512        .unwrap();
513        let rec = json!({"names": ["x", "y"], "rows": [{"a": {"cells": [1, 2]}}]});
514        let out = spec.compile().unwrap().apply(&rec).unwrap();
515        assert_eq!(out, vec![json!({"x": 1, "y": 2})]);
516    }
517
518    #[test]
519    fn groups_edge_cases() {
520        let c = grouped_report().compile().unwrap();
521        // An empty report (the API omits `rows`) yields nothing.
522        assert!(
523            c.apply(&json!({"dimensionHeaders": [], "metricHeaders": []}))
524                .unwrap()
525                .is_empty()
526        );
527        // Empty headers with empty cells yield an empty record.
528        let empty = json!({"dimensionHeaders": [], "metricHeaders": [],
529            "rows": [{"dimensionValues": [], "metricValues": []}]});
530        assert_eq!(c.apply(&empty).unwrap(), vec![json!({})]);
531
532        let err = |rec: Value| c.apply(&rec).unwrap_err().to_string();
533        let mut r = report();
534        r["rows"][1]["metricValues"] = json!([{"value": "1"}]);
535        let e = err(r);
536        assert!(
537            e.contains("row 1, group 'metricValues'") && e.contains("1 cell(s) but 2 header(s)"),
538            "{e}"
539        );
540
541        let mut r = report();
542        r["rows"][0].as_object_mut().unwrap().remove("metricValues");
543        let e = err(r);
544        assert!(e.contains("row 0 has no `metricValues` array"), "{e}");
545
546        let mut r = report();
547        r["metricHeaders"][0]["name"] = json!("date");
548        let e = err(r);
549        assert!(
550            e.contains("duplicate column 'date'")
551                && e.contains("'dimensionValues' and 'metricValues'"),
552            "{e}"
553        );
554
555        let mut r = report();
556        r["dimensionHeaders"][1]["name"] = json!("date");
557        assert!(err(r).contains("group 'dimensionValues' names it twice"));
558
559        let mut r = report();
560        r["dimensionHeaders"][0]["name"] = json!(7);
561        assert!(err(r).contains("group 'dimensionValues': header"));
562
563        let mut r = report();
564        r["metricHeaders"][0] = json!("sessions");
565        assert!(err(r).contains("group 'metricValues': header"));
566
567        let mut r = report();
568        r["rows"][0] = json!([1]);
569        assert!(err(r).contains("row 0 at `$.rows[*]` is not an object"));
570    }
571
572    #[test]
573    fn groups_compile_validation() {
574        let bad = |v: Value| {
575            serde_json::from_value::<ZipColumnsSpec>(v)
576                .unwrap()
577                .compile()
578                .unwrap_err()
579                .to_string()
580        };
581        let g = json!({"from": "a", "header": "h"});
582        assert!(bad(json!({"rows_path": "r"})).contains("exactly one"));
583        assert!(
584            bad(json!({"rows_path": "r", "columns_path": "c", "groups": [g]}))
585                .contains("exactly one")
586        );
587        assert!(
588            bad(json!({"rows_path": "r", "groups": [{"from": " ", "header": "h"}]}))
589                .contains("group 0")
590        );
591        assert!(
592            bad(json!({"rows_path": "r", "groups": [{"from": "a", "header": "h", "value": ""}]}))
593                .contains("group 'a'")
594        );
595        assert!(
596            bad(json!({"rows_path": "r", "groups": [{"from": "a", "header": "h", "header_label": " "}]}))
597                .contains("group 'a'")
598        );
599        assert!(bad(json!({"rows_path": " ", "groups": [g]})).contains("rows_path"));
600        let s = grouped_report();
601        assert_eq!(
602            serde_json::from_value::<ZipColumnsSpec>(serde_json::to_value(&s).unwrap()).unwrap(),
603            s
604        );
605    }
606}