Skip to main content

faucet_core/
cross_join.rs

1//! Inbuilt `cross_join` transform (#534): expand one record into the
2//! **cartesian product of two or more of its sibling array fields**, emitting
3//! one flat record per combination.
4//!
5//! This is the last per-record reshape that otherwise falls to the DuckDB SQL
6//! transform. It is a different shape from `explode` (one array → N rows) and
7//! `unpivot` (wide → long): here several sibling arrays within a single record
8//! are crossed (e.g. a HCM record's `jobs[] × compensation[] × employment[]`).
9//!
10//! The whole module is gated by `#[cfg(feature = "transform-cross-join")]` at
11//! the `mod` site in `lib.rs`. It routes through
12//! [`TransformStage::PageFn`](crate::stage::TransformStage) (page-level, fallible)
13//! so an over-limit cartesian product fails loudly rather than risking OOM.
14
15use crate::FaucetError;
16use crate::stage::TransformStage;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value};
20use std::sync::Arc;
21
22/// Default per-record cartesian-product ceiling.
23pub const DEFAULT_MAX_PRODUCT: usize = 10_000;
24
25fn default_true() -> bool {
26    true
27}
28fn default_max_product() -> usize {
29    DEFAULT_MAX_PRODUCT
30}
31
32/// What to do when one of the crossed arrays is empty for a given record.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum OnEmpty {
36    /// SQL `CROSS JOIN` semantics: an empty crossed array ⇒ the record produces
37    /// **zero** rows.
38    #[default]
39    Skip,
40    /// `LEFT JOIN … ON true` semantics: an empty crossed array contributes a
41    /// single `null` element, so the record still produces a (null-filled) row.
42    OneRow,
43}
44
45/// User-facing `cross_join` config.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
47#[serde(deny_unknown_fields)]
48pub struct CrossJoinSpec {
49    /// Sibling array fields to cross (≥2). Each element is expected to be an
50    /// object; a scalar element is wrapped under the array's name.
51    pub arrays: Vec<String>,
52    /// Prefix each produced column with its array name (`jobs` → `jobs_title`).
53    /// Avoids collisions between arrays that share field names. Default `false`.
54    #[serde(default)]
55    pub prefix: bool,
56    /// Keep the record's non-array scalar fields on every output row.
57    /// Default `true`.
58    #[serde(default = "default_true")]
59    pub keep_parent: bool,
60    /// Behavior when a crossed array is empty. Default `skip`.
61    #[serde(default)]
62    pub on_empty: OnEmpty,
63    /// Remove the source array fields from the output rows after expansion.
64    /// Default `true`.
65    #[serde(default = "default_true")]
66    pub drop_arrays: bool,
67    /// Fail (rather than OOM) if a single record's cartesian product would
68    /// exceed this many rows. Default [`DEFAULT_MAX_PRODUCT`].
69    #[serde(default = "default_max_product")]
70    pub max_product: usize,
71}
72
73impl CrossJoinSpec {
74    /// Validate the spec, returning a reusable [`CompiledCrossJoin`].
75    pub fn compile(&self) -> Result<CompiledCrossJoin, FaucetError> {
76        CompiledCrossJoin::compile(self)
77    }
78
79    /// Compile and wrap as a [`TransformStage::PageFn`] (1→0..N per record,
80    /// fallible on product overflow).
81    pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
82        let compiled = self.compile()?;
83        Ok(TransformStage::PageFn(Arc::new(move |page: Vec<Value>| {
84            let mut out = Vec::with_capacity(page.len());
85            for rec in page {
86                out.extend(compiled.apply(rec)?);
87            }
88            Ok(out)
89        })))
90    }
91}
92
93/// Validated [`CrossJoinSpec`] — apply per record with [`CompiledCrossJoin::apply`].
94#[derive(Debug, Clone)]
95pub struct CompiledCrossJoin {
96    spec: CrossJoinSpec,
97}
98
99impl CompiledCrossJoin {
100    fn compile(spec: &CrossJoinSpec) -> Result<Self, FaucetError> {
101        if spec.arrays.len() < 2 {
102            return Err(FaucetError::Config(
103                "cross_join: `arrays` needs at least 2 fields (a single array is `explode`)".into(),
104            ));
105        }
106        if spec.arrays.iter().any(|a| a.trim().is_empty()) {
107            return Err(FaucetError::Config(
108                "cross_join: `arrays` entries must be non-empty field names".into(),
109            ));
110        }
111        let mut seen = std::collections::HashSet::new();
112        for a in &spec.arrays {
113            if !seen.insert(a) {
114                return Err(FaucetError::Config(format!(
115                    "cross_join: duplicate array field `{a}`"
116                )));
117            }
118        }
119        if spec.max_product == 0 {
120            return Err(FaucetError::Config(
121                "cross_join: `max_product` must be greater than 0".into(),
122            ));
123        }
124        Ok(Self { spec: spec.clone() })
125    }
126
127    /// Expand one record into the cartesian product of its named sibling arrays.
128    /// Non-object records pass through unchanged. Missing / non-array named
129    /// fields are treated as empty sets (subject to `on_empty`).
130    pub fn apply(&self, rec: Value) -> Result<Vec<Value>, FaucetError> {
131        let Value::Object(obj) = rec else {
132            return Ok(vec![rec]);
133        };
134
135        // Resolve each named array into its element set (missing / non-array →
136        // empty). Preserve declared order so column-collision precedence and the
137        // `OneRow` null-fill are deterministic.
138        let mut sets: Vec<(&str, Vec<Value>)> = Vec::with_capacity(self.spec.arrays.len());
139        for name in &self.spec.arrays {
140            let elems = match obj.get(name) {
141                Some(Value::Array(a)) => a.clone(),
142                _ => Vec::new(),
143            };
144            sets.push((name.as_str(), elems));
145        }
146
147        // Empty-array handling.
148        match self.spec.on_empty {
149            OnEmpty::Skip => {
150                if sets.iter().any(|(_, e)| e.is_empty()) {
151                    return Ok(Vec::new());
152                }
153            }
154            OnEmpty::OneRow => {
155                for (_, e) in sets.iter_mut() {
156                    if e.is_empty() {
157                        e.push(Value::Null);
158                    }
159                }
160            }
161        }
162
163        // Product-size guard (fail loud, never OOM). After `on_empty` every set
164        // has len >= 1, so the product is always >= 1 (no zero-size case).
165        sets.iter()
166            .try_fold(1usize, |acc, (_, e)| acc.checked_mul(e.len()))
167            .filter(|n| *n <= self.spec.max_product)
168            .ok_or_else(|| {
169                FaucetError::Transform(format!(
170                    "cross_join: record's cartesian product over {:?} exceeds max_product={} \
171                     — narrow the arrays or raise max_product",
172                    self.spec.arrays, self.spec.max_product
173                ))
174            })?;
175
176        // Parent scalars carried onto every row.
177        let mut parent = Map::new();
178        if self.spec.keep_parent {
179            for (k, v) in &obj {
180                if self.spec.drop_arrays && self.spec.arrays.iter().any(|a| a == k) {
181                    continue;
182                }
183                parent.insert(k.clone(), v.clone());
184            }
185        } else if !self.spec.drop_arrays {
186            // keep_parent=false still carries the raw arrays only if not dropping.
187            for name in &self.spec.arrays {
188                if let Some(v) = obj.get(name) {
189                    parent.insert(name.clone(), v.clone());
190                }
191            }
192        }
193
194        // Iterative cartesian product.
195        let mut rows: Vec<Map<String, Value>> = vec![parent];
196        for (name, elems) in &sets {
197            let mut next = Vec::with_capacity(rows.len() * elems.len());
198            for base in &rows {
199                for elem in elems {
200                    let mut row = base.clone();
201                    merge_element(&mut row, name, elem, self.spec.prefix);
202                    next.push(row);
203                }
204            }
205            rows = next;
206        }
207
208        Ok(rows.into_iter().map(Value::Object).collect())
209    }
210}
211
212/// Merge one crossed array element into a product row. Object elements spread
213/// their fields (name-prefixed when `prefix`); scalar/null elements land under
214/// the array's own name.
215fn merge_element(row: &mut Map<String, Value>, array_name: &str, elem: &Value, prefix: bool) {
216    match elem {
217        Value::Object(fields) => {
218            for (k, v) in fields {
219                let key = if prefix {
220                    format!("{array_name}_{k}")
221                } else {
222                    k.clone()
223                };
224                row.insert(key, v.clone());
225            }
226        }
227        other => {
228            row.insert(array_name.to_string(), other.clone());
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use serde_json::json;
237
238    fn spec(arrays: &[&str]) -> CrossJoinSpec {
239        CrossJoinSpec {
240            arrays: arrays.iter().map(|s| s.to_string()).collect(),
241            prefix: false,
242            keep_parent: true,
243            on_empty: OnEmpty::Skip,
244            drop_arrays: true,
245            max_product: DEFAULT_MAX_PRODUCT,
246        }
247    }
248
249    #[test]
250    fn crosses_two_sibling_arrays() {
251        let c = spec(&["jobs", "comp"]).compile().unwrap();
252        let rec = json!({
253            "emp_id": 1,
254            "jobs": [{"title": "eng"}, {"title": "mgr"}],
255            "comp": [{"amount": 100}, {"amount": 200}]
256        });
257        let out = c.apply(rec).unwrap();
258        assert_eq!(out.len(), 4); // 2 × 2
259        // parent scalar carried; array fields dropped; element fields merged.
260        assert_eq!(out[0]["emp_id"], json!(1));
261        assert_eq!(out[0]["title"], json!("eng"));
262        assert_eq!(out[0]["amount"], json!(100));
263        assert!(out[0].get("jobs").is_none());
264    }
265
266    #[test]
267    fn skip_vs_one_row_on_empty() {
268        let rec = json!({"id": 1, "jobs": [{"t": "a"}], "comp": []});
269        assert!(
270            spec(&["jobs", "comp"])
271                .compile()
272                .unwrap()
273                .apply(rec.clone())
274                .unwrap()
275                .is_empty()
276        );
277        let mut s = spec(&["jobs", "comp"]);
278        s.on_empty = OnEmpty::OneRow;
279        let out = s.compile().unwrap().apply(rec).unwrap();
280        assert_eq!(out.len(), 1);
281        assert_eq!(out[0]["t"], json!("a"));
282        // OneRow fills the empty `comp` array with a null element → lands under
283        // the array name as null.
284        assert_eq!(out[0]["comp"], json!(null));
285    }
286
287    #[test]
288    fn prefix_avoids_collisions() {
289        let mut s = spec(&["a", "b"]);
290        s.prefix = true;
291        let rec = json!({"a": [{"x": 1}], "b": [{"x": 2}]});
292        let out = s.compile().unwrap().apply(rec).unwrap();
293        assert_eq!(out.len(), 1);
294        assert_eq!(out[0]["a_x"], json!(1));
295        assert_eq!(out[0]["b_x"], json!(2));
296    }
297
298    #[test]
299    fn scalar_elements_wrap_under_array_name() {
300        let rec = json!({"id": 1, "tags": ["x", "y"], "vals": [10]});
301        let out = spec(&["tags", "vals"])
302            .compile()
303            .unwrap()
304            .apply(rec)
305            .unwrap();
306        assert_eq!(out.len(), 2);
307        assert_eq!(out[0]["tags"], json!("x"));
308        assert_eq!(out[0]["vals"], json!(10));
309    }
310
311    #[test]
312    fn max_product_overflow_errors() {
313        let mut s = spec(&["a", "b"]);
314        s.max_product = 3;
315        let rec = json!({"a": [1, 2], "b": [1, 2]}); // 2×2 = 4 > 3
316        assert!(s.compile().unwrap().apply(rec).is_err());
317    }
318
319    #[test]
320    fn non_object_and_missing_array_passthrough() {
321        let c = spec(&["a", "b"]).compile().unwrap();
322        // non-object record → passthrough
323        assert_eq!(c.apply(json!(5)).unwrap(), vec![json!(5)]);
324        // missing array under Skip → 0 rows
325        assert!(c.apply(json!({"a": [{"x": 1}]})).unwrap().is_empty());
326    }
327
328    #[test]
329    fn compile_rejects_bad_specs() {
330        assert!(spec(&["only"]).compile().is_err()); // <2 arrays
331        assert!(spec(&["a", ""]).compile().is_err()); // empty name
332        assert!(spec(&["a", "a"]).compile().is_err()); // duplicate
333        let mut s = spec(&["a", "b"]);
334        s.max_product = 0;
335        assert!(s.compile().is_err());
336    }
337
338    #[test]
339    fn into_stage_is_pagefn_and_flat_maps() {
340        let stage = spec(&["a", "b"]).into_stage().unwrap();
341        assert!(matches!(stage, TransformStage::PageFn(_)));
342    }
343
344    #[test]
345    fn into_stage_pagefn_runs_over_a_page() {
346        // Exercise the PageFn closure body (flat-map over a real page).
347        let TransformStage::PageFn(f) = spec(&["jobs", "comp"]).into_stage().unwrap() else {
348            panic!("expected PageFn");
349        };
350        let page = vec![
351            json!({"id": 1, "jobs": [{"t": "a"}], "comp": [{"c": 1}, {"c": 2}]}),
352            json!({"id": 2, "jobs": [{"t": "b"}, {"t": "c"}], "comp": [{"c": 9}]}),
353        ];
354        let out = f(page).unwrap();
355        assert_eq!(out.len(), 2 + 2); // (1×2) + (2×1)
356        assert_eq!(out[0]["id"], json!(1));
357    }
358
359    #[test]
360    fn into_stage_pagefn_propagates_overflow_error() {
361        let mut s = spec(&["a", "b"]);
362        s.max_product = 1;
363        let TransformStage::PageFn(f) = s.into_stage().unwrap() else {
364            panic!("expected PageFn");
365        };
366        assert!(f(vec![json!({"a": [1, 2], "b": [1, 2]})]).is_err());
367    }
368
369    #[test]
370    fn keep_parent_false_drops_scalars() {
371        let mut s = spec(&["a", "b"]);
372        s.keep_parent = false;
373        let out = s
374            .compile()
375            .unwrap()
376            .apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
377            .unwrap();
378        assert_eq!(out.len(), 1);
379        assert!(out[0].get("id").is_none()); // parent scalar dropped
380        assert_eq!(out[0]["x"], json!(1));
381    }
382
383    #[test]
384    fn keep_parent_false_keep_arrays_when_not_dropping() {
385        let mut s = spec(&["a", "b"]);
386        s.keep_parent = false;
387        s.drop_arrays = false;
388        let out = s
389            .compile()
390            .unwrap()
391            .apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
392            .unwrap();
393        assert_eq!(out.len(), 1);
394        // raw arrays carried (not dropped), parent scalar still dropped
395        assert_eq!(out[0]["a"], json!([{"x": 1}]));
396        assert!(out[0].get("id").is_none());
397    }
398
399    #[test]
400    fn keep_parent_true_no_drop_keeps_arrays_and_scalars() {
401        let mut s = spec(&["a", "b"]);
402        s.drop_arrays = false;
403        let out = s
404            .compile()
405            .unwrap()
406            .apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
407            .unwrap();
408        assert_eq!(out[0]["id"], json!(7));
409        assert_eq!(out[0]["a"], json!([{"x": 1}])); // array retained
410        assert_eq!(out[0]["x"], json!(1)); // and exploded
411    }
412}