1use crate::FaucetError;
18use crate::stage::TransformStage;
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21use std::sync::Arc;
22
23pub const DEFAULT_MAX_DEPTH: usize = 64;
26
27fn default_max_depth() -> usize {
28 DEFAULT_MAX_DEPTH
29}
30fn default_leaf() -> String {
31 "has_no_children".to_owned()
32}
33fn default_value_field() -> String {
34 "value".to_owned()
35}
36fn default_path_sep() -> String {
37 " > ".to_owned()
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct ColumnsSpec {
44 pub from: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub header: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub header_label: Option<String>,
55 #[serde(default = "default_value_field")]
58 pub value: String,
59}
60
61impl Default for ColumnsSpec {
62 fn default() -> Self {
63 Self {
64 from: String::new(),
65 header: None,
66 header_label: None,
67 value: default_value_field(),
68 }
69 }
70}
71
72impl ColumnsSpec {
73 fn is_unset(&self) -> bool {
74 self.from.trim().is_empty()
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(deny_unknown_fields)]
81pub struct AncestorsSpec {
82 pub field: String,
85 #[serde(default, rename = "as")]
88 pub as_names: Vec<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
95#[serde(deny_unknown_fields)]
96pub struct TreeFlattenSpec {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub root: Option<String>,
101 pub children: String,
104 #[serde(default = "default_leaf")]
107 pub leaf: String,
108 #[serde(default, skip_serializing_if = "ColumnsSpec::is_unset")]
111 pub columns: ColumnsSpec,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub groups: Vec<ColumnsSpec>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub ancestors: Option<AncestorsSpec>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub path_as: Option<String>,
125 #[serde(default = "default_path_sep")]
127 pub path_sep: String,
128 #[serde(default)]
130 pub drop_empty: bool,
131 #[serde(default)]
133 pub emit_group_rows: bool,
134 #[serde(default = "default_max_depth")]
137 pub max_depth: usize,
138}
139
140impl TreeFlattenSpec {
141 pub fn compile(&self) -> Result<CompiledTreeFlatten, FaucetError> {
143 CompiledTreeFlatten::compile(self)
144 }
145
146 pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
152 let compiled = self.compile()?;
153 Ok(TransformStage::PageFn(Arc::new(move |page: Vec<Value>| {
154 let mut out = Vec::with_capacity(page.len());
155 for rec in page {
156 out.extend(compiled.try_apply(rec)?);
157 }
158 Ok(out)
159 })))
160 }
161}
162
163#[derive(Debug, Clone, PartialEq)]
164enum LeafMode {
165 NoChildren,
166 HasField(String),
167}
168
169#[derive(Debug, Clone)]
171pub struct CompiledTreeFlatten {
172 spec: TreeFlattenSpec,
173 leaf_mode: LeafMode,
174}
175
176impl CompiledTreeFlatten {
177 fn compile(spec: &TreeFlattenSpec) -> Result<Self, FaucetError> {
178 if spec.children.trim().is_empty() {
179 return Err(FaucetError::Transform(
180 "tree_flatten: `children` must be non-empty".to_owned(),
181 ));
182 }
183 if spec.groups.is_empty() && spec.columns.is_unset() {
184 return Err(FaucetError::Transform(
185 "tree_flatten: `columns.from` must be non-empty".to_owned(),
186 ));
187 }
188 if !spec.groups.is_empty() && !spec.columns.is_unset() {
189 return Err(FaucetError::Transform(
190 "tree_flatten: set exactly one of `columns` or `groups`".to_owned(),
191 ));
192 }
193 for (i, g) in spec.groups.iter().enumerate() {
194 if g.is_unset() || g.header.as_deref().is_none_or(|h| h.trim().is_empty()) {
195 return Err(FaucetError::Transform(format!(
196 "tree_flatten: group {i} needs a non-empty `from` and `header`"
197 )));
198 }
199 }
200 if spec.max_depth == 0 {
201 return Err(FaucetError::Transform(
202 "tree_flatten: `max_depth` must be greater than zero".to_owned(),
203 ));
204 }
205 let leaf_mode = if spec.leaf == "has_no_children" {
206 LeafMode::NoChildren
207 } else if let Some(field) = spec.leaf.strip_prefix("has_field:") {
208 if field.trim().is_empty() {
209 return Err(FaucetError::Transform(
210 "tree_flatten: `leaf: has_field:<name>` requires a field name".to_owned(),
211 ));
212 }
213 LeafMode::HasField(field.to_owned())
214 } else {
215 return Err(FaucetError::Transform(format!(
216 "tree_flatten: `leaf` must be `has_no_children` or `has_field:<name>`, got '{}'",
217 spec.leaf
218 )));
219 };
220 Ok(Self {
221 spec: spec.clone(),
222 leaf_mode,
223 })
224 }
225
226 pub fn apply(&self, rec: Value) -> Vec<Value> {
232 match self.flatten(&rec) {
233 Ok(Some(rows)) => rows,
234 Ok(None) => vec![rec],
235 Err(e) => {
236 tracing::error!(error = %e, "tree_flatten: record passed through unflattened");
237 vec![rec]
238 }
239 }
240 }
241
242 pub fn try_apply(&self, rec: Value) -> Result<Vec<Value>, FaucetError> {
247 Ok(self.flatten(&rec)?.unwrap_or_else(|| vec![rec]))
248 }
249
250 fn flatten(&self, rec: &Value) -> Result<Option<Vec<Value>>, FaucetError> {
252 if !rec.is_object() {
253 return Ok(None);
254 }
255 let sources = self.column_sources(rec)?;
256
257 let roots: Vec<&Value> = match &self.spec.root {
261 Some(path) => match path_get(rec, path) {
262 Some(Value::Array(a)) => a.iter().collect(),
263 Some(v) => vec![v],
264 None => return Ok(None),
265 },
266 None => vec![rec],
267 };
268
269 let mut out: Vec<Value> = Vec::new();
270 let mut ancestors: Vec<Value> = Vec::new();
271 for node in roots {
272 self.walk(node, &mut ancestors, 0, &sources, &mut out)?;
273 }
274 Ok(Some(out))
275 }
276
277 fn column_sources<'s>(
280 &'s self,
281 rec: &Value,
282 ) -> Result<Vec<(&'s ColumnsSpec, Vec<String>)>, FaucetError> {
283 if self.spec.groups.is_empty() {
284 let labels = self
285 .spec
286 .columns
287 .header
288 .as_deref()
289 .and_then(|h| path_get(rec, h))
290 .and_then(Value::as_array)
291 .map(|arr| {
292 arr.iter()
293 .map(|el| header_label(&self.spec.columns, el))
294 .collect()
295 })
296 .unwrap_or_default();
297 return Ok(vec![(&self.spec.columns, labels)]);
298 }
299 let mut owner: Map<String, Value> = Map::new();
300 let mut out = Vec::with_capacity(self.spec.groups.len());
301 for g in &self.spec.groups {
302 let header = g.header.as_deref().unwrap_or_default();
303 let labels: Vec<String> = path_get(rec, header)
304 .and_then(Value::as_array)
305 .ok_or_else(|| {
306 FaucetError::Transform(format!(
307 "tree_flatten: group '{}': header `{header}` is not an array",
308 g.from
309 ))
310 })?
311 .iter()
312 .map(|el| header_label(g, el))
313 .collect();
314 for n in &labels {
315 if let Some(Value::String(prev)) =
316 owner.insert(n.clone(), Value::String(g.from.clone()))
317 {
318 let which = if prev == g.from {
319 format!("group '{prev}' names it twice")
320 } else {
321 format!("groups '{prev}' and '{}' both name it", g.from)
322 };
323 return Err(FaucetError::Transform(format!(
324 "tree_flatten: duplicate column '{n}': {which}"
325 )));
326 }
327 }
328 out.push((g, labels));
329 }
330 Ok(out)
331 }
332
333 fn is_leaf(&self, node: &Value, has_children: bool) -> bool {
334 match &self.leaf_mode {
335 LeafMode::NoChildren => !has_children,
336 LeafMode::HasField(f) => node.get(f).is_some(),
337 }
338 }
339
340 #[allow(clippy::too_many_arguments)]
341 fn walk(
342 &self,
343 node: &Value,
344 ancestors: &mut Vec<Value>,
345 depth: usize,
346 sources: &[(&ColumnsSpec, Vec<String>)],
347 out: &mut Vec<Value>,
348 ) -> Result<(), FaucetError> {
349 if depth >= self.spec.max_depth {
350 return Err(FaucetError::Transform(format!(
352 "tree_flatten: the tree is nested deeper than max_depth ({}) — raise \
353 `max_depth` if the report is genuinely that deep",
354 self.spec.max_depth
355 )));
356 }
357 let children = path_get(node, &self.spec.children).and_then(Value::as_array);
358 let has_children = children.is_some_and(|c| !c.is_empty());
359 let leaf = self.is_leaf(node, has_children);
360 let group_row =
361 self.spec.emit_group_rows && sources.iter().any(|(c, _)| node_has_cells(node, &c.from));
362
363 if (leaf || group_row)
364 && let Some(row) = self.emit_row(node, ancestors, sources, out.len())?
365 {
366 out.push(row);
367 }
368
369 if let Some(children) = children.filter(|c| !c.is_empty()) {
370 let label = self
372 .spec
373 .ancestors
374 .as_ref()
375 .and_then(|a| path_get(node, &a.field).cloned())
376 .unwrap_or(Value::Null);
377 ancestors.push(label);
378 for child in children {
379 self.walk(child, ancestors, depth + 1, sources, out)?;
380 }
381 ancestors.pop();
382 }
383 Ok(())
384 }
385
386 fn emit_row(
387 &self,
388 node: &Value,
389 ancestors: &[Value],
390 sources: &[(&ColumnsSpec, Vec<String>)],
391 row_index: usize,
392 ) -> Result<Option<Value>, FaucetError> {
393 let mut row = Map::new();
394
395 if let Some(anc) = &self.spec.ancestors {
397 for (i, label) in ancestors.iter().enumerate() {
398 let name = anc
399 .as_names
400 .get(i)
401 .cloned()
402 .unwrap_or_else(|| format!("ancestor_{}", i + 1));
403 row.insert(name, label.clone());
404 }
405 }
406 if let Some(path_col) = &self.spec.path_as {
408 let joined = ancestors
409 .iter()
410 .map(scalar_string)
411 .collect::<Vec<_>>()
412 .join(&self.spec.path_sep);
413 row.insert(path_col.clone(), Value::String(joined));
414 }
415
416 let strict = !self.spec.groups.is_empty();
418 let mut all_empty = true;
419 for (spec, labels) in sources {
420 let cells = path_get(node, &spec.from).and_then(Value::as_array);
421 if strict {
422 let Some(cells) = cells else {
423 return Err(FaucetError::Transform(format!(
424 "tree_flatten: row {row_index} has no `{}` array (group '{}')",
425 spec.from, spec.from
426 )));
427 };
428 if cells.len() != labels.len() {
429 return Err(FaucetError::Transform(format!(
430 "tree_flatten: row {row_index}, group '{}': {} cell(s) but {} header(s)",
431 spec.from,
432 cells.len(),
433 labels.len()
434 )));
435 }
436 }
437 for (i, cell) in cells.into_iter().flatten().enumerate() {
438 let value = path_get(cell, &spec.value)
439 .cloned()
440 .unwrap_or_else(|| cell.clone());
441 if !is_empty_value(&value) {
442 all_empty = false;
443 }
444 let name = labels
445 .get(i)
446 .cloned()
447 .filter(|s| !s.is_empty())
448 .unwrap_or_else(|| format!("col_{i}"));
449 if row.contains_key(&name) {
450 return Err(FaucetError::Transform(format!(
451 "tree_flatten: row {row_index}: duplicate column '{name}' (header \
452 labels repeat, or collide with an ancestor/path column)"
453 )));
454 }
455 row.insert(name, value);
456 }
457 }
458
459 if self.spec.drop_empty && all_empty {
460 return Ok(None);
461 }
462 Ok(Some(Value::Object(row)))
463 }
464}
465
466fn header_label(spec: &ColumnsSpec, el: &Value) -> String {
467 if let Some(field) = &spec.header_label
468 && let Some(v) = path_get(el, field)
469 {
470 return scalar_string(v);
471 }
472 scalar_string(el)
473}
474
475fn node_has_cells(node: &Value, from: &str) -> bool {
476 path_get(node, from)
477 .and_then(Value::as_array)
478 .is_some_and(|a| !a.is_empty())
479}
480
481fn is_empty_value(v: &Value) -> bool {
482 match v {
483 Value::Null => true,
484 Value::String(s) => s.is_empty(),
485 _ => false,
486 }
487}
488
489fn scalar_string(v: &Value) -> String {
491 match v {
492 Value::String(s) => s.clone(),
493 Value::Null => String::new(),
494 Value::Bool(b) => b.to_string(),
495 Value::Number(n) => n.to_string(),
496 other => crate::util::canonical_json(other),
497 }
498}
499
500fn path_get<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
505 let mut cur = root;
506 let mut rest = path.trim();
507 rest = rest.strip_prefix('$').unwrap_or(rest);
508 rest = rest.strip_prefix('.').unwrap_or(rest);
509 while !rest.is_empty() {
510 if let Some(after) = rest.strip_prefix('[') {
511 let close = after.find(']')?;
513 let idx: usize = after[..close].trim().parse().ok()?;
514 cur = cur.as_array()?.get(idx)?;
515 rest = &after[close + 1..];
516 rest = rest.strip_prefix('.').unwrap_or(rest);
517 } else {
518 let end = rest.find(['.', '[']).unwrap_or(rest.len());
520 let key = &rest[..end];
521 if key.is_empty() {
522 return None;
523 }
524 cur = cur.get(key)?;
525 rest = &rest[end..];
526 rest = rest.strip_prefix('.').unwrap_or(rest);
527 }
528 }
529 Some(cur)
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use serde_json::json;
536
537 fn login_then_id() -> Value {
539 let mut m = serde_json::Map::new();
540 m.insert("login".into(), json!("o"));
541 m.insert("id".into(), json!(2));
542 Value::Object(m)
543 }
544
545 #[test]
546 fn scalar_string_sorts_nested_object_keys() {
547 assert_eq!(scalar_string(&login_then_id()), r#"{"id":2,"login":"o"}"#);
548 }
549
550 fn spec() -> TreeFlattenSpec {
551 TreeFlattenSpec {
552 root: Some("Rows.Row".to_owned()),
553 children: "Rows.Row".to_owned(),
554 leaf: "has_no_children".to_owned(),
555 columns: ColumnsSpec {
556 from: "ColData".to_owned(),
557 header: Some("Columns.Column".to_owned()),
558 header_label: Some("ColTitle".to_owned()),
559 value: "value".to_owned(),
560 },
561 groups: vec![],
562 ancestors: Some(AncestorsSpec {
563 field: "Header.ColData[0].value".to_owned(),
564 as_names: vec!["section".to_owned(), "subsection".to_owned()],
565 }),
566 path_as: Some("group_path".to_owned()),
567 path_sep: " > ".to_owned(),
568 drop_empty: false,
569 emit_group_rows: false,
570 max_depth: DEFAULT_MAX_DEPTH,
571 }
572 }
573
574 fn nested_rows_report() -> Value {
577 json!({
578 "Columns": { "Column": [ {"ColTitle": ""}, {"ColTitle": "Jan 2024"}, {"ColTitle": "Feb 2024"} ] },
579 "Rows": { "Row": [
580 {
581 "Header": { "ColData": [ {"value": "Income"} ] },
582 "Rows": { "Row": [
583 { "ColData": [ {"value": "Sales"}, {"value": "100"}, {"value": "120"} ] },
584 { "ColData": [ {"value": "Services"}, {"value": "50"}, {"value": "60"} ] }
585 ] }
586 }
587 ] }
588 })
589 }
590
591 #[test]
592 fn flattens_nested_rows_report_to_leaf_rows() {
593 let out = spec().compile().unwrap().apply(nested_rows_report());
594 assert_eq!(out.len(), 2);
595 assert_eq!(out[0]["section"], json!("Income"));
596 assert_eq!(out[0]["group_path"], json!("Income"));
597 assert_eq!(out[0]["col_0"], json!("Sales"));
601 assert_eq!(out[0]["Jan 2024"], json!("100"));
602 assert_eq!(out[0]["Feb 2024"], json!("120"));
603 assert_eq!(out[1]["col_0"], json!("Services"));
604 assert_eq!(out[1]["Feb 2024"], json!("60"));
605 }
606
607 #[test]
608 fn uneven_depth_names_extra_levels_and_leaves_missing_null() {
609 let report = json!({
611 "Rows": { "Row": [
612 {
613 "Header": { "ColData": [ {"value": "Income"} ] },
614 "Rows": { "Row": [
615 {
616 "Header": { "ColData": [ {"value": "Domestic"} ] },
617 "Rows": { "Row": [
618 { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
619 ] }
620 }
621 ] }
622 },
623 { "ColData": [ {"value": "Other"}, {"value": "5"} ] }
624 ] }
625 });
626 let mut s = spec();
627 s.columns.header = None;
628 let out = s.compile().unwrap().apply(report);
629 assert_eq!(out.len(), 2);
630 assert_eq!(out[0]["section"], json!("Income"));
632 assert_eq!(out[0]["subsection"], json!("Domestic"));
633 assert_eq!(out[0]["col_0"], json!("Sales"));
634 assert!(out[1].get("section").is_none());
636 assert_eq!(out[1]["col_0"], json!("Other"));
637 }
638
639 #[test]
640 fn header_cell_length_mismatch_zips_to_shorter() {
641 let mut s = spec();
642 s.ancestors = None;
643 s.root = None;
644 s.children = "children".to_owned();
645 let report = json!({
646 "Columns": { "Column": [ {"ColTitle": "A"}, {"ColTitle": "B"} ] },
647 "ColData": [ {"value": "x"}, {"value": "y"}, {"value": "z"} ]
648 });
649 let out = s.compile().unwrap().apply(report);
650 assert_eq!(out.len(), 1);
651 assert_eq!(out[0]["A"], json!("x"));
652 assert_eq!(out[0]["B"], json!("y"));
653 assert_eq!(out[0]["col_2"], json!("z"));
655 }
656
657 #[test]
658 fn leaf_has_field_mode() {
659 let mut s = spec();
660 s.leaf = "has_field:ColData".to_owned();
661 s.emit_group_rows = false;
662 let report = json!({
664 "Rows": { "Row": [
665 {
666 "Header": { "ColData": [ {"value": "Total"} ] },
667 "ColData": [ {"value": "Total"}, {"value": "9"} ],
668 "Rows": { "Row": [ { "ColData": [ {"value": "x"}, {"value": "1"} ] } ] }
669 }
670 ] }
671 });
672 s.columns.header = None;
673 let out = s.compile().unwrap().apply(report);
674 assert_eq!(out.len(), 2);
676 assert_eq!(out[0]["col_0"], json!("Total"));
677 }
678
679 #[test]
680 fn emit_group_rows_includes_subtotals() {
681 let mut s = spec();
682 s.emit_group_rows = true;
683 s.columns.header = None;
684 let report = json!({
685 "Rows": { "Row": [
686 {
687 "Header": { "ColData": [ {"value": "Income"} ] },
688 "ColData": [ {"value": "Income total"}, {"value": "150"} ],
689 "Rows": { "Row": [
690 { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
691 ] }
692 }
693 ] }
694 });
695 let out = s.compile().unwrap().apply(report);
696 assert_eq!(out.len(), 2);
698 assert_eq!(out[0]["col_0"], json!("Income total"));
699 assert_eq!(out[1]["col_0"], json!("Sales"));
700 }
701
702 #[test]
703 fn drop_empty_skips_all_empty_leaves() {
704 let mut s = spec();
705 s.drop_empty = true;
706 s.columns.header = None;
707 s.ancestors = None;
708 s.root = None;
709 s.children = "children".to_owned();
710 let report = json!({ "ColData": [ {"value": ""}, {"value": null} ] });
711 let out = s.compile().unwrap().apply(report);
712 assert!(out.is_empty(), "an all-empty leaf is dropped");
713 }
714
715 #[test]
716 fn max_depth_guard_fails_instead_of_truncating() {
717 let mut node = json!({ "ColData": [ {"value": "leaf"} ] });
719 for _ in 0..10 {
720 node = json!({ "Header": {"ColData":[{"value":"g"}]}, "children": [node] });
721 }
722 let mut s = spec();
723 s.root = None;
724 s.children = "children".to_owned();
725 s.columns.header = None;
726 s.ancestors = None;
727 s.max_depth = 3;
728 let err = s.compile().unwrap().try_apply(node).unwrap_err();
729 assert!(
730 err.to_string().contains("deeper than max_depth (3)"),
731 "{err}"
732 );
733 }
734
735 #[test]
736 fn duplicate_column_labels_fail_the_record() {
737 let node = json!({
738 "Header": {"ColData": [{"value": "a"}, {"value": "a"}]},
739 "ColData": [{"value": "1"}, {"value": "2"}]
740 });
741 let mut s = spec();
742 s.root = None;
743 s.children = "children".to_owned();
744 s.ancestors = None;
745 s.columns.header = Some("Header.ColData".to_owned());
746 s.columns.header_label = Some("value".to_owned());
747 let c = s.compile().unwrap();
748 let err = c.try_apply(node);
749 assert!(
750 err.as_ref()
751 .is_err_and(|e| e.to_string().contains("duplicate column")),
752 "{err:?}"
753 );
754 }
755
756 #[test]
757 fn empty_report_yields_nothing() {
758 let mut s = spec();
759 let out = s
760 .clone()
761 .compile()
762 .unwrap()
763 .apply(json!({ "Rows": { "Row": [] } }));
764 assert!(out.is_empty());
765 s.root = None;
767 let passed = s.compile().unwrap().apply(json!("scalar"));
768 assert_eq!(passed, vec![json!("scalar")]);
769 }
770
771 #[test]
772 fn compile_rejects_bad_config() {
773 let mut s = spec();
774 s.children = " ".to_owned();
775 assert!(s.compile().is_err());
776 let mut s = spec();
777 s.columns.from = "".to_owned();
778 assert!(s.compile().is_err());
779 let mut s = spec();
780 s.leaf = "bogus".to_owned();
781 assert!(s.compile().is_err());
782 let mut s = spec();
783 s.leaf = "has_field:".to_owned();
784 assert!(s.compile().is_err());
785 let mut s = spec();
786 s.max_depth = 0;
787 assert!(s.compile().is_err());
788 }
789
790 #[test]
791 fn path_get_supports_dots_and_indices() {
792 let v = json!({ "Header": { "ColData": [ {"value": "hi"} ] } });
793 assert_eq!(path_get(&v, "Header.ColData[0].value"), Some(&json!("hi")));
794 assert_eq!(
795 path_get(&v, "$.Header.ColData[0].value"),
796 Some(&json!("hi"))
797 );
798 assert_eq!(path_get(&v, "Header.missing"), None);
799 assert_eq!(path_get(&v, "Header.ColData[9].value"), None);
800 }
801
802 fn page_fn(stage: TransformStage) -> Option<crate::stage::PageFnBox> {
803 match stage {
804 TransformStage::PageFn(f) => Some(f),
805 _ => None,
806 }
807 }
808
809 #[test]
810 fn page_fn_is_none_for_a_record_stage() {
811 let custom = TransformStage::Custom(Arc::new(|v| vec![v]));
812 assert!(page_fn(custom).is_none());
813 }
814
815 #[test]
816 fn into_stage_fails_the_page_instead_of_passing_an_unflattenable_record_through() {
817 let f = page_fn(spec().into_stage().unwrap()).expect("a page stage");
818 let mut shallow = spec();
819 shallow.max_depth = 1;
820 let g = page_fn(shallow.into_stage().unwrap()).expect("a page stage");
821 assert!(f(vec![nested_rows_report()]).is_ok());
822 let err = g(vec![nested_rows_report()]).unwrap_err().to_string();
823 assert!(err.contains("max_depth") || err.contains("deeper"), "{err}");
824 assert_eq!(f(vec![json!({"x": 1})]).unwrap(), vec![json!({"x": 1})]);
826 }
827
828 fn grouped() -> TreeFlattenSpec {
831 serde_json::from_value(json!({
832 "root": "rows",
833 "children": "rows",
834 "groups": [
835 {"from": "dims", "header": "dimHeaders", "header_label": "name"},
836 {"from": "mets", "header": "metHeaders", "header_label": "name"}
837 ],
838 "ancestors": {"field": "label", "as": ["section"]}
839 }))
840 .unwrap()
841 }
842
843 fn grouped_report() -> Value {
844 json!({
845 "dimHeaders": [{"name": "date"}, {"name": "country"}],
846 "metHeaders": [{"name": "sessions"}],
847 "rows": [
848 {"label": "Web", "rows": [
849 {"dims": [{"value": "20260901"}, {"value": "DE"}], "mets": [{"value": "12"}]},
850 {"dims": [{"value": "20260902"}, {}], "mets": [{"value": "7"}]}
851 ]}
852 ]
853 })
854 }
855
856 #[test]
857 fn groups_merge_into_one_row_per_leaf() {
858 let c = grouped().compile().unwrap();
859 let out = c.try_apply(grouped_report()).unwrap();
860 assert_eq!(
861 out,
862 vec![
863 json!({"section": "Web", "date": "20260901", "country": "DE", "sessions": "12"}),
864 json!({"section": "Web", "date": "20260902", "country": {}, "sessions": "7"}),
865 ]
866 );
867 assert_eq!(c.apply(grouped_report()), out);
868 }
869
870 #[test]
871 fn groups_fail_loudly_and_apply_passes_through() {
872 let c = grouped().compile().unwrap();
873 let err = |r: Value| c.try_apply(r).unwrap_err().to_string();
874
875 let mut r = grouped_report();
876 r["rows"][0]["rows"][1]["mets"] = json!([]);
877 let e = err(r.clone());
878 assert!(
879 e.contains("row 1, group 'mets': 0 cell(s) but 1 header(s)"),
880 "{e}"
881 );
882 assert_eq!(c.apply(r.clone()), vec![r]);
883
884 let mut r = grouped_report();
885 r["rows"][0]["rows"][0]
886 .as_object_mut()
887 .unwrap()
888 .remove("dims");
889 assert!(err(r).contains("row 0 has no `dims` array"));
890
891 let mut r = grouped_report();
892 r["metHeaders"][0]["name"] = json!("date");
893 let e = err(r);
894 assert!(
895 e.contains("duplicate column 'date'") && e.contains("'dims' and 'mets'"),
896 "{e}"
897 );
898
899 let mut r = grouped_report();
900 r["dimHeaders"][1]["name"] = json!("date");
901 assert!(err(r).contains("group 'dims' names it twice"));
902
903 let mut r = grouped_report();
904 r["metHeaders"] = json!("sessions");
905 assert!(err(r).contains("group 'mets': header `metHeaders` is not an array"));
906
907 assert_eq!(c.try_apply(json!(7)).unwrap(), vec![json!(7)]);
909 let bare = json!({"dimHeaders": [], "metHeaders": []});
910 assert_eq!(c.try_apply(bare.clone()).unwrap(), vec![bare]);
911 }
912
913 #[test]
914 fn groups_compile_validation_and_stage() {
915 let bad = |v: Value| {
916 serde_json::from_value::<TreeFlattenSpec>(v)
917 .unwrap()
918 .compile()
919 .unwrap_err()
920 .to_string()
921 };
922 let g = json!({"from": "a", "header": "h"});
923 assert!(bad(json!({"children": "rows"})).contains("`columns.from`"));
924 assert!(
925 bad(json!({"children": "rows", "columns": {"from": "c"}, "groups": [g]}))
926 .contains("exactly one")
927 );
928 assert!(bad(json!({"children": "rows", "groups": [{"from": "a"}]})).contains("group 0"));
929 assert!(
930 bad(json!({"children": "rows", "groups": [{"from": "a", "header": " "}]}))
931 .contains("group 0")
932 );
933 let s = grouped();
934 let back: TreeFlattenSpec =
935 serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
936 assert_eq!(back, s);
937 assert!(serde_json::to_value(&s).unwrap().get("columns").is_none());
938
939 match s.into_stage().unwrap() {
940 TransformStage::PageFn(f) => {
941 assert_eq!(f(vec![grouped_report()]).unwrap().len(), 2);
942 let mut r = grouped_report();
943 r["rows"][0]["rows"][0]["mets"] = json!([]);
944 assert!(f(vec![r]).is_err());
945 }
946 other => panic!("expected PageFn, got {other:?}"),
947 }
948 }
949
950 #[test]
951 fn group_rows_count_any_groups_cells() {
952 let mut s = grouped();
953 s.emit_group_rows = true;
954 let c = s.compile().unwrap();
955 let mut r = grouped_report();
956 r["rows"][0]["dims"] = json!([{"value": "total"}, {"value": "*"}]);
957 r["rows"][0]["mets"] = json!([{"value": "19"}]);
958 let out = c.try_apply(r).unwrap();
959 assert_eq!(out.len(), 3);
960 assert_eq!(out[0]["sessions"], json!("19"));
961 }
962}