1use 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 #[serde(default, skip_serializing_if = "String::is_empty")]
35 pub columns_path: String,
36 pub rows_path: String,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub groups: Vec<ColumnGroupSpec>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52pub struct ColumnGroupSpec {
53 pub from: String,
56 pub header: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub header_label: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub value: Option<String>,
68}
69
70impl ZipColumnsSpec {
71 pub fn compile(&self) -> Result<CompiledZipColumns, FaucetError> {
73 CompiledZipColumns::compile(self)
74 }
75
76 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#[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 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 fn column_names(&self, rec: &Value) -> Result<Vec<String>, FaucetError> {
184 let matched = extract_records(rec, Some(&self.columns_path))?;
185 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 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
307fn path_get<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
309 path.split('.').try_fold(root, |cur, seg| cur.get(seg))
310}
311
312fn 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
323fn column_candidates(matched: &[Value]) -> Vec<Value> {
327 match matched {
328 [Value::Array(inner)] => inner.clone(),
329 other => other.to_vec(),
330 }
331}
332
333fn 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 assert!(
523 c.apply(&json!({"dimensionHeaders": [], "metricHeaders": []}))
524 .unwrap()
525 .is_empty()
526 );
527 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}