use crate::FaucetError;
use crate::stage::TransformStage;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::sync::Arc;
pub const DEFAULT_MAX_PRODUCT: usize = 10_000;
fn default_true() -> bool {
true
}
fn default_max_product() -> usize {
DEFAULT_MAX_PRODUCT
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum OnEmpty {
#[default]
Skip,
OneRow,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CrossJoinSpec {
pub arrays: Vec<String>,
#[serde(default)]
pub prefix: bool,
#[serde(default = "default_true")]
pub keep_parent: bool,
#[serde(default)]
pub on_empty: OnEmpty,
#[serde(default = "default_true")]
pub drop_arrays: bool,
#[serde(default = "default_max_product")]
pub max_product: usize,
}
impl CrossJoinSpec {
pub fn compile(&self) -> Result<CompiledCrossJoin, FaucetError> {
CompiledCrossJoin::compile(self)
}
pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
let compiled = self.compile()?;
Ok(TransformStage::PageFn(Arc::new(move |page: Vec<Value>| {
let mut out = Vec::with_capacity(page.len());
for rec in page {
out.extend(compiled.apply(rec)?);
}
Ok(out)
})))
}
}
#[derive(Debug, Clone)]
pub struct CompiledCrossJoin {
spec: CrossJoinSpec,
}
impl CompiledCrossJoin {
fn compile(spec: &CrossJoinSpec) -> Result<Self, FaucetError> {
if spec.arrays.len() < 2 {
return Err(FaucetError::Config(
"cross_join: `arrays` needs at least 2 fields (a single array is `explode`)".into(),
));
}
if spec.arrays.iter().any(|a| a.trim().is_empty()) {
return Err(FaucetError::Config(
"cross_join: `arrays` entries must be non-empty field names".into(),
));
}
let mut seen = std::collections::HashSet::new();
for a in &spec.arrays {
if !seen.insert(a) {
return Err(FaucetError::Config(format!(
"cross_join: duplicate array field `{a}`"
)));
}
}
if spec.max_product == 0 {
return Err(FaucetError::Config(
"cross_join: `max_product` must be greater than 0".into(),
));
}
Ok(Self { spec: spec.clone() })
}
pub fn apply(&self, rec: Value) -> Result<Vec<Value>, FaucetError> {
let Value::Object(obj) = rec else {
return Ok(vec![rec]);
};
let mut sets: Vec<(&str, Vec<Value>)> = Vec::with_capacity(self.spec.arrays.len());
for name in &self.spec.arrays {
let elems = match obj.get(name) {
Some(Value::Array(a)) => a.clone(),
_ => Vec::new(),
};
sets.push((name.as_str(), elems));
}
match self.spec.on_empty {
OnEmpty::Skip => {
if sets.iter().any(|(_, e)| e.is_empty()) {
return Ok(Vec::new());
}
}
OnEmpty::OneRow => {
for (_, e) in sets.iter_mut() {
if e.is_empty() {
e.push(Value::Null);
}
}
}
}
sets.iter()
.try_fold(1usize, |acc, (_, e)| acc.checked_mul(e.len()))
.filter(|n| *n <= self.spec.max_product)
.ok_or_else(|| {
FaucetError::Transform(format!(
"cross_join: record's cartesian product over {:?} exceeds max_product={} \
— narrow the arrays or raise max_product",
self.spec.arrays, self.spec.max_product
))
})?;
let mut parent = Map::new();
if self.spec.keep_parent {
for (k, v) in &obj {
if self.spec.drop_arrays && self.spec.arrays.iter().any(|a| a == k) {
continue;
}
parent.insert(k.clone(), v.clone());
}
} else if !self.spec.drop_arrays {
for name in &self.spec.arrays {
if let Some(v) = obj.get(name) {
parent.insert(name.clone(), v.clone());
}
}
}
let mut rows: Vec<Map<String, Value>> = vec![parent];
for (name, elems) in &sets {
let mut next = Vec::with_capacity(rows.len() * elems.len());
for base in &rows {
for elem in elems {
let mut row = base.clone();
merge_element(&mut row, name, elem, self.spec.prefix);
next.push(row);
}
}
rows = next;
}
Ok(rows.into_iter().map(Value::Object).collect())
}
}
fn merge_element(row: &mut Map<String, Value>, array_name: &str, elem: &Value, prefix: bool) {
match elem {
Value::Object(fields) => {
for (k, v) in fields {
let key = if prefix {
format!("{array_name}_{k}")
} else {
k.clone()
};
row.insert(key, v.clone());
}
}
other => {
row.insert(array_name.to_string(), other.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn spec(arrays: &[&str]) -> CrossJoinSpec {
CrossJoinSpec {
arrays: arrays.iter().map(|s| s.to_string()).collect(),
prefix: false,
keep_parent: true,
on_empty: OnEmpty::Skip,
drop_arrays: true,
max_product: DEFAULT_MAX_PRODUCT,
}
}
#[test]
fn crosses_two_sibling_arrays() {
let c = spec(&["jobs", "comp"]).compile().unwrap();
let rec = json!({
"emp_id": 1,
"jobs": [{"title": "eng"}, {"title": "mgr"}],
"comp": [{"amount": 100}, {"amount": 200}]
});
let out = c.apply(rec).unwrap();
assert_eq!(out.len(), 4); assert_eq!(out[0]["emp_id"], json!(1));
assert_eq!(out[0]["title"], json!("eng"));
assert_eq!(out[0]["amount"], json!(100));
assert!(out[0].get("jobs").is_none());
}
#[test]
fn skip_vs_one_row_on_empty() {
let rec = json!({"id": 1, "jobs": [{"t": "a"}], "comp": []});
assert!(
spec(&["jobs", "comp"])
.compile()
.unwrap()
.apply(rec.clone())
.unwrap()
.is_empty()
);
let mut s = spec(&["jobs", "comp"]);
s.on_empty = OnEmpty::OneRow;
let out = s.compile().unwrap().apply(rec).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0]["t"], json!("a"));
assert_eq!(out[0]["comp"], json!(null));
}
#[test]
fn prefix_avoids_collisions() {
let mut s = spec(&["a", "b"]);
s.prefix = true;
let rec = json!({"a": [{"x": 1}], "b": [{"x": 2}]});
let out = s.compile().unwrap().apply(rec).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0]["a_x"], json!(1));
assert_eq!(out[0]["b_x"], json!(2));
}
#[test]
fn scalar_elements_wrap_under_array_name() {
let rec = json!({"id": 1, "tags": ["x", "y"], "vals": [10]});
let out = spec(&["tags", "vals"])
.compile()
.unwrap()
.apply(rec)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0]["tags"], json!("x"));
assert_eq!(out[0]["vals"], json!(10));
}
#[test]
fn max_product_overflow_errors() {
let mut s = spec(&["a", "b"]);
s.max_product = 3;
let rec = json!({"a": [1, 2], "b": [1, 2]}); assert!(s.compile().unwrap().apply(rec).is_err());
}
#[test]
fn non_object_and_missing_array_passthrough() {
let c = spec(&["a", "b"]).compile().unwrap();
assert_eq!(c.apply(json!(5)).unwrap(), vec![json!(5)]);
assert!(c.apply(json!({"a": [{"x": 1}]})).unwrap().is_empty());
}
#[test]
fn compile_rejects_bad_specs() {
assert!(spec(&["only"]).compile().is_err()); assert!(spec(&["a", ""]).compile().is_err()); assert!(spec(&["a", "a"]).compile().is_err()); let mut s = spec(&["a", "b"]);
s.max_product = 0;
assert!(s.compile().is_err());
}
#[test]
fn into_stage_is_pagefn_and_flat_maps() {
let stage = spec(&["a", "b"]).into_stage().unwrap();
assert!(matches!(stage, TransformStage::PageFn(_)));
}
#[test]
fn into_stage_pagefn_runs_over_a_page() {
let TransformStage::PageFn(f) = spec(&["jobs", "comp"]).into_stage().unwrap() else {
panic!("expected PageFn");
};
let page = vec![
json!({"id": 1, "jobs": [{"t": "a"}], "comp": [{"c": 1}, {"c": 2}]}),
json!({"id": 2, "jobs": [{"t": "b"}, {"t": "c"}], "comp": [{"c": 9}]}),
];
let out = f(page).unwrap();
assert_eq!(out.len(), 2 + 2); assert_eq!(out[0]["id"], json!(1));
}
#[test]
fn into_stage_pagefn_propagates_overflow_error() {
let mut s = spec(&["a", "b"]);
s.max_product = 1;
let TransformStage::PageFn(f) = s.into_stage().unwrap() else {
panic!("expected PageFn");
};
assert!(f(vec![json!({"a": [1, 2], "b": [1, 2]})]).is_err());
}
#[test]
fn keep_parent_false_drops_scalars() {
let mut s = spec(&["a", "b"]);
s.keep_parent = false;
let out = s
.compile()
.unwrap()
.apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
.unwrap();
assert_eq!(out.len(), 1);
assert!(out[0].get("id").is_none()); assert_eq!(out[0]["x"], json!(1));
}
#[test]
fn keep_parent_false_keep_arrays_when_not_dropping() {
let mut s = spec(&["a", "b"]);
s.keep_parent = false;
s.drop_arrays = false;
let out = s
.compile()
.unwrap()
.apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0]["a"], json!([{"x": 1}]));
assert!(out[0].get("id").is_none());
}
#[test]
fn keep_parent_true_no_drop_keeps_arrays_and_scalars() {
let mut s = spec(&["a", "b"]);
s.drop_arrays = false;
let out = s
.compile()
.unwrap()
.apply(json!({"id": 7, "a": [{"x": 1}], "b": [{"y": 2}]}))
.unwrap();
assert_eq!(out[0]["id"], json!(7));
assert_eq!(out[0]["a"], json!([{"x": 1}])); assert_eq!(out[0]["x"], json!(1)); }
}