faucet_core/
zip_columns.rs1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
28#[serde(deny_unknown_fields)]
29pub struct ZipColumnsSpec {
30 pub columns_path: String,
34 pub rows_path: String,
38}
39
40impl ZipColumnsSpec {
41 pub fn compile(&self) -> Result<CompiledZipColumns, FaucetError> {
43 CompiledZipColumns::compile(self)
44 }
45
46 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#[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 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 fn column_names(&self, rec: &Value) -> Result<Vec<String>, FaucetError> {
114 let matched = extract_records(rec, Some(&self.columns_path))?;
115 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
140fn 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
151fn column_candidates(matched: &[Value]) -> Vec<Value> {
155 match matched {
156 [Value::Array(inner)] => inner.clone(),
157 other => other.to_vec(),
158 }
159}
160
161fn 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}