Skip to main content

faucet_core/
tree.rs

1//! Recursive report-tree / matrix flatten transform (`tree_flatten`, #530).
2//!
3//! Financial-report APIs (profit-and-loss, balance-sheet and similar report
4//! endpoints) return a self-referential nested-`Rows` matrix — a tree of
5//! section → subsection → line, where the tabular output is one row per **leaf**
6//! carrying the section labels it sits under plus the period columns. Flattening
7//! it is the one reshape that kept those taps on the embedded-DuckDB SQL path;
8//! `tree_flatten` moves them back to an inbuilt transform.
9//!
10//! Pure + recursive: a depth-first walk carrying an ancestor-label stack; at each
11//! leaf it emits `{ <ancestor columns…>, <header→value columns…>, [path] }`. It
12//! routes through [`TransformStage::Custom`](crate::stage::TransformStage) (1→0..N),
13//! so no new stage-enum variant is needed (the exhaustive-enum rule).
14
15// The whole module is gated by `#[cfg(feature = "transform-tree-flatten")]` at
16// its `pub mod tree;` declaration in `lib.rs`.
17use crate::FaucetError;
18use crate::stage::TransformStage;
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21use std::sync::Arc;
22
23/// Default [`TreeFlattenSpec::max_depth`] — a stack-overflow backstop for a
24/// malformed or cyclic tree, far above any real report nesting.
25pub 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/// How the value columns are read from a leaf node.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct ColumnsSpec {
44    /// Path (within a node) to the leaf's cell array — e.g. `ColData`.
45    pub from: String,
46    /// Path (within the whole record) to the header definitions, paired
47    /// positionally with the cells to name the value columns. Absent → cells are
48    /// named `col_0`, `col_1`, ….
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub header: Option<String>,
51    /// Field within each header element holding its label (e.g. `ColTitle`).
52    /// Absent → the header element is used as a scalar string.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub header_label: Option<String>,
55    /// Field within each cell holding the value (e.g. `value`). Absent-in-cell →
56    /// the whole cell is used.
57    #[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/// Which ancestor labels to carry down onto each emitted row.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(deny_unknown_fields)]
81pub struct AncestorsSpec {
82    /// Path (within a node) to that node's group label — e.g.
83    /// `Header.ColData[0].value`.
84    pub field: String,
85    /// Column names for depth 1, 2, …; extra depth is appended as
86    /// `ancestor_<n>`, missing levels are null.
87    #[serde(default, rename = "as")]
88    pub as_names: Vec<String>,
89}
90
91/// Spec for the `tree_flatten` transform — recursive tree/matrix → leaf rows
92/// (1→0..N). Compile with [`TreeFlattenSpec::compile`]; attach via
93/// [`TreeFlattenSpec::into_stage`].
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
95#[serde(deny_unknown_fields)]
96pub struct TreeFlattenSpec {
97    /// Path to the top-level node array within the record — e.g. `Rows.Row`.
98    /// Absent → the record itself is the single root node.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub root: Option<String>,
101    /// Path (within a node) to its child-node array — the recursion key, e.g.
102    /// `Rows.Row`.
103    pub children: String,
104    /// Leaf detection: `has_no_children` (default) or `has_field:<name>` (a node
105    /// carrying `<name>` is a leaf even if it also has children).
106    #[serde(default = "default_leaf")]
107    pub leaf: String,
108    /// How the value columns are read from a leaf. Set exactly one of
109    /// `columns` or `groups`.
110    #[serde(default, skip_serializing_if = "ColumnsSpec::is_unset")]
111    pub columns: ColumnsSpec,
112    /// Several positional cell arrays per leaf, each named by its own header
113    /// list (#746) — the `zip_columns` `groups` shape. Each group needs a
114    /// `header`; the groups' columns are merged into one row, and a width
115    /// mismatch, a missing cell array or a column two groups both name fails
116    /// the page.
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub groups: Vec<ColumnsSpec>,
119    /// Ancestor/group labels carried onto every row.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub ancestors: Option<AncestorsSpec>,
122    /// Emit the joined ancestor path under this column (e.g. `Income > Sales`).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub path_as: Option<String>,
125    /// Separator for `path_as`.
126    #[serde(default = "default_path_sep")]
127    pub path_sep: String,
128    /// Skip leaves whose value cells are all empty (null or `""`).
129    #[serde(default)]
130    pub drop_empty: bool,
131    /// Also emit a row for a group node that carries its own cells (subtotals).
132    #[serde(default)]
133    pub emit_group_rows: bool,
134    /// Stack-overflow backstop; a tree deeper than this fails the record rather
135    /// than silently dropping the deeper rows.
136    #[serde(default = "default_max_depth")]
137    pub max_depth: usize,
138}
139
140impl TreeFlattenSpec {
141    /// Validate the spec, returning a reusable [`CompiledTreeFlatten`].
142    pub fn compile(&self) -> Result<CompiledTreeFlatten, FaucetError> {
143        CompiledTreeFlatten::compile(self)
144    }
145
146    /// Compile and wrap as a [`TransformStage::PageFn`] (1→0..N per record).
147    /// A record that cannot be flattened without losing data (a duplicate
148    /// column, a tree deeper than `max_depth`, a group that cannot be zipped)
149    /// fails the page instead of passing through as one unflattened row; a
150    /// record without the root array still passes through unchanged.
151    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/// Validated [`TreeFlattenSpec`] — apply per record with [`CompiledTreeFlatten::apply`].
170#[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    /// Flatten one record (a report) into 0..N leaf rows. Non-object records and
227    /// records with no resolvable root pass through unchanged (never silently
228    /// dropped). With `groups`, a record that cannot be zipped (see
229    /// [`try_apply`](Self::try_apply)) is also passed through, with an error
230    /// logged; the pipeline stage uses `try_apply` and fails instead.
231    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    /// [`apply`](Self::apply), failing on a record that cannot be flattened
243    /// without losing data: a leaf missing a group's cell array, a group whose
244    /// width differs from its header count, a column named twice, or a tree
245    /// deeper than `max_depth`.
246    pub fn try_apply(&self, rec: Value) -> Result<Vec<Value>, FaucetError> {
247        Ok(self.flatten(&rec)?.unwrap_or_else(|| vec![rec]))
248    }
249
250    /// `Ok(None)` = pass the record through unchanged.
251    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        // Resolve the root node list. A root that resolves to an (even empty)
258        // array/node is used as-is — an empty report yields zero rows. Only a
259        // *missing* root path passes the record through (never silently dropped).
260        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    /// Each column source with its header labels. `columns` is lenient
278    /// (unnamed cells become `col_<i>`); every group is strict.
279    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            // Truncating would silently drop every deeper row (CORE-32).
351            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            // Push this node's label, recurse, pop.
371            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        // Ancestor columns.
396        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        // Joined ancestor path.
407        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        // Value columns from the node's cell array(s).
417        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
489/// Render a JSON scalar as a plain string (objects/arrays → compact JSON).
490fn 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
500/// Resolve a dot/bracket path against a value. Supports a leading `$`/`$.`, `.key`
501/// segments, and `[n]` array indices (e.g. `Header.ColData[0].value`,
502/// `$.Rows.Row`). Returns `None` on any miss. Purpose-built here because
503/// `CompiledPath` (stage.rs) does not support array indexing.
504fn 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            // [n] index
512            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            // .key up to the next '.' or '['
519            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    /// An object inserted in reverse-sorted key order (#817).
538    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    /// A nested-`Rows` P&L: Income → {Sales, Services}, one leaf each, two
575    /// period columns.
576    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        // Header pairing: the first column's header is empty, so it falls back to
598        // `col_0` (an empty column name is unusable downstream); the two periods
599        // take their header titles.
600        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        // Income has a nested subsection; a sibling leaf sits at depth 1.
610        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        // Deep leaf: section=Income, subsection=Domestic.
631        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        // Shallow leaf: no ancestors at all.
635        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        // Third cell has no header → col_2.
654        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        // A node that has BOTH children and ColData is a leaf under has_field.
663        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        // Parent (has ColData) emits, plus its child leaf → 2 rows.
675        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        // The group row (subtotal) + the leaf.
697        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        // Build a chain deeper than max_depth.
718        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        // Non-object passes through.
766        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        // A record without the root still passes through unchanged.
825        assert_eq!(f(vec![json!({"x": 1})]).unwrap(), vec![json!({"x": 1})]);
826    }
827
828    /// #746: a leaf carrying two positional cell arrays, each named by its own
829    /// header list.
830    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        // Non-objects and a missing root still pass through.
908        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}