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 or `null` named fields
129    /// are treated as empty sets (subject to `on_empty`); a field that is present
130    /// but not an array is an error, so an upstream type change cannot silently
131    /// drop whole records (CORE-59). A produced column that would overwrite an
132    /// existing one (a parent field, or a field of an earlier array) is an error
133    /// too — set `prefix: true` to keep both (CORE-33).
134    pub fn apply(&self, rec: Value) -> Result<Vec<Value>, FaucetError> {
135        let Value::Object(obj) = rec else {
136            return Ok(vec![rec]);
137        };
138
139        // Resolve each named array into its element set (missing / non-array →
140        // empty). Preserve declared order so column-collision precedence and the
141        // `OneRow` null-fill are deterministic.
142        let mut sets: Vec<(&str, Vec<Value>)> = Vec::with_capacity(self.spec.arrays.len());
143        for name in &self.spec.arrays {
144            let elems = match obj.get(name) {
145                Some(Value::Array(a)) => a.clone(),
146                None | Some(Value::Null) => Vec::new(),
147                Some(other) => {
148                    return Err(FaucetError::Transform(format!(
149                        "cross_join: field `{name}` is {} rather than an array",
150                        match other {
151                            Value::Object(_) => "an object",
152                            Value::String(_) => "a string",
153                            Value::Number(_) => "a number",
154                            _ => "a boolean",
155                        }
156                    )));
157                }
158            };
159            sets.push((name.as_str(), elems));
160        }
161
162        // Empty-array handling.
163        match self.spec.on_empty {
164            OnEmpty::Skip => {
165                if sets.iter().any(|(_, e)| e.is_empty()) {
166                    return Ok(Vec::new());
167                }
168            }
169            OnEmpty::OneRow => {
170                for (_, e) in sets.iter_mut() {
171                    if e.is_empty() {
172                        e.push(Value::Null);
173                    }
174                }
175            }
176        }
177
178        // Product-size guard (fail loud, never OOM). After `on_empty` every set
179        // has len >= 1, so the product is always >= 1 (no zero-size case).
180        sets.iter()
181            .try_fold(1usize, |acc, (_, e)| acc.checked_mul(e.len()))
182            .filter(|n| *n <= self.spec.max_product)
183            .ok_or_else(|| {
184                FaucetError::Transform(format!(
185                    "cross_join: record's cartesian product over {:?} exceeds max_product={} \
186                     — narrow the arrays or raise max_product",
187                    self.spec.arrays, self.spec.max_product
188                ))
189            })?;
190
191        // Parent scalars carried onto every row.
192        let mut parent = Map::new();
193        if self.spec.keep_parent {
194            for (k, v) in &obj {
195                if self.spec.drop_arrays && self.spec.arrays.iter().any(|a| a == k) {
196                    continue;
197                }
198                parent.insert(k.clone(), v.clone());
199            }
200        } else if !self.spec.drop_arrays {
201            // keep_parent=false still carries the raw arrays only if not dropping.
202            for name in &self.spec.arrays {
203                if let Some(v) = obj.get(name) {
204                    parent.insert(name.clone(), v.clone());
205                }
206            }
207        }
208
209        // Iterative cartesian product.
210        let mut rows: Vec<Map<String, Value>> = vec![parent];
211        for (name, elems) in &sets {
212            let mut next = Vec::with_capacity(rows.len() * elems.len());
213            for base in &rows {
214                for elem in elems {
215                    let mut row = base.clone();
216                    merge_element(&mut row, name, elem, self.spec.prefix)?;
217                    next.push(row);
218                }
219            }
220            rows = next;
221        }
222
223        Ok(rows.into_iter().map(Value::Object).collect())
224    }
225}
226
227/// Merge one crossed array element into a product row. Object elements spread
228/// their fields (name-prefixed when `prefix`); scalar/null elements land under
229/// the array's own name, replacing the raw array a kept parent carries. Any
230/// other overwrite of an existing column is refused.
231fn merge_element(
232    row: &mut Map<String, Value>,
233    array_name: &str,
234    elem: &Value,
235    prefix: bool,
236) -> Result<(), FaucetError> {
237    match elem {
238        Value::Object(fields) => {
239            for (k, v) in fields {
240                let key = if prefix {
241                    format!("{array_name}_{k}")
242                } else {
243                    k.clone()
244                };
245                if row.contains_key(&key) {
246                    return Err(FaucetError::Transform(format!(
247                        "cross_join: field `{key}` of `{array_name}` would overwrite an \
248                         existing column — set `prefix: true` or rename it first"
249                    )));
250                }
251                row.insert(key, v.clone());
252            }
253        }
254        other => {
255            row.insert(array_name.to_string(), other.clone());
256        }
257    }
258    Ok(())
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265
266    fn spec(arrays: &[&str]) -> CrossJoinSpec {
267        CrossJoinSpec {
268            arrays: arrays.iter().map(|s| s.to_string()).collect(),
269            prefix: false,
270            keep_parent: true,
271            on_empty: OnEmpty::Skip,
272            drop_arrays: true,
273            max_product: DEFAULT_MAX_PRODUCT,
274        }
275    }
276
277    #[test]
278    fn crosses_two_sibling_arrays() {
279        let c = spec(&["jobs", "comp"]).compile().unwrap();
280        let rec = json!({
281            "emp_id": 1,
282            "jobs": [{"title": "eng"}, {"title": "mgr"}],
283            "comp": [{"amount": 100}, {"amount": 200}]
284        });
285        let out = c.apply(rec).unwrap();
286        assert_eq!(out.len(), 4); // 2 × 2
287        // parent scalar carried; array fields dropped; element fields merged.
288        assert_eq!(out[0]["emp_id"], json!(1));
289        assert_eq!(out[0]["title"], json!("eng"));
290        assert_eq!(out[0]["amount"], json!(100));
291        assert!(out[0].get("jobs").is_none());
292    }
293
294    #[test]
295    fn skip_vs_one_row_on_empty() {
296        let rec = json!({"id": 1, "jobs": [{"t": "a"}], "comp": []});
297        assert!(
298            spec(&["jobs", "comp"])
299                .compile()
300                .unwrap()
301                .apply(rec.clone())
302                .unwrap()
303                .is_empty()
304        );
305        let mut s = spec(&["jobs", "comp"]);
306        s.on_empty = OnEmpty::OneRow;
307        let out = s.compile().unwrap().apply(rec).unwrap();
308        assert_eq!(out.len(), 1);
309        assert_eq!(out[0]["t"], json!("a"));
310        // OneRow fills the empty `comp` array with a null element → lands under
311        // the array name as null.
312        assert_eq!(out[0]["comp"], json!(null));
313    }
314
315    #[test]
316    fn prefix_avoids_collisions() {
317        let mut s = spec(&["a", "b"]);
318        s.prefix = true;
319        let rec = json!({"a": [{"x": 1}], "b": [{"x": 2}]});
320        let out = s.compile().unwrap().apply(rec).unwrap();
321        assert_eq!(out.len(), 1);
322        assert_eq!(out[0]["a_x"], json!(1));
323        assert_eq!(out[0]["b_x"], json!(2));
324    }
325
326    #[test]
327    fn scalar_elements_wrap_under_array_name() {
328        let rec = json!({"id": 1, "tags": ["x", "y"], "vals": [10]});
329        let out = spec(&["tags", "vals"])
330            .compile()
331            .unwrap()
332            .apply(rec)
333            .unwrap();
334        assert_eq!(out.len(), 2);
335        assert_eq!(out[0]["tags"], json!("x"));
336        assert_eq!(out[0]["vals"], json!(10));
337    }
338
339    #[test]
340    fn max_product_overflow_errors() {
341        let mut s = spec(&["a", "b"]);
342        s.max_product = 3;
343        let rec = json!({"a": [1, 2], "b": [1, 2]}); // 2×2 = 4 > 3
344        assert!(s.compile().unwrap().apply(rec).is_err());
345    }
346
347    #[test]
348    fn non_object_and_missing_array_passthrough() {
349        let c = spec(&["a", "b"]).compile().unwrap();
350        // non-object record → passthrough
351        assert_eq!(c.apply(json!(5)).unwrap(), vec![json!(5)]);
352        // missing array under Skip → 0 rows
353        assert!(c.apply(json!({"a": [{"x": 1}]})).unwrap().is_empty());
354    }
355
356    #[test]
357    fn overwriting_a_column_is_refused_without_prefix() {
358        let c = spec(&["a", "b"]).compile().unwrap();
359        let err = c
360            .apply(json!({"id": 1, "a": [{"id": 9}], "b": [{"y": 2}]}))
361            .unwrap_err();
362        assert!(err.to_string().contains("field `id` of `a`"), "{err}");
363        let err = c
364            .apply(json!({"a": [{"x": 1}], "b": [{"x": 2}]}))
365            .unwrap_err();
366        assert!(err.to_string().contains("field `x` of `b`"), "{err}");
367        let mut p = spec(&["a", "b"]);
368        p.prefix = true;
369        let out = p
370            .compile()
371            .unwrap()
372            .apply(json!({"id": 1, "a": [{"id": 9}], "b": [{"id": 2}]}))
373            .unwrap();
374        assert_eq!(out[0]["id"], json!(1));
375        assert_eq!(out[0]["a_id"], json!(9));
376    }
377
378    #[test]
379    fn a_present_non_array_is_a_type_error() {
380        let c = spec(&["a", "b"]).compile().unwrap();
381        for bad in [json!({"x": 1}), json!("s"), json!(3), json!(true)] {
382            let err = c.apply(json!({"a": [{"x": 1}], "b": bad})).unwrap_err();
383            assert!(err.to_string().contains("rather than an array"), "{err}");
384        }
385        assert!(
386            c.apply(json!({"a": [{"x": 1}], "b": null}))
387                .unwrap()
388                .is_empty()
389        );
390    }
391
392    #[test]
393    fn compile_rejects_bad_specs() {
394        assert!(spec(&["only"]).compile().is_err()); // <2 arrays
395        assert!(spec(&["a", ""]).compile().is_err()); // empty name
396        assert!(spec(&["a", "a"]).compile().is_err()); // duplicate
397        let mut s = spec(&["a", "b"]);
398        s.max_product = 0;
399        assert!(s.compile().is_err());
400    }
401
402    #[test]
403    fn into_stage_is_pagefn_and_flat_maps() {
404        let stage = spec(&["a", "b"]).into_stage().unwrap();
405        assert!(matches!(stage, TransformStage::PageFn(_)));
406    }
407
408    #[test]
409    fn into_stage_pagefn_runs_over_a_page() {
410        // Exercise the PageFn closure body (flat-map over a real page).
411        let TransformStage::PageFn(f) = spec(&["jobs", "comp"]).into_stage().unwrap() else {
412            panic!("expected PageFn");
413        };
414        let page = vec![
415            json!({"id": 1, "jobs": [{"t": "a"}], "comp": [{"c": 1}, {"c": 2}]}),
416            json!({"id": 2, "jobs": [{"t": "b"}, {"t": "c"}], "comp": [{"c": 9}]}),
417        ];
418        let out = f(page).unwrap();
419        assert_eq!(out.len(), 2 + 2); // (1×2) + (2×1)
420        assert_eq!(out[0]["id"], json!(1));
421    }
422
423    #[test]
424    fn into_stage_pagefn_propagates_overflow_error() {
425        let mut s = spec(&["a", "b"]);
426        s.max_product = 1;
427        let TransformStage::PageFn(f) = s.into_stage().unwrap() else {
428            panic!("expected PageFn");
429        };
430        assert!(f(vec![json!({"a": [1, 2], "b": [1, 2]})]).is_err());
431    }
432
433    #[test]
434    fn keep_parent_false_drops_scalars() {
435        let mut s = spec(&["a", "b"]);
436        s.keep_parent = false;
437        let out = s
438            .compile()
439            .unwrap()
440            .apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
441            .unwrap();
442        assert_eq!(out.len(), 1);
443        assert!(out[0].get("id").is_none()); // parent scalar dropped
444        assert_eq!(out[0]["x"], json!(1));
445    }
446
447    #[test]
448    fn keep_parent_false_keep_arrays_when_not_dropping() {
449        let mut s = spec(&["a", "b"]);
450        s.keep_parent = false;
451        s.drop_arrays = false;
452        let out = s
453            .compile()
454            .unwrap()
455            .apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
456            .unwrap();
457        assert_eq!(out.len(), 1);
458        // raw arrays carried (not dropped), parent scalar still dropped
459        assert_eq!(out[0]["a"], json!([{"x": 1}]));
460        assert!(out[0].get("id").is_none());
461    }
462
463    #[test]
464    fn keep_parent_true_no_drop_keeps_arrays_and_scalars() {
465        let mut s = spec(&["a", "b"]);
466        s.drop_arrays = false;
467        let out = s
468            .compile()
469            .unwrap()
470            .apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
471            .unwrap();
472        assert_eq!(out[0]["id"], json!(7));
473        assert_eq!(out[0]["a"], json!([{"x": 1}])); // array retained
474        assert_eq!(out[0]["x"], json!(1)); // and exploded
475    }
476}