Skip to main content

faucet_core/
tree.rs

1//! Recursive report-tree / matrix flatten transform (`tree_flatten`, #530).
2//!
3//! Financial-report APIs (QuickBooks / Xero / ZohoBooks / Rillet, and
4//! Sage/Intacct) 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
61/// Which ancestor labels to carry down onto each emitted row.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct AncestorsSpec {
65    /// Path (within a node) to that node's group label — e.g.
66    /// `Header.ColData[0].value`.
67    pub field: String,
68    /// Column names for depth 1, 2, …; extra depth is appended as
69    /// `ancestor_<n>`, missing levels are null.
70    #[serde(default, rename = "as")]
71    pub as_names: Vec<String>,
72}
73
74/// Spec for the `tree_flatten` transform — recursive tree/matrix → leaf rows
75/// (1→0..N). Compile with [`TreeFlattenSpec::compile`]; attach via
76/// [`TreeFlattenSpec::into_stage`].
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct TreeFlattenSpec {
80    /// Path to the top-level node array within the record — e.g. `Rows.Row`.
81    /// Absent → the record itself is the single root node.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub root: Option<String>,
84    /// Path (within a node) to its child-node array — the recursion key, e.g.
85    /// `Rows.Row`.
86    pub children: String,
87    /// Leaf detection: `has_no_children` (default) or `has_field:<name>` (a node
88    /// carrying `<name>` is a leaf even if it also has children).
89    #[serde(default = "default_leaf")]
90    pub leaf: String,
91    /// How the value columns are read from a leaf.
92    pub columns: ColumnsSpec,
93    /// Ancestor/group labels carried onto every row.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub ancestors: Option<AncestorsSpec>,
96    /// Emit the joined ancestor path under this column (e.g. `Income > Sales`).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub path_as: Option<String>,
99    /// Separator for `path_as`.
100    #[serde(default = "default_path_sep")]
101    pub path_sep: String,
102    /// Skip leaves whose value cells are all empty (null or `""`).
103    #[serde(default)]
104    pub drop_empty: bool,
105    /// Also emit a row for a group node that carries its own cells (subtotals).
106    #[serde(default)]
107    pub emit_group_rows: bool,
108    /// Stack-overflow backstop; a branch deeper than this is truncated (logged).
109    #[serde(default = "default_max_depth")]
110    pub max_depth: usize,
111}
112
113impl TreeFlattenSpec {
114    /// Validate the spec, returning a reusable [`CompiledTreeFlatten`].
115    pub fn compile(&self) -> Result<CompiledTreeFlatten, FaucetError> {
116        CompiledTreeFlatten::compile(self)
117    }
118
119    /// Compile and wrap as a [`TransformStage::Custom`] (1→0..N).
120    pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
121        let compiled = self.compile()?;
122        Ok(TransformStage::Custom(Arc::new(move |rec| {
123            compiled.apply(rec)
124        })))
125    }
126}
127
128#[derive(Debug, Clone, PartialEq)]
129enum LeafMode {
130    NoChildren,
131    HasField(String),
132}
133
134/// Validated [`TreeFlattenSpec`] — apply per record with [`CompiledTreeFlatten::apply`].
135#[derive(Debug, Clone)]
136pub struct CompiledTreeFlatten {
137    spec: TreeFlattenSpec,
138    leaf_mode: LeafMode,
139}
140
141impl CompiledTreeFlatten {
142    fn compile(spec: &TreeFlattenSpec) -> Result<Self, FaucetError> {
143        if spec.children.trim().is_empty() {
144            return Err(FaucetError::Transform(
145                "tree_flatten: `children` must be non-empty".to_owned(),
146            ));
147        }
148        if spec.columns.from.trim().is_empty() {
149            return Err(FaucetError::Transform(
150                "tree_flatten: `columns.from` must be non-empty".to_owned(),
151            ));
152        }
153        if spec.max_depth == 0 {
154            return Err(FaucetError::Transform(
155                "tree_flatten: `max_depth` must be greater than zero".to_owned(),
156            ));
157        }
158        let leaf_mode = if spec.leaf == "has_no_children" {
159            LeafMode::NoChildren
160        } else if let Some(field) = spec.leaf.strip_prefix("has_field:") {
161            if field.trim().is_empty() {
162                return Err(FaucetError::Transform(
163                    "tree_flatten: `leaf: has_field:<name>` requires a field name".to_owned(),
164                ));
165            }
166            LeafMode::HasField(field.to_owned())
167        } else {
168            return Err(FaucetError::Transform(format!(
169                "tree_flatten: `leaf` must be `has_no_children` or `has_field:<name>`, got '{}'",
170                spec.leaf
171            )));
172        };
173        Ok(Self {
174            spec: spec.clone(),
175            leaf_mode,
176        })
177    }
178
179    /// Flatten one record (a report) into 0..N leaf rows. Non-object records and
180    /// records with no resolvable root pass through unchanged (never silently
181    /// dropped).
182    pub fn apply(&self, rec: Value) -> Vec<Value> {
183        if !rec.is_object() {
184            return vec![rec];
185        }
186        // Header labels for naming value columns.
187        let header_labels: Vec<String> = self
188            .spec
189            .columns
190            .header
191            .as_deref()
192            .and_then(|h| path_get(&rec, h))
193            .and_then(Value::as_array)
194            .map(|arr| {
195                arr.iter()
196                    .map(|el| self.header_label(el))
197                    .collect::<Vec<_>>()
198            })
199            .unwrap_or_default();
200
201        // Resolve the root node list. A root that resolves to an (even empty)
202        // array/node is used as-is — an empty report yields zero rows. Only a
203        // *missing* root path passes the record through (never silently dropped).
204        let roots: Vec<&Value> = match &self.spec.root {
205            Some(path) => match path_get(&rec, path) {
206                Some(Value::Array(a)) => a.iter().collect(),
207                Some(v) => vec![v],
208                None => return vec![rec],
209            },
210            None => vec![&rec],
211        };
212
213        let mut out: Vec<Value> = Vec::new();
214        let mut ancestors: Vec<Value> = Vec::new();
215        let mut depth_exceeded = false;
216        for node in roots {
217            self.walk(
218                node,
219                &mut ancestors,
220                0,
221                &header_labels,
222                &mut out,
223                &mut depth_exceeded,
224            );
225        }
226        out
227    }
228
229    fn header_label(&self, el: &Value) -> String {
230        if let Some(field) = &self.spec.columns.header_label
231            && let Some(v) = path_get(el, field)
232        {
233            return scalar_string(v);
234        }
235        scalar_string(el)
236    }
237
238    fn is_leaf(&self, node: &Value, has_children: bool) -> bool {
239        match &self.leaf_mode {
240            LeafMode::NoChildren => !has_children,
241            LeafMode::HasField(f) => node.get(f).is_some(),
242        }
243    }
244
245    #[allow(clippy::too_many_arguments)]
246    fn walk(
247        &self,
248        node: &Value,
249        ancestors: &mut Vec<Value>,
250        depth: usize,
251        header_labels: &[String],
252        out: &mut Vec<Value>,
253        depth_exceeded: &mut bool,
254    ) {
255        if depth >= self.spec.max_depth {
256            if !*depth_exceeded {
257                *depth_exceeded = true;
258                tracing::error!(
259                    max_depth = self.spec.max_depth,
260                    "tree_flatten: max_depth exceeded — branch truncated (malformed or cyclic tree?)"
261                );
262            }
263            return;
264        }
265        let children = path_get(node, &self.spec.children).and_then(Value::as_array);
266        let has_children = children.is_some_and(|c| !c.is_empty());
267        let leaf = self.is_leaf(node, has_children);
268
269        if (leaf || (self.spec.emit_group_rows && node_has_cells(node, &self.spec.columns.from)))
270            && let Some(row) = self.emit_row(node, ancestors, header_labels)
271        {
272            out.push(row);
273        }
274
275        if has_children {
276            // Push this node's label, recurse, pop.
277            let label = self
278                .spec
279                .ancestors
280                .as_ref()
281                .and_then(|a| path_get(node, &a.field).cloned())
282                .unwrap_or(Value::Null);
283            ancestors.push(label);
284            for child in children.unwrap() {
285                self.walk(
286                    child,
287                    ancestors,
288                    depth + 1,
289                    header_labels,
290                    out,
291                    depth_exceeded,
292                );
293            }
294            ancestors.pop();
295        }
296    }
297
298    fn emit_row(
299        &self,
300        node: &Value,
301        ancestors: &[Value],
302        header_labels: &[String],
303    ) -> Option<Value> {
304        let mut row = Map::new();
305
306        // Ancestor columns.
307        if let Some(anc) = &self.spec.ancestors {
308            for (i, label) in ancestors.iter().enumerate() {
309                let name = anc
310                    .as_names
311                    .get(i)
312                    .cloned()
313                    .unwrap_or_else(|| format!("ancestor_{}", i + 1));
314                row.insert(name, label.clone());
315            }
316        }
317        // Joined ancestor path.
318        if let Some(path_col) = &self.spec.path_as {
319            let joined = ancestors
320                .iter()
321                .map(scalar_string)
322                .collect::<Vec<_>>()
323                .join(&self.spec.path_sep);
324            row.insert(path_col.clone(), Value::String(joined));
325        }
326
327        // Value columns from the leaf's cell array.
328        let cells = path_get(node, &self.spec.columns.from).and_then(Value::as_array);
329        let mut all_empty = true;
330        if let Some(cells) = cells {
331            for (i, cell) in cells.iter().enumerate() {
332                let value = path_get(cell, &self.spec.columns.value)
333                    .cloned()
334                    .unwrap_or_else(|| cell.clone());
335                if !is_empty_value(&value) {
336                    all_empty = false;
337                }
338                let name = header_labels
339                    .get(i)
340                    .cloned()
341                    .filter(|s| !s.is_empty())
342                    .unwrap_or_else(|| format!("col_{i}"));
343                row.insert(name, value);
344            }
345        }
346
347        if self.spec.drop_empty && all_empty {
348            return None;
349        }
350        Some(Value::Object(row))
351    }
352}
353
354fn node_has_cells(node: &Value, from: &str) -> bool {
355    path_get(node, from)
356        .and_then(Value::as_array)
357        .is_some_and(|a| !a.is_empty())
358}
359
360fn is_empty_value(v: &Value) -> bool {
361    match v {
362        Value::Null => true,
363        Value::String(s) => s.is_empty(),
364        _ => false,
365    }
366}
367
368/// Render a JSON scalar as a plain string (objects/arrays → compact JSON).
369fn scalar_string(v: &Value) -> String {
370    match v {
371        Value::String(s) => s.clone(),
372        Value::Null => String::new(),
373        Value::Bool(b) => b.to_string(),
374        Value::Number(n) => n.to_string(),
375        other => other.to_string(),
376    }
377}
378
379/// Resolve a dot/bracket path against a value. Supports a leading `$`/`$.`, `.key`
380/// segments, and `[n]` array indices (e.g. `Header.ColData[0].value`,
381/// `$.Rows.Row`). Returns `None` on any miss. Purpose-built here because
382/// `CompiledPath` (stage.rs) does not support array indexing.
383fn path_get<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
384    let mut cur = root;
385    let mut rest = path.trim();
386    rest = rest.strip_prefix('$').unwrap_or(rest);
387    rest = rest.strip_prefix('.').unwrap_or(rest);
388    while !rest.is_empty() {
389        if let Some(after) = rest.strip_prefix('[') {
390            // [n] index
391            let close = after.find(']')?;
392            let idx: usize = after[..close].trim().parse().ok()?;
393            cur = cur.as_array()?.get(idx)?;
394            rest = &after[close + 1..];
395            rest = rest.strip_prefix('.').unwrap_or(rest);
396        } else {
397            // .key up to the next '.' or '['
398            let end = rest.find(['.', '[']).unwrap_or(rest.len());
399            let key = &rest[..end];
400            if key.is_empty() {
401                return None;
402            }
403            cur = cur.get(key)?;
404            rest = &rest[end..];
405            rest = rest.strip_prefix('.').unwrap_or(rest);
406        }
407    }
408    Some(cur)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use serde_json::json;
415
416    fn spec() -> TreeFlattenSpec {
417        TreeFlattenSpec {
418            root: Some("Rows.Row".to_owned()),
419            children: "Rows.Row".to_owned(),
420            leaf: "has_no_children".to_owned(),
421            columns: ColumnsSpec {
422                from: "ColData".to_owned(),
423                header: Some("Columns.Column".to_owned()),
424                header_label: Some("ColTitle".to_owned()),
425                value: "value".to_owned(),
426            },
427            ancestors: Some(AncestorsSpec {
428                field: "Header.ColData[0].value".to_owned(),
429                as_names: vec!["section".to_owned(), "subsection".to_owned()],
430            }),
431            path_as: Some("group_path".to_owned()),
432            path_sep: " > ".to_owned(),
433            drop_empty: false,
434            emit_group_rows: false,
435            max_depth: DEFAULT_MAX_DEPTH,
436        }
437    }
438
439    /// A QuickBooks-style P&L: Income → {Sales, Services}, one leaf each, two
440    /// period columns.
441    fn quickbooks_report() -> Value {
442        json!({
443            "Columns": { "Column": [ {"ColTitle": ""}, {"ColTitle": "Jan 2024"}, {"ColTitle": "Feb 2024"} ] },
444            "Rows": { "Row": [
445                {
446                    "Header": { "ColData": [ {"value": "Income"} ] },
447                    "Rows": { "Row": [
448                        { "ColData": [ {"value": "Sales"}, {"value": "100"}, {"value": "120"} ] },
449                        { "ColData": [ {"value": "Services"}, {"value": "50"}, {"value": "60"} ] }
450                    ] }
451                }
452            ] }
453        })
454    }
455
456    #[test]
457    fn flattens_quickbooks_report_to_leaf_rows() {
458        let out = spec().compile().unwrap().apply(quickbooks_report());
459        assert_eq!(out.len(), 2);
460        assert_eq!(out[0]["section"], json!("Income"));
461        assert_eq!(out[0]["group_path"], json!("Income"));
462        // Header pairing: the first column's header is empty, so it falls back to
463        // `col_0` (an empty column name is unusable downstream); the two periods
464        // take their header titles.
465        assert_eq!(out[0]["col_0"], json!("Sales"));
466        assert_eq!(out[0]["Jan 2024"], json!("100"));
467        assert_eq!(out[0]["Feb 2024"], json!("120"));
468        assert_eq!(out[1]["col_0"], json!("Services"));
469        assert_eq!(out[1]["Feb 2024"], json!("60"));
470    }
471
472    #[test]
473    fn uneven_depth_names_extra_levels_and_leaves_missing_null() {
474        // Income has a nested subsection; a sibling leaf sits at depth 1.
475        let report = json!({
476            "Rows": { "Row": [
477                {
478                    "Header": { "ColData": [ {"value": "Income"} ] },
479                    "Rows": { "Row": [
480                        {
481                            "Header": { "ColData": [ {"value": "Domestic"} ] },
482                            "Rows": { "Row": [
483                                { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
484                            ] }
485                        }
486                    ] }
487                },
488                { "ColData": [ {"value": "Other"}, {"value": "5"} ] }
489            ] }
490        });
491        let mut s = spec();
492        s.columns.header = None;
493        let out = s.compile().unwrap().apply(report);
494        assert_eq!(out.len(), 2);
495        // Deep leaf: section=Income, subsection=Domestic.
496        assert_eq!(out[0]["section"], json!("Income"));
497        assert_eq!(out[0]["subsection"], json!("Domestic"));
498        assert_eq!(out[0]["col_0"], json!("Sales"));
499        // Shallow leaf: no ancestors at all.
500        assert!(out[1].get("section").is_none());
501        assert_eq!(out[1]["col_0"], json!("Other"));
502    }
503
504    #[test]
505    fn header_cell_length_mismatch_zips_to_shorter() {
506        let mut s = spec();
507        s.ancestors = None;
508        s.root = None;
509        s.children = "children".to_owned();
510        let report = json!({
511            "Columns": { "Column": [ {"ColTitle": "A"}, {"ColTitle": "B"} ] },
512            "ColData": [ {"value": "x"}, {"value": "y"}, {"value": "z"} ]
513        });
514        let out = s.compile().unwrap().apply(report);
515        assert_eq!(out.len(), 1);
516        assert_eq!(out[0]["A"], json!("x"));
517        assert_eq!(out[0]["B"], json!("y"));
518        // Third cell has no header → col_2.
519        assert_eq!(out[0]["col_2"], json!("z"));
520    }
521
522    #[test]
523    fn leaf_has_field_mode() {
524        let mut s = spec();
525        s.leaf = "has_field:ColData".to_owned();
526        s.emit_group_rows = false;
527        // A node that has BOTH children and ColData is a leaf under has_field.
528        let report = json!({
529            "Rows": { "Row": [
530                {
531                    "Header": { "ColData": [ {"value": "Total"} ] },
532                    "ColData": [ {"value": "Total"}, {"value": "9"} ],
533                    "Rows": { "Row": [ { "ColData": [ {"value": "x"}, {"value": "1"} ] } ] }
534                }
535            ] }
536        });
537        s.columns.header = None;
538        let out = s.compile().unwrap().apply(report);
539        // Parent (has ColData) emits, plus its child leaf → 2 rows.
540        assert_eq!(out.len(), 2);
541        assert_eq!(out[0]["col_0"], json!("Total"));
542    }
543
544    #[test]
545    fn emit_group_rows_includes_subtotals() {
546        let mut s = spec();
547        s.emit_group_rows = true;
548        s.columns.header = None;
549        let report = json!({
550            "Rows": { "Row": [
551                {
552                    "Header": { "ColData": [ {"value": "Income"} ] },
553                    "ColData": [ {"value": "Income total"}, {"value": "150"} ],
554                    "Rows": { "Row": [
555                        { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
556                    ] }
557                }
558            ] }
559        });
560        let out = s.compile().unwrap().apply(report);
561        // The group row (subtotal) + the leaf.
562        assert_eq!(out.len(), 2);
563        assert_eq!(out[0]["col_0"], json!("Income total"));
564        assert_eq!(out[1]["col_0"], json!("Sales"));
565    }
566
567    #[test]
568    fn drop_empty_skips_all_empty_leaves() {
569        let mut s = spec();
570        s.drop_empty = true;
571        s.columns.header = None;
572        s.ancestors = None;
573        s.root = None;
574        s.children = "children".to_owned();
575        let report = json!({ "ColData": [ {"value": ""}, {"value": null} ] });
576        let out = s.compile().unwrap().apply(report);
577        assert!(out.is_empty(), "an all-empty leaf is dropped");
578    }
579
580    #[test]
581    fn max_depth_guard_truncates_without_panicking() {
582        // Build a chain deeper than max_depth.
583        let mut node = json!({ "ColData": [ {"value": "leaf"} ] });
584        for _ in 0..10 {
585            node = json!({ "Header": {"ColData":[{"value":"g"}]}, "children": [node] });
586        }
587        let mut s = spec();
588        s.root = None;
589        s.children = "children".to_owned();
590        s.columns.header = None;
591        s.ancestors = None;
592        s.max_depth = 3;
593        let out = s.compile().unwrap().apply(node);
594        // Truncated: the deep leaf is never reached, no panic.
595        assert!(out.is_empty());
596    }
597
598    #[test]
599    fn empty_report_yields_nothing() {
600        let mut s = spec();
601        let out = s
602            .clone()
603            .compile()
604            .unwrap()
605            .apply(json!({ "Rows": { "Row": [] } }));
606        assert!(out.is_empty());
607        // Non-object passes through.
608        s.root = None;
609        let passed = s.compile().unwrap().apply(json!("scalar"));
610        assert_eq!(passed, vec![json!("scalar")]);
611    }
612
613    #[test]
614    fn compile_rejects_bad_config() {
615        let mut s = spec();
616        s.children = " ".to_owned();
617        assert!(s.compile().is_err());
618        let mut s = spec();
619        s.columns.from = "".to_owned();
620        assert!(s.compile().is_err());
621        let mut s = spec();
622        s.leaf = "bogus".to_owned();
623        assert!(s.compile().is_err());
624        let mut s = spec();
625        s.leaf = "has_field:".to_owned();
626        assert!(s.compile().is_err());
627        let mut s = spec();
628        s.max_depth = 0;
629        assert!(s.compile().is_err());
630    }
631
632    #[test]
633    fn path_get_supports_dots_and_indices() {
634        let v = json!({ "Header": { "ColData": [ {"value": "hi"} ] } });
635        assert_eq!(path_get(&v, "Header.ColData[0].value"), Some(&json!("hi")));
636        assert_eq!(
637            path_get(&v, "$.Header.ColData[0].value"),
638            Some(&json!("hi"))
639        );
640        assert_eq!(path_get(&v, "Header.missing"), None);
641        assert_eq!(path_get(&v, "Header.ColData[9].value"), None);
642    }
643
644    #[test]
645    fn into_stage_produces_a_custom_stage() {
646        let stage = spec().into_stage().unwrap();
647        assert!(matches!(stage, TransformStage::Custom(_)));
648    }
649}