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. Shopify's ShopifyQL `tableData`) 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.
33    pub columns_path: String,
34    /// JSONPath to the **rows** — an array of positional value-arrays
35    /// (`rows` or `rows[*]`). Each row must have exactly as many values as
36    /// there are columns.
37    pub rows_path: String,
38}
39
40impl ZipColumnsSpec {
41    /// Validate the spec, returning a reusable [`CompiledZipColumns`].
42    pub fn compile(&self) -> Result<CompiledZipColumns, FaucetError> {
43        CompiledZipColumns::compile(self)
44    }
45
46    /// Compile and wrap as a [`TransformStage::PageFn`] (1→0..N per record,
47    /// fallible on a row/column width mismatch).
48    pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
49        let compiled = self.compile()?;
50        Ok(TransformStage::PageFn(Arc::new(move |page: Vec<Value>| {
51            let mut out = Vec::with_capacity(page.len());
52            for rec in page {
53                out.extend(compiled.apply(&rec)?);
54            }
55            Ok(out)
56        })))
57    }
58}
59
60/// Validated [`ZipColumnsSpec`] — apply per record with [`CompiledZipColumns::apply`].
61#[derive(Debug, Clone)]
62pub struct CompiledZipColumns {
63    columns_path: String,
64    rows_path: String,
65}
66
67impl CompiledZipColumns {
68    fn compile(spec: &ZipColumnsSpec) -> Result<Self, FaucetError> {
69        if spec.columns_path.trim().is_empty() {
70            return Err(FaucetError::Config(
71                "zip_columns: `columns_path` must not be empty".into(),
72            ));
73        }
74        if spec.rows_path.trim().is_empty() {
75            return Err(FaucetError::Config(
76                "zip_columns: `rows_path` must not be empty".into(),
77            ));
78        }
79        Ok(Self {
80            columns_path: normalize_path(&spec.columns_path),
81            rows_path: normalize_path(&spec.rows_path),
82        })
83    }
84
85    /// Zip one columnar record into one object per row. A record that carries no
86    /// rows produces zero output records; a row whose width differs from the
87    /// column count is a hard error (never silently misaligned).
88    pub fn apply(&self, rec: &Value) -> Result<Vec<Value>, FaucetError> {
89        let columns = self.column_names(rec)?;
90        let rows = row_candidates(&extract_records(rec, Some(&self.rows_path))?);
91        let mut out = Vec::with_capacity(rows.len());
92        for (i, row) in rows.into_iter().enumerate() {
93            let Value::Array(values) = row else {
94                return Err(FaucetError::Transform(format!(
95                    "zip_columns: row {i} at `{}` is not an array",
96                    self.rows_path
97                )));
98            };
99            if values.len() != columns.len() {
100                return Err(FaucetError::Transform(format!(
101                    "zip_columns: row {i} has {} value(s) but there are {} column(s)",
102                    values.len(),
103                    columns.len()
104                )));
105            }
106            let obj: Map<String, Value> = columns.iter().cloned().zip(values).collect();
107            out.push(Value::Object(obj));
108        }
109        Ok(out)
110    }
111
112    /// Resolve the column names, requiring every matched value to be a string.
113    fn column_names(&self, rec: &Value) -> Result<Vec<String>, FaucetError> {
114        let matched = extract_records(rec, Some(&self.columns_path))?;
115        // A single match that is itself an array (`columns_path: columns` where
116        // columns is already a string array) is unwrapped to its elements.
117        let candidates = column_candidates(&matched);
118        let mut names = Vec::with_capacity(candidates.len());
119        for c in candidates {
120            match c {
121                Value::String(s) => names.push(s),
122                other => {
123                    return Err(FaucetError::Transform(format!(
124                        "zip_columns: column name at `{}` is not a string: {other}",
125                        self.columns_path
126                    )));
127                }
128            }
129        }
130        if names.is_empty() {
131            return Err(FaucetError::Transform(format!(
132                "zip_columns: `columns_path` `{}` matched no column names",
133                self.columns_path
134            )));
135        }
136        Ok(names)
137    }
138}
139
140/// Accept a bare path (`rows`, `columns[*].name`) by rooting it at `$`, while
141/// leaving an already-`$`-rooted expression untouched.
142fn normalize_path(path: &str) -> String {
143    let p = path.trim();
144    if p.starts_with('$') {
145        p.to_string()
146    } else {
147        format!("$.{p}")
148    }
149}
150
151/// Column candidates: a single array match (`columns_path: columns` pointing at
152/// a string array) is unwrapped to its elements; a `columns[*].name`-style match
153/// already yields the names directly.
154fn column_candidates(matched: &[Value]) -> Vec<Value> {
155    match matched {
156        [Value::Array(inner)] => inner.clone(),
157        other => other.to_vec(),
158    }
159}
160
161/// Row candidates: `rows` matches the rows array (one match, an array *of
162/// arrays*) → unwrap to the rows; `rows[*]` yields each row directly. Unwrapping
163/// only when every element is itself an array disambiguates a single-row
164/// `rows[*]` (one array of scalars) from the whole rows container.
165fn row_candidates(matched: &[Value]) -> Vec<Value> {
166    if let [Value::Array(inner)] = matched
167        && inner.iter().all(|v| matches!(v, Value::Array(_)))
168    {
169        return inner.clone();
170    }
171    matched.to_vec()
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use serde_json::json;
178
179    fn spec() -> CompiledZipColumns {
180        ZipColumnsSpec {
181            columns_path: "columns[*].name".into(),
182            rows_path: "rows".into(),
183        }
184        .compile()
185        .unwrap()
186    }
187
188    #[test]
189    fn zips_columns_into_row_objects() {
190        let rec = json!({
191            "columns": [{"name": "day"}, {"name": "sessions"}],
192            "rows": [["2026-01-01", 12], ["2026-01-02", 7]],
193        });
194        let out = spec().apply(&rec).unwrap();
195        assert_eq!(out.len(), 2);
196        assert_eq!(out[0], json!({"day": "2026-01-01", "sessions": 12}));
197        assert_eq!(out[1], json!({"day": "2026-01-02", "sessions": 7}));
198    }
199
200    #[test]
201    fn direct_string_array_columns_and_rows_star() {
202        let compiled = ZipColumnsSpec {
203            columns_path: "columns".into(),
204            rows_path: "rows[*]".into(),
205        }
206        .compile()
207        .unwrap();
208        let rec = json!({"columns": ["a", "b"], "rows": [[1, 2]]});
209        let out = compiled.apply(&rec).unwrap();
210        assert_eq!(out, vec![json!({"a": 1, "b": 2})]);
211    }
212
213    #[test]
214    fn no_rows_yields_no_records() {
215        let rec = json!({"columns": [{"name": "a"}], "rows": []});
216        assert!(spec().apply(&rec).unwrap().is_empty());
217    }
218
219    #[test]
220    fn width_mismatch_errors_clearly() {
221        let rec = json!({"columns": [{"name": "a"}, {"name": "b"}], "rows": [[1]]});
222        let err = spec().apply(&rec).unwrap_err();
223        let msg = err.to_string();
224        assert!(msg.contains("1 value") && msg.contains("2 column"), "{msg}");
225    }
226
227    #[test]
228    fn non_string_column_name_errors() {
229        let rec = json!({"columns": [{"name": 7}], "rows": [[1]]});
230        assert!(spec().apply(&rec).is_err());
231    }
232
233    #[test]
234    fn empty_paths_rejected_at_compile() {
235        assert!(
236            ZipColumnsSpec {
237                columns_path: "".into(),
238                rows_path: "rows".into()
239            }
240            .compile()
241            .is_err()
242        );
243        assert!(
244            ZipColumnsSpec {
245                columns_path: "columns".into(),
246                rows_path: " ".into()
247            }
248            .compile()
249            .is_err()
250        );
251    }
252
253    #[test]
254    fn into_stage_is_pagefn_and_flat_maps() {
255        let stage = spec_spec().into_stage().unwrap();
256        match stage {
257            TransformStage::PageFn(f) => {
258                let page = vec![json!({
259                    "columns": [{"name": "a"}],
260                    "rows": [[1], [2]],
261                })];
262                let out = f(page).unwrap();
263                assert_eq!(out, vec![json!({"a": 1}), json!({"a": 2})]);
264            }
265            other => panic!("expected PageFn, got {other:?}"),
266        }
267    }
268
269    fn spec_spec() -> ZipColumnsSpec {
270        ZipColumnsSpec {
271            columns_path: "columns[*].name".into(),
272            rows_path: "rows".into(),
273        }
274    }
275}