1use crate::error::FaucetError;
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32use std::collections::HashMap;
33
34pub const DEFAULT_MAX_BUILD_RECORDS: usize = 10_000_000;
36
37#[derive(
39 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
40)]
41#[serde(rename_all = "lowercase")]
42pub enum JoinMode {
43 #[default]
45 Inner,
46 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#[derive(
62 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
63)]
64#[serde(rename_all = "lowercase")]
65pub enum OnDuplicate {
66 #[default]
68 First,
69 Cartesian,
72}
73
74#[derive(
77 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
78)]
79#[serde(rename_all = "lowercase")]
80pub enum OnCollision {
81 #[default]
83 Overwrite,
84 Skip,
86 Error,
88}
89
90#[derive(
92 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
93)]
94#[serde(rename_all = "lowercase")]
95pub enum KeyNormalize {
96 #[default]
98 Preserve,
99 Stringify,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
107pub struct Projection {
108 pub from: String,
110 #[serde(rename = "as")]
112 pub as_: String,
113}
114
115#[derive(Debug, Clone)]
117pub struct JoinConfig {
118 pub mode: JoinMode,
120 pub build_key: String,
122 pub probe_key: String,
124 pub projections: Vec<Projection>,
126 pub on_missing: Value,
128 pub on_duplicate: OnDuplicate,
130 pub on_collision: OnCollision,
132 pub key_normalize: KeyNormalize,
134 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub struct JoinStats {
157 pub build_records: u64,
159 pub build_nulls: u64,
161 pub duplicates: u64,
163 pub probe_records: u64,
165 pub matches: u64,
167 pub misses: u64,
169 pub project_misses: u64,
172}
173
174#[derive(Debug)]
176pub struct HashJoin {
177 config: JoinConfig,
178 index: HashMap<String, Vec<Value>>,
180 stats: JoinStats,
181}
182
183impl HashJoin {
184 pub fn new(config: JoinConfig) -> Self {
186 Self {
187 config,
188 index: HashMap::new(),
189 stats: JoinStats::default(),
190 }
191 }
192
193 pub fn stats(&self) -> &JoinStats {
195 &self.stats
196 }
197
198 pub fn config(&self) -> &JoinConfig {
200 &self.config
201 }
202
203 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 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 pub fn indexed_keys(&self) -> usize {
244 self.index.len()
245 }
246
247 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 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 }
294 }
295 }
296 Ok(out)
297 }
298}
299
300fn 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 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
325fn 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
343fn 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
376fn 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 #[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 #[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 #[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 #[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 #[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"}), 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 #[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 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 #[test]
643 fn projection_missing_source_field_is_skipped() {
644 let mut j = HashJoin::new(cfg());
645 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 #[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 #[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 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 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}