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