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