Skip to main content

faucet_core/
join.rs

1//! Pure hash-join logic for the topology `join` node (issue #72).
2//!
3//! A [`HashJoin`] buffers one upstream (the **build** side) into an in-memory
4//! index keyed by a configurable dotted path, then enriches records streamed
5//! from the other upstream (the **probe** side) with projected fields looked
6//! up from the matching build record. This is the classic *hash-join* shape:
7//! the build side is fully materialized before the probe side starts emitting.
8//!
9//! This module is **pure data logic** — no I/O, no channels, no `async`. The
10//! topology executor ([`crate::topology`]) owns the streaming/channel plumbing
11//! and calls [`HashJoin::add_build_page`] / [`HashJoin::probe_page`] as pages
12//! arrive. Keeping it pure makes every join semantic unit-testable in
13//! isolation (see the extensive tests at the bottom of this file).
14//!
15//! ## Semantics
16//!
17//! - **`inner`** drops probe records with no build-side match.
18//! - **`left`** passes probe records through for non-matches, filling the
19//!   projected fields with [`JoinConfig::on_missing`].
20//! - **`on_duplicate`** decides what happens when one probe key matches more
21//!   than one build record: [`OnDuplicate::First`] keeps the first, and
22//!   [`OnDuplicate::Cartesian`] emits one enriched record per match.
23//! - **`on_collision`** decides what happens when a projected `as` name
24//!   already exists on the probe record.
25//! - **`key_normalize`** controls whether `"42"` (string) and `42` (number)
26//!   are treated as the same key ([`KeyNormalize::Stringify`]) or as distinct
27//!   keys ([`KeyNormalize::Preserve`], the default — no coercion).
28
29use crate::error::FaucetError;
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32use std::collections::HashMap;
33
34/// Default safety cap on build-side records (10M).
35pub const DEFAULT_MAX_BUILD_RECORDS: usize = 10_000_000;
36
37/// Join mode — how probe records with no build match are handled.
38#[derive(
39    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
40)]
41#[serde(rename_all = "lowercase")]
42pub enum JoinMode {
43    /// Drop probe records that have no matching build record.
44    #[default]
45    Inner,
46    /// Pass probe records through even without a match, filling projected
47    /// fields from [`JoinConfig::on_missing`].
48    Left,
49}
50
51impl std::fmt::Display for JoinMode {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str(match self {
54            JoinMode::Inner => "inner",
55            JoinMode::Left => "left",
56        })
57    }
58}
59
60/// What to do when one probe key matches more than one build record.
61#[derive(
62    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
63)]
64#[serde(rename_all = "lowercase")]
65pub enum OnDuplicate {
66    /// Keep only the first build-side match (deterministic given build order).
67    #[default]
68    First,
69    /// Emit one enriched record per build-side match (may duplicate the probe
70    /// record).
71    Cartesian,
72}
73
74/// What to do when a projected `as` name collides with an existing field on
75/// the probe record.
76#[derive(
77    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
78)]
79#[serde(rename_all = "lowercase")]
80pub enum OnCollision {
81    /// Overwrite the probe record's field with the projected value.
82    #[default]
83    Overwrite,
84    /// Leave the probe record's field untouched; skip the projection.
85    Skip,
86    /// Fail the join node — and with it the run — with a typed error.
87    Error,
88}
89
90/// How to normalize keys before comparison.
91#[derive(
92    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
93)]
94#[serde(rename_all = "lowercase")]
95pub enum KeyNormalize {
96    /// Compare keys as their JSON value (no coercion): `"42"` != `42`.
97    #[default]
98    Preserve,
99    /// Coerce scalar keys to their string form before comparison so `"42"`
100    /// and `42` match.
101    Stringify,
102}
103
104/// A single field projection: copy `from` (a dotted path into the build
105/// record) onto the probe record under the name `as_`.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
107pub struct Projection {
108    /// Dotted path inside each build (right) record.
109    pub from: String,
110    /// Output field name written onto the probe (left) record.
111    #[serde(rename = "as")]
112    pub as_: String,
113}
114
115/// Compiled join configuration. Dotted key paths are pre-split at construction.
116#[derive(Debug, Clone)]
117pub struct JoinConfig {
118    /// Inner vs left.
119    pub mode: JoinMode,
120    /// Dotted path to the build-side (right) key.
121    pub build_key: String,
122    /// Dotted path to the probe-side (left) key.
123    pub probe_key: String,
124    /// Fields to copy from the build record onto the probe record.
125    pub projections: Vec<Projection>,
126    /// Value used to fill projected fields on a `left`-mode non-match.
127    pub on_missing: Value,
128    /// Multi-match policy.
129    pub on_duplicate: OnDuplicate,
130    /// Projection-collision policy.
131    pub on_collision: OnCollision,
132    /// Key-normalization policy.
133    pub key_normalize: KeyNormalize,
134    /// Safety cap on build-side records.
135    pub max_build_records: usize,
136}
137
138impl Default for JoinConfig {
139    fn default() -> Self {
140        Self {
141            mode: JoinMode::default(),
142            build_key: String::new(),
143            probe_key: String::new(),
144            projections: Vec::new(),
145            on_missing: Value::Null,
146            on_duplicate: OnDuplicate::default(),
147            on_collision: OnCollision::default(),
148            key_normalize: KeyNormalize::default(),
149            max_build_records: DEFAULT_MAX_BUILD_RECORDS,
150        }
151    }
152}
153
154/// Running counters for a join, mirroring the `faucet_join_*` metrics.
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub struct JoinStats {
157    /// Records ingested by the build side (before null-key skipping).
158    pub build_records: u64,
159    /// Build records skipped because their key resolved to null/absent.
160    pub build_nulls: u64,
161    /// Distinct build keys that appeared more than once (duplicate keys).
162    pub duplicates: u64,
163    /// Records ingested by the probe side.
164    pub probe_records: u64,
165    /// Probe records that matched at least one build record.
166    pub matches: u64,
167    /// Probe records with no build-side match.
168    pub misses: u64,
169    /// Projections skipped because the `from` path was absent on the build
170    /// record.
171    pub project_misses: u64,
172}
173
174/// A hash join: build the index, then probe it.
175#[derive(Debug)]
176pub struct HashJoin {
177    config: JoinConfig,
178    /// Build index: canonical key → build records (in build order).
179    index: HashMap<String, Vec<Value>>,
180    stats: JoinStats,
181}
182
183impl HashJoin {
184    /// Create an empty join ready to receive build-side pages.
185    pub fn new(config: JoinConfig) -> Self {
186        Self {
187            config,
188            index: HashMap::new(),
189            stats: JoinStats::default(),
190        }
191    }
192
193    /// Access the running counters.
194    pub fn stats(&self) -> &JoinStats {
195        &self.stats
196    }
197
198    /// The compiled config.
199    pub fn config(&self) -> &JoinConfig {
200        &self.config
201    }
202
203    /// Ingest a page of build-side records into the index.
204    ///
205    /// Records whose build key resolves to null/absent are skipped (counted in
206    /// [`JoinStats::build_nulls`]). Returns [`FaucetError::Transform`] with a
207    /// `JoinBuildOverflow`-style message once the cumulative build count
208    /// exceeds [`JoinConfig::max_build_records`].
209    pub fn add_build_page(&mut self, records: Vec<Value>) -> Result<(), FaucetError> {
210        for rec in records {
211            self.stats.build_records += 1;
212            if self.stats.build_records as usize > self.config.max_build_records {
213                return Err(FaucetError::Transform(format!(
214                    "join build side exceeded max_build_records ({}); raise the limit or partition the join",
215                    self.config.max_build_records
216                )));
217            }
218            let key = match get_path(&rec, &self.config.build_key) {
219                Some(v) if !v.is_null() => v,
220                _ => {
221                    self.stats.build_nulls += 1;
222                    continue;
223                }
224            };
225            let ckey = match canonical_key(key, self.config.key_normalize) {
226                Some(k) => k,
227                None => {
228                    // Non-scalar key under stringify, or otherwise unrepresentable.
229                    self.stats.build_nulls += 1;
230                    continue;
231                }
232            };
233            let bucket = self.index.entry(ckey).or_default();
234            if bucket.len() == 1 {
235                self.stats.duplicates += 1;
236            }
237            bucket.push(rec);
238        }
239        Ok(())
240    }
241
242    /// Number of distinct keys indexed so far.
243    pub fn indexed_keys(&self) -> usize {
244        self.index.len()
245    }
246
247    /// Enrich a page of probe-side records against the built index.
248    ///
249    /// Must be called only after every build page has been ingested. Returns
250    /// the enriched output records (0..N per input record depending on mode
251    /// and duplicate policy).
252    pub fn probe_page(&mut self, records: Vec<Value>) -> Result<Vec<Value>, FaucetError> {
253        let mut out = Vec::with_capacity(records.len());
254        for left in records {
255            self.stats.probe_records += 1;
256            let key = get_path(&left, &self.config.probe_key).filter(|v| !v.is_null());
257            let ckey = key.and_then(|k| canonical_key(k, self.config.key_normalize));
258
259            // Split the borrows so the matched build records are read in
260            // place while the stats are updated.
261            let Self {
262                config,
263                index,
264                stats,
265            } = self;
266            let matches: Option<&Vec<Value>> = ckey.as_ref().and_then(|k| index.get(k));
267
268            match matches {
269                Some(bucket) if !bucket.is_empty() => {
270                    stats.matches += 1;
271                    let take = match config.on_duplicate {
272                        OnDuplicate::First => &bucket[..1],
273                        OnDuplicate::Cartesian => &bucket[..],
274                    };
275                    let (last, rest) = take.split_last().expect("bucket is not empty");
276                    for right in rest {
277                        let mut enriched = left.clone();
278                        apply_projection(config, stats, &mut enriched, Some(right))?;
279                        out.push(enriched);
280                    }
281                    let mut enriched = left;
282                    apply_projection(config, stats, &mut enriched, Some(last))?;
283                    out.push(enriched);
284                }
285                _ => {
286                    stats.misses += 1;
287                    if config.mode == JoinMode::Left {
288                        let mut enriched = left;
289                        apply_projection(config, stats, &mut enriched, None)?;
290                        out.push(enriched);
291                    }
292                    // inner mode: drop.
293                }
294            }
295        }
296        Ok(out)
297    }
298}
299
300/// Apply the configured projections onto `left`. `right = None` fills every
301/// projected field with `on_missing` (the `left`-mode non-match path).
302fn apply_projection(
303    config: &JoinConfig,
304    stats: &mut JoinStats,
305    left: &mut Value,
306    right: Option<&Value>,
307) -> Result<(), FaucetError> {
308    for proj in &config.projections {
309        let value = match right {
310            Some(r) => match get_path(r, &proj.from) {
311                Some(v) => v.clone(),
312                None => {
313                    // Field absent on the matched build record — skip it.
314                    stats.project_misses += 1;
315                    continue;
316                }
317            },
318            None => config.on_missing.clone(),
319        };
320        set_field(left, &proj.as_, value, config.on_collision)?;
321    }
322    Ok(())
323}
324
325/// Resolve a dotted path against a JSON value, traversing objects only.
326///
327/// `"a.b.c"` descends three object levels. A missing key or a non-object
328/// intermediate yields `None`. An empty path yields the value itself.
329fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
330    if path.is_empty() {
331        return Some(value);
332    }
333    let mut cur = value;
334    for seg in path.split('.') {
335        match cur {
336            Value::Object(map) => cur = map.get(seg)?,
337            _ => return None,
338        }
339    }
340    Some(cur)
341}
342
343/// Set `left[name] = value` at the top level, applying the collision policy.
344fn set_field(
345    left: &mut Value,
346    name: &str,
347    value: Value,
348    on_collision: OnCollision,
349) -> Result<(), FaucetError> {
350    let obj = match left {
351        Value::Object(map) => map,
352        _ => {
353            return Err(FaucetError::Transform(
354                "join can only enrich object-shaped records".into(),
355            ));
356        }
357    };
358    if obj.contains_key(name) {
359        match on_collision {
360            OnCollision::Overwrite => {
361                obj.insert(name.to_string(), value);
362            }
363            OnCollision::Skip => {}
364            OnCollision::Error => {
365                return Err(FaucetError::Transform(format!(
366                    "join projection '{name}' collides with an existing field (on_collision: error)"
367                )));
368            }
369        }
370    } else {
371        obj.insert(name.to_string(), value);
372    }
373    Ok(())
374}
375
376/// Turn a JSON scalar into a canonical hash-map key string.
377///
378/// `Preserve` prefixes a type tag so `"42"` and `42` never collide. `Stringify`
379/// coerces scalars to their plain string form so they do. Non-scalar keys
380/// (objects/arrays) return `None` — they are not valid join keys.
381fn canonical_key(value: &Value, mode: KeyNormalize) -> Option<String> {
382    match mode {
383        KeyNormalize::Preserve => match value {
384            Value::String(s) => Some(format!("s:{s}")),
385            Value::Number(n) => Some(format!("n:{n}")),
386            Value::Bool(b) => Some(format!("b:{b}")),
387            Value::Null => None,
388            _ => None,
389        },
390        KeyNormalize::Stringify => match value {
391            Value::String(s) => Some(s.clone()),
392            Value::Number(n) => Some(n.to_string()),
393            Value::Bool(b) => Some(b.to_string()),
394            Value::Null => None,
395            _ => None,
396        },
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use serde_json::json;
404
405    fn cfg() -> JoinConfig {
406        JoinConfig {
407            build_key: "id".into(),
408            probe_key: "customer_id".into(),
409            projections: vec![
410                Projection {
411                    from: "tier".into(),
412                    as_: "customer_tier".into(),
413                },
414                Projection {
415                    from: "signup_date".into(),
416                    as_: "customer_signup_date".into(),
417                },
418            ],
419            ..Default::default()
420        }
421    }
422
423    fn build_customers(join: &mut HashJoin) {
424        join.add_build_page(vec![
425            json!({"id": 1, "tier": "gold", "signup_date": "2020-01-01"}),
426            json!({"id": 2, "tier": "silver", "signup_date": "2021-06-15"}),
427        ])
428        .unwrap();
429    }
430
431    // ── get_path ─────────────────────────────────────────────────────────────
432
433    #[test]
434    fn get_path_top_level() {
435        let v = json!({"a": 1});
436        assert_eq!(get_path(&v, "a"), Some(&json!(1)));
437    }
438
439    #[test]
440    fn get_path_nested() {
441        let v = json!({"a": {"b": {"c": 42}}});
442        assert_eq!(get_path(&v, "a.b.c"), Some(&json!(42)));
443    }
444
445    #[test]
446    fn get_path_missing() {
447        let v = json!({"a": 1});
448        assert_eq!(get_path(&v, "b"), None);
449        assert_eq!(get_path(&v, "a.b"), None);
450    }
451
452    #[test]
453    fn get_path_empty_returns_self() {
454        let v = json!({"a": 1});
455        assert_eq!(get_path(&v, ""), Some(&v));
456    }
457
458    // ── inner join ─────────────────────────────────────────────────────────────
459
460    #[test]
461    fn inner_match_enriches() {
462        let mut j = HashJoin::new(cfg());
463        build_customers(&mut j);
464        let out = j
465            .probe_page(vec![json!({"order": "A", "customer_id": 1})])
466            .unwrap();
467        assert_eq!(out.len(), 1);
468        assert_eq!(out[0]["customer_tier"], json!("gold"));
469        assert_eq!(out[0]["customer_signup_date"], json!("2020-01-01"));
470        assert_eq!(out[0]["order"], json!("A"));
471        assert_eq!(j.stats().matches, 1);
472        assert_eq!(j.stats().misses, 0);
473    }
474
475    #[test]
476    fn inner_no_match_drops() {
477        let mut j = HashJoin::new(cfg());
478        build_customers(&mut j);
479        let out = j
480            .probe_page(vec![json!({"order": "A", "customer_id": 999})])
481            .unwrap();
482        assert!(out.is_empty());
483        assert_eq!(j.stats().matches, 0);
484        assert_eq!(j.stats().misses, 1);
485    }
486
487    // ── left join ─────────────────────────────────────────────────────────────
488
489    #[test]
490    fn left_match_enriches() {
491        let mut c = cfg();
492        c.mode = JoinMode::Left;
493        let mut j = HashJoin::new(c);
494        build_customers(&mut j);
495        let out = j
496            .probe_page(vec![json!({"order": "A", "customer_id": 2})])
497            .unwrap();
498        assert_eq!(out.len(), 1);
499        assert_eq!(out[0]["customer_tier"], json!("silver"));
500    }
501
502    #[test]
503    fn left_no_match_passes_through_with_on_missing() {
504        let mut c = cfg();
505        c.mode = JoinMode::Left;
506        c.on_missing = json!("UNKNOWN");
507        let mut j = HashJoin::new(c);
508        build_customers(&mut j);
509        let out = j
510            .probe_page(vec![json!({"order": "A", "customer_id": 999})])
511            .unwrap();
512        assert_eq!(out.len(), 1);
513        assert_eq!(out[0]["order"], json!("A"));
514        assert_eq!(out[0]["customer_tier"], json!("UNKNOWN"));
515        assert_eq!(out[0]["customer_signup_date"], json!("UNKNOWN"));
516        assert_eq!(j.stats().misses, 1);
517    }
518
519    #[test]
520    fn left_no_match_default_on_missing_is_null() {
521        let mut c = cfg();
522        c.mode = JoinMode::Left;
523        let mut j = HashJoin::new(c);
524        build_customers(&mut j);
525        let out = j.probe_page(vec![json!({"customer_id": 999})]).unwrap();
526        assert_eq!(out[0]["customer_tier"], Value::Null);
527    }
528
529    // ── duplicate build keys ────────────────────────────────────────────────────
530
531    #[test]
532    fn duplicate_first_wins() {
533        let mut c = cfg();
534        c.on_duplicate = OnDuplicate::First;
535        let mut j = HashJoin::new(c);
536        j.add_build_page(vec![
537            json!({"id": 1, "tier": "gold", "signup_date": "d1"}),
538            json!({"id": 1, "tier": "platinum", "signup_date": "d2"}),
539        ])
540        .unwrap();
541        let out = j.probe_page(vec![json!({"customer_id": 1})]).unwrap();
542        assert_eq!(out.len(), 1);
543        assert_eq!(out[0]["customer_tier"], json!("gold"));
544        assert_eq!(j.stats().duplicates, 1);
545    }
546
547    #[test]
548    fn duplicates_counts_keys_not_extra_records() {
549        let mut j = HashJoin::new(cfg());
550        j.add_build_page(vec![
551            json!({"id": 1, "tier": "a"}),
552            json!({"id": 1, "tier": "b"}),
553            json!({"id": 1, "tier": "c"}),
554            json!({"id": 2, "tier": "d"}),
555            json!({"id": 2, "tier": "e"}),
556            json!({"id": 3, "tier": "f"}),
557        ])
558        .unwrap();
559        assert_eq!(j.stats().duplicates, 2);
560    }
561
562    #[test]
563    fn duplicate_cartesian_emits_all() {
564        let mut c = cfg();
565        c.on_duplicate = OnDuplicate::Cartesian;
566        let mut j = HashJoin::new(c);
567        j.add_build_page(vec![
568            json!({"id": 1, "tier": "gold", "signup_date": "d1"}),
569            json!({"id": 1, "tier": "platinum", "signup_date": "d2"}),
570        ])
571        .unwrap();
572        let out = j.probe_page(vec![json!({"customer_id": 1})]).unwrap();
573        assert_eq!(out.len(), 2);
574        assert_eq!(out[0]["customer_tier"], json!("gold"));
575        assert_eq!(out[1]["customer_tier"], json!("platinum"));
576        assert_eq!(j.stats().matches, 1);
577    }
578
579    // ── null keys ───────────────────────────────────────────────────────────────
580
581    #[test]
582    fn null_build_key_is_skipped() {
583        let mut j = HashJoin::new(cfg());
584        j.add_build_page(vec![
585            json!({"id": null, "tier": "gold"}),
586            json!({"tier": "silver"}), // missing id
587            json!({"id": 3, "tier": "bronze", "signup_date": "d"}),
588        ])
589        .unwrap();
590        assert_eq!(j.stats().build_nulls, 2);
591        assert_eq!(j.indexed_keys(), 1);
592    }
593
594    #[test]
595    fn null_probe_key_inner_drops() {
596        let mut j = HashJoin::new(cfg());
597        build_customers(&mut j);
598        let out = j.probe_page(vec![json!({"order": "A"})]).unwrap();
599        assert!(out.is_empty());
600        assert_eq!(j.stats().misses, 1);
601    }
602
603    #[test]
604    fn null_probe_key_left_emits_on_missing() {
605        let mut c = cfg();
606        c.mode = JoinMode::Left;
607        let mut j = HashJoin::new(c);
608        build_customers(&mut j);
609        let out = j.probe_page(vec![json!({"order": "A"})]).unwrap();
610        assert_eq!(out.len(), 1);
611        assert_eq!(out[0]["customer_tier"], Value::Null);
612    }
613
614    // ── key normalization ────────────────────────────────────────────────────────
615
616    #[test]
617    fn preserve_does_not_coerce_types() {
618        let mut c = cfg();
619        c.key_normalize = KeyNormalize::Preserve;
620        let mut j = HashJoin::new(c);
621        j.add_build_page(vec![json!({"id": "42", "tier": "str", "signup_date": "d"})])
622            .unwrap();
623        // Probe with numeric 42 must NOT match the string "42".
624        let out = j.probe_page(vec![json!({"customer_id": 42})]).unwrap();
625        assert!(out.is_empty());
626    }
627
628    #[test]
629    fn stringify_coerces_types() {
630        let mut c = cfg();
631        c.key_normalize = KeyNormalize::Stringify;
632        let mut j = HashJoin::new(c);
633        j.add_build_page(vec![json!({"id": "42", "tier": "str", "signup_date": "d"})])
634            .unwrap();
635        let out = j.probe_page(vec![json!({"customer_id": 42})]).unwrap();
636        assert_eq!(out.len(), 1);
637        assert_eq!(out[0]["customer_tier"], json!("str"));
638    }
639
640    // ── projection edge cases ─────────────────────────────────────────────────────
641
642    #[test]
643    fn projection_missing_source_field_is_skipped() {
644        let mut j = HashJoin::new(cfg());
645        // Build record lacks `signup_date`.
646        j.add_build_page(vec![json!({"id": 1, "tier": "gold"})])
647            .unwrap();
648        let out = j.probe_page(vec![json!({"customer_id": 1})]).unwrap();
649        assert_eq!(out[0]["customer_tier"], json!("gold"));
650        assert!(out[0].get("customer_signup_date").is_none());
651        assert_eq!(j.stats().project_misses, 1);
652    }
653
654    #[test]
655    fn projection_collision_overwrite() {
656        let mut c = cfg();
657        c.on_collision = OnCollision::Overwrite;
658        let mut j = HashJoin::new(c);
659        build_customers(&mut j);
660        let out = j
661            .probe_page(vec![json!({"customer_id": 1, "customer_tier": "OLD"})])
662            .unwrap();
663        assert_eq!(out[0]["customer_tier"], json!("gold"));
664    }
665
666    #[test]
667    fn projection_collision_skip() {
668        let mut c = cfg();
669        c.on_collision = OnCollision::Skip;
670        let mut j = HashJoin::new(c);
671        build_customers(&mut j);
672        let out = j
673            .probe_page(vec![json!({"customer_id": 1, "customer_tier": "OLD"})])
674            .unwrap();
675        assert_eq!(out[0]["customer_tier"], json!("OLD"));
676    }
677
678    #[test]
679    fn projection_collision_error() {
680        let mut c = cfg();
681        c.on_collision = OnCollision::Error;
682        let mut j = HashJoin::new(c);
683        build_customers(&mut j);
684        let err = j
685            .probe_page(vec![json!({"customer_id": 1, "customer_tier": "OLD"})])
686            .unwrap_err();
687        assert!(matches!(err, FaucetError::Transform(_)));
688        assert!(err.to_string().contains("collides"));
689    }
690
691    #[test]
692    fn nested_projection_path() {
693        let mut c = cfg();
694        c.projections = vec![Projection {
695            from: "profile.tier".into(),
696            as_: "tier".into(),
697        }];
698        let mut j = HashJoin::new(c);
699        j.add_build_page(vec![json!({"id": 1, "profile": {"tier": "gold"}})])
700            .unwrap();
701        let out = j.probe_page(vec![json!({"customer_id": 1})]).unwrap();
702        assert_eq!(out[0]["tier"], json!("gold"));
703    }
704
705    // ── overflow ─────────────────────────────────────────────────────────────────
706
707    #[test]
708    fn build_overflow_errors() {
709        let mut c = cfg();
710        c.max_build_records = 2;
711        let mut j = HashJoin::new(c);
712        let err = j
713            .add_build_page(vec![
714                json!({"id": 1, "tier": "a", "signup_date": "d"}),
715                json!({"id": 2, "tier": "b", "signup_date": "d"}),
716                json!({"id": 3, "tier": "c", "signup_date": "d"}),
717            ])
718            .unwrap_err();
719        assert!(matches!(err, FaucetError::Transform(_)));
720        assert!(err.to_string().contains("max_build_records"));
721    }
722
723    #[test]
724    fn build_at_exactly_limit_ok() {
725        let mut c = cfg();
726        c.max_build_records = 2;
727        let mut j = HashJoin::new(c);
728        assert!(
729            j.add_build_page(vec![
730                json!({"id": 1, "tier": "a", "signup_date": "d"}),
731                json!({"id": 2, "tier": "b", "signup_date": "d"}),
732            ])
733            .is_ok()
734        );
735    }
736
737    // ── multi-page build ───────────────────────────────────────────────────────────
738
739    #[test]
740    fn build_across_multiple_pages() {
741        let mut j = HashJoin::new(cfg());
742        j.add_build_page(vec![json!({"id": 1, "tier": "gold", "signup_date": "d1"})])
743            .unwrap();
744        j.add_build_page(vec![
745            json!({"id": 2, "tier": "silver", "signup_date": "d2"}),
746        ])
747        .unwrap();
748        assert_eq!(j.indexed_keys(), 2);
749        let out = j
750            .probe_page(vec![json!({"customer_id": 1}), json!({"customer_id": 2})])
751            .unwrap();
752        assert_eq!(out.len(), 2);
753    }
754
755    #[test]
756    fn non_object_record_errors() {
757        // A non-object probe record resolves to a null key (miss). Under inner
758        // mode it is simply dropped; under left mode we attempt to project onto
759        // it, which is where the "object-shaped records only" guard fires.
760        let mut c = cfg();
761        c.mode = JoinMode::Left;
762        let mut j = HashJoin::new(c);
763        build_customers(&mut j);
764        let err = j.probe_page(vec![json!(42)]).unwrap_err();
765        assert!(matches!(err, FaucetError::Transform(_)));
766    }
767
768    #[test]
769    fn non_object_probe_inner_is_dropped_not_errored() {
770        let mut j = HashJoin::new(cfg());
771        build_customers(&mut j);
772        // inner mode: non-object → null key → miss → dropped, no error.
773        let out = j.probe_page(vec![json!(42)]).unwrap();
774        assert!(out.is_empty());
775        assert_eq!(j.stats().misses, 1);
776    }
777
778    #[test]
779    fn bool_key_matches() {
780        let mut c = cfg();
781        c.build_key = "active".into();
782        c.probe_key = "is_active".into();
783        c.projections = vec![Projection {
784            from: "label".into(),
785            as_: "label".into(),
786        }];
787        let mut j = HashJoin::new(c);
788        j.add_build_page(vec![json!({"active": true, "label": "yes"})])
789            .unwrap();
790        let out = j.probe_page(vec![json!({"is_active": true})]).unwrap();
791        assert_eq!(out[0]["label"], json!("yes"));
792    }
793
794    #[test]
795    fn enums_serde_roundtrip() {
796        assert_eq!(serde_json::to_value(JoinMode::Left).unwrap(), json!("left"));
797        assert_eq!(
798            serde_json::from_value::<OnDuplicate>(json!("cartesian")).unwrap(),
799            OnDuplicate::Cartesian
800        );
801        assert_eq!(
802            serde_json::from_value::<OnCollision>(json!("skip")).unwrap(),
803            OnCollision::Skip
804        );
805        assert_eq!(
806            serde_json::from_value::<KeyNormalize>(json!("stringify")).unwrap(),
807            KeyNormalize::Stringify
808        );
809    }
810
811    #[test]
812    fn join_mode_display() {
813        assert_eq!(JoinMode::Inner.to_string(), "inner");
814        assert_eq!(JoinMode::Left.to_string(), "left");
815    }
816
817    #[test]
818    fn projection_serde_uses_as_rename() {
819        let p: Projection = serde_json::from_value(json!({"from": "a", "as": "b"})).unwrap();
820        assert_eq!(p.from, "a");
821        assert_eq!(p.as_, "b");
822    }
823}