Skip to main content

reddb_engine/query/executors/
join.rs

1//! JOIN Executor Algorithms
2//!
3//! Provides multiple join strategies for different scenarios:
4//! - Hash Join: O(n+m) for equi-joins, best for large datasets
5//! - Nested Loop Join: O(n*m) fallback, works with any condition
6//! - Merge Join: O(n log n + m log m) for sorted inputs
7//!
8//! # Architecture
9//!
10//! ```text
11//! ┌─────────────────────────────────────────────────────────────┐
12//! │                    JoinExecutor                              │
13//! ├─────────────────────────────────────────────────────────────┤
14//! │  ┌───────────┐  ┌───────────────┐  ┌─────────────────────┐  │
15//! │  │ Hash Join │  │ Nested Loop   │  │   Merge Join        │  │
16//! │  │  (fast)   │  │  (fallback)   │  │   (sorted)          │  │
17//! │  └─────┬─────┘  └───────┬───────┘  └──────────┬──────────┘  │
18//! │        │                │                      │             │
19//! │        └────────────────┼──────────────────────┘             │
20//! │                         ▼                                    │
21//! │              ┌──────────────────────┐                        │
22//! │              │   JoinPlanner        │                        │
23//! │              │   (cost-based)       │                        │
24//! │              └──────────────────────┘                        │
25//! └─────────────────────────────────────────────────────────────┘
26//! ```
27
28use std::collections::hash_map::RandomState;
29use std::collections::HashMap;
30use std::hash::{BuildHasher, Hash, Hasher};
31
32use super::super::engine::binding::{Binding, Value, Var};
33use super::value_compare::total_compare_values;
34
35// ============================================================================
36// Join Types
37// ============================================================================
38
39/// Type of JOIN operation
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum JoinType {
42    /// INNER JOIN - only matching rows
43    Inner,
44    /// LEFT JOIN - all left rows, matching right
45    Left,
46    /// RIGHT JOIN - all right rows, matching left
47    Right,
48    /// CROSS JOIN - Cartesian product
49    Cross,
50    /// FULL OUTER JOIN - all rows from both
51    FullOuter,
52}
53
54/// Join condition for filtering matches
55#[derive(Debug, Clone)]
56pub enum JoinCondition {
57    /// Equality on columns: left.col = right.col
58    Eq(Var, Var),
59    /// Multiple equality conditions (AND)
60    And(Vec<JoinCondition>),
61    /// No condition (cross join)
62    None,
63}
64
65impl JoinCondition {
66    /// Get all left-side variables
67    pub fn left_vars(&self) -> Vec<Var> {
68        match self {
69            JoinCondition::Eq(left, _) => vec![left.clone()],
70            JoinCondition::And(conditions) => {
71                conditions.iter().flat_map(|c| c.left_vars()).collect()
72            }
73            JoinCondition::None => Vec::new(),
74        }
75    }
76
77    /// Get all right-side variables
78    pub fn right_vars(&self) -> Vec<Var> {
79        match self {
80            JoinCondition::Eq(_, right) => vec![right.clone()],
81            JoinCondition::And(conditions) => {
82                conditions.iter().flat_map(|c| c.right_vars()).collect()
83            }
84            JoinCondition::None => Vec::new(),
85        }
86    }
87}
88
89// ============================================================================
90// Join Algorithm Selection
91// ============================================================================
92
93/// Strategy to use for executing the join
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum JoinStrategy {
96    /// Hash join - build hash table on smaller side
97    Hash,
98    /// Nested loop - iterate all combinations
99    NestedLoop,
100    /// Merge join - for pre-sorted inputs
101    Merge,
102}
103
104/// Statistics for choosing join strategy
105#[derive(Debug, Clone)]
106pub struct JoinStats {
107    pub left_cardinality: usize,
108    pub right_cardinality: usize,
109    pub left_sorted: bool,
110    pub right_sorted: bool,
111    pub condition_selectivity: f64,
112}
113
114impl Default for JoinStats {
115    fn default() -> Self {
116        Self {
117            left_cardinality: 0,
118            right_cardinality: 0,
119            left_sorted: false,
120            right_sorted: false,
121            condition_selectivity: 1.0,
122        }
123    }
124}
125
126/// Choose optimal join strategy based on statistics
127pub fn choose_strategy(stats: &JoinStats, condition: &JoinCondition) -> JoinStrategy {
128    // Cross join always uses nested loop (no condition to hash on)
129    if matches!(condition, JoinCondition::None) {
130        return JoinStrategy::NestedLoop;
131    }
132
133    // If both sides are sorted on join keys, use merge join
134    if stats.left_sorted && stats.right_sorted {
135        return JoinStrategy::Merge;
136    }
137
138    // For very small tables, nested loop is fine
139    let total = stats.left_cardinality * stats.right_cardinality;
140    if total < 1000 {
141        return JoinStrategy::NestedLoop;
142    }
143
144    // Default to hash join for equi-joins
145    if matches!(condition, JoinCondition::Eq(_, _) | JoinCondition::And(_)) {
146        return JoinStrategy::Hash;
147    }
148
149    JoinStrategy::NestedLoop
150}
151
152// ============================================================================
153// Hash Join Implementation
154// ============================================================================
155
156/// Hash key for join matching
157#[derive(Clone, PartialEq, Eq)]
158#[doc(hidden)]
159pub struct HashKey(Vec<Option<Value>>);
160
161impl Hash for HashKey {
162    fn hash<H: Hasher>(&self, state: &mut H) {
163        for value in &self.0 {
164            hash_key_value(value.as_ref(), state);
165        }
166    }
167}
168
169/// Hash one join-key column. `NULL` and an absent binding hash alike (tag 0) —
170/// they are also equal-by-absence nowhere, so a null key still finds its bucket
171/// and is then rejected by the element-wise equality check.
172fn hash_key_value<H: Hasher>(value: Option<&Value>, state: &mut H) {
173    match value {
174        Some(Value::String(s)) => {
175            1u8.hash(state);
176            s.hash(state);
177        }
178        Some(Value::Integer(i)) => {
179            2u8.hash(state);
180            i.hash(state);
181        }
182        Some(Value::Float(f)) => {
183            3u8.hash(state);
184            f.to_bits().hash(state);
185        }
186        Some(Value::Boolean(b)) => {
187            4u8.hash(state);
188            b.hash(state);
189        }
190        Some(Value::Uri(u)) => {
191            5u8.hash(state);
192            u.hash(state);
193        }
194        Some(Value::Node(n)) => {
195            6u8.hash(state);
196            n.hash(state);
197        }
198        Some(Value::Edge(e)) => {
199            7u8.hash(state);
200            e.hash(state);
201        }
202        Some(Value::Null) | None => {
203            0u8.hash(state);
204        }
205    }
206}
207
208/// Hash a row's join key *in place* — without materializing an owned `HashKey`.
209/// This is the probe-side hot path: one hash per probe row, zero allocations.
210///
211/// The caller supplies a `RandomState` (one per join) so bucket hashes stay
212/// randomly seeded like the `HashMap<HashKey, _>` this replaced — a fixed-key
213/// hasher would let precomputed collisions degrade the build to O(n²).
214#[doc(hidden)]
215pub fn key_hash(state: &RandomState, binding: &Binding, vars: &[Var]) -> u64 {
216    let mut hasher = state.build_hasher();
217    for var in vars {
218        hash_key_value(binding.get(var), &mut hasher);
219    }
220    hasher.finish()
221}
222
223/// Element-wise equality between a stored build key and a probe row's key,
224/// compared through references so the probe key is never cloned. Identical to
225/// `extract_key(binding, vars) == *key`, including `None`/`NULL` columns.
226#[doc(hidden)]
227pub fn key_matches(key: &HashKey, binding: &Binding, vars: &[Var]) -> bool {
228    key.0.len() == vars.len()
229        && key
230            .0
231            .iter()
232            .zip(vars)
233            .all(|(stored, var)| stored.as_ref() == binding.get(var))
234}
235
236/// Execute a hash join
237pub fn hash_join(
238    left: Vec<Binding>,
239    right: Vec<Binding>,
240    condition: &JoinCondition,
241    join_type: JoinType,
242) -> Vec<Binding> {
243    let left_keys = condition.left_vars();
244    let right_keys = condition.right_vars();
245
246    if left_keys.is_empty() {
247        // No keys means cross join behavior
248        return nested_loop_join(left, right, condition, join_type);
249    }
250
251    // Build phase: build hash table on the smaller side
252    let (build_side, probe_side, build_keys, probe_keys, is_left_build) =
253        if left.len() <= right.len() {
254            (&left, &right, &left_keys, &right_keys, true)
255        } else {
256            (&right, &left, &right_keys, &left_keys, false)
257        };
258
259    // Build hash table, bucketed by the key's precomputed hash. The build side
260    // owns its keys; collisions inside a bucket are resolved by element-wise
261    // equality, exactly as the `HashMap<HashKey, _>` it replaces did. Keeping
262    // the hash explicit is what lets the probe side look a row up *without*
263    // cloning its key values (see `key_hash` / `key_matches`).
264    #[allow(clippy::type_complexity)]
265    let mut hash_table: HashMap<u64, Vec<(HashKey, Vec<&Binding>)>> = HashMap::new();
266    let hash_state = RandomState::new();
267    for binding in build_side {
268        let hash = key_hash(&hash_state, binding, build_keys);
269        let bucket = hash_table.entry(hash).or_default();
270        match bucket
271            .iter_mut()
272            .find(|(key, _)| key_matches(key, binding, build_keys))
273        {
274            Some((_, bindings)) => bindings.push(binding),
275            None => bucket.push((extract_key(binding, build_keys), vec![binding])),
276        }
277    }
278
279    // Probe phase
280    let mut results = Vec::new();
281    let mut matched_build: std::collections::HashSet<usize> = std::collections::HashSet::new();
282
283    for probe_binding in probe_side {
284        // Probe with a borrowed key: hash the row's join columns in place, then
285        // confirm the match element-wise. No `Value` is cloned per probe row.
286        let matches = hash_table
287            .get(&key_hash(&hash_state, probe_binding, probe_keys))
288            .and_then(|bucket: &Vec<(HashKey, Vec<&Binding>)>| {
289                bucket
290                    .iter()
291                    .find(|(key, _)| key_matches(key, probe_binding, probe_keys))
292                    .map(|(_, bindings)| bindings)
293            });
294
295        let mut had_match = false;
296        if let Some(build_bindings) = matches {
297            for &build_binding in build_bindings {
298                had_match = true;
299
300                // Remember which build rows were matched (for full outer)
301                if matches!(join_type, JoinType::FullOuter) {
302                    // We need to track by original index
303                    let original_idx = build_side
304                        .iter()
305                        .position(|b| std::ptr::eq(b, build_binding));
306                    if let Some(idx) = original_idx {
307                        matched_build.insert(idx);
308                    }
309                }
310
311                // Merge bindings
312                let merged = if is_left_build {
313                    merge_bindings(build_binding, probe_binding)
314                } else {
315                    merge_bindings(probe_binding, build_binding)
316                };
317                results.push(merged);
318            }
319        }
320
321        // Handle outer joins - add probe side with nulls if no match
322        if !had_match {
323            match join_type {
324                JoinType::Left if !is_left_build => {
325                    // probe_side is left, need to include unmatched left rows
326                    results.push(probe_binding.clone());
327                }
328                JoinType::Right if is_left_build => {
329                    // probe_side is right, need to include unmatched right rows
330                    results.push(probe_binding.clone());
331                }
332                JoinType::FullOuter => {
333                    results.push(probe_binding.clone());
334                }
335                _ => {}
336            }
337        }
338    }
339
340    // For full outer join, add unmatched build side rows
341    if matches!(join_type, JoinType::FullOuter) {
342        for (idx, binding) in build_side.iter().enumerate() {
343            if !matched_build.contains(&idx) {
344                results.push((*binding).clone());
345            }
346        }
347    }
348
349    // Handle LEFT/RIGHT join for the build side
350    match (join_type, is_left_build) {
351        (JoinType::Left, true) => {
352            // Build side is left, need to add unmatched left rows
353            let mut all_left_matched: std::collections::HashSet<usize> =
354                std::collections::HashSet::new();
355            for binding in &results {
356                // Check which left rows are in results
357                for (idx, left_binding) in left.iter().enumerate() {
358                    if bindings_match(binding, left_binding, &left_keys) {
359                        all_left_matched.insert(idx);
360                    }
361                }
362            }
363            for (idx, binding) in left.iter().enumerate() {
364                if !all_left_matched.contains(&idx) {
365                    results.push(binding.clone());
366                }
367            }
368        }
369        (JoinType::Right, false) => {
370            // Build side is right, need to add unmatched right rows
371            let mut all_right_matched: std::collections::HashSet<usize> =
372                std::collections::HashSet::new();
373            for binding in &results {
374                for (idx, right_binding) in right.iter().enumerate() {
375                    if bindings_match(binding, right_binding, &right_keys) {
376                        all_right_matched.insert(idx);
377                    }
378                }
379            }
380            for (idx, binding) in right.iter().enumerate() {
381                if !all_right_matched.contains(&idx) {
382                    results.push(binding.clone());
383                }
384            }
385        }
386        _ => {}
387    }
388
389    results
390}
391
392#[doc(hidden)]
393pub fn extract_key(binding: &Binding, vars: &[Var]) -> HashKey {
394    HashKey(vars.iter().map(|v| binding.get(v).cloned()).collect())
395}
396
397fn bindings_match(a: &Binding, b: &Binding, keys: &[Var]) -> bool {
398    keys.iter().all(|k| match (a.get(k), b.get(k)) {
399        (Some(v1), Some(v2)) => v1 == v2,
400        _ => false,
401    })
402}
403
404// ============================================================================
405// Nested Loop Join Implementation
406// ============================================================================
407
408/// Execute a nested loop join (O(n*m) but works with any condition)
409pub fn nested_loop_join(
410    left: Vec<Binding>,
411    right: Vec<Binding>,
412    condition: &JoinCondition,
413    join_type: JoinType,
414) -> Vec<Binding> {
415    let mut results = Vec::new();
416    let mut left_matched = vec![false; left.len()];
417    let mut right_matched = vec![false; right.len()];
418
419    for (left_idx, left_binding) in left.iter().enumerate() {
420        let mut found_match = false;
421
422        for (right_idx, right_binding) in right.iter().enumerate() {
423            if check_condition(left_binding, right_binding, condition) {
424                found_match = true;
425                left_matched[left_idx] = true;
426                right_matched[right_idx] = true;
427
428                let merged = merge_bindings(left_binding, right_binding);
429                results.push(merged);
430            }
431        }
432
433        // LEFT JOIN: include unmatched left rows
434        if !found_match && matches!(join_type, JoinType::Left | JoinType::FullOuter) {
435            results.push(left_binding.clone());
436        }
437    }
438
439    // RIGHT JOIN / FULL OUTER: include unmatched right rows
440    if matches!(join_type, JoinType::Right | JoinType::FullOuter) {
441        for (right_idx, right_binding) in right.iter().enumerate() {
442            if !right_matched[right_idx] {
443                results.push(right_binding.clone());
444            }
445        }
446    }
447
448    results
449}
450
451fn check_condition(left: &Binding, right: &Binding, condition: &JoinCondition) -> bool {
452    match condition {
453        JoinCondition::Eq(left_var, right_var) => {
454            match (left.get(left_var), right.get(right_var)) {
455                (Some(l), Some(r)) => l == r,
456                _ => false,
457            }
458        }
459        JoinCondition::And(conditions) => {
460            conditions.iter().all(|c| check_condition(left, right, c))
461        }
462        JoinCondition::None => true,
463    }
464}
465
466// ============================================================================
467// Merge Join Implementation
468// ============================================================================
469
470/// Execute a merge join (for sorted inputs)
471pub fn merge_join(
472    left: Vec<Binding>,
473    right: Vec<Binding>,
474    condition: &JoinCondition,
475    join_type: JoinType,
476) -> Vec<Binding> {
477    // For simplicity, fall back to hash join if not simple equality
478    // A full merge join would require sorted inputs and careful cursor management
479    let left_keys = condition.left_vars();
480    let right_keys = condition.right_vars();
481
482    if left_keys.is_empty() || right_keys.is_empty() {
483        return nested_loop_join(left, right, condition, join_type);
484    }
485
486    // Sort both sides by join keys
487    let mut left_sorted = left;
488    let mut right_sorted = right;
489
490    left_sorted.sort_by(|a, b| compare_by_keys(a, b, &left_keys));
491    right_sorted.sort_by(|a, b| compare_by_keys(a, b, &right_keys));
492
493    let mut results = Vec::new();
494    let mut left_idx = 0;
495    let mut right_idx = 0;
496    let mut left_matched = vec![false; left_sorted.len()];
497    let mut right_matched = vec![false; right_sorted.len()];
498
499    while left_idx < left_sorted.len() && right_idx < right_sorted.len() {
500        let left_key = extract_key(&left_sorted[left_idx], &left_keys);
501        let right_key = extract_key(&right_sorted[right_idx], &right_keys);
502
503        match compare_keys(&left_key, &right_key) {
504            std::cmp::Ordering::Less => {
505                // Left row has no match
506                if matches!(join_type, JoinType::Left | JoinType::FullOuter)
507                    && !left_matched[left_idx]
508                {
509                    results.push(left_sorted[left_idx].clone());
510                }
511                left_idx += 1;
512            }
513            std::cmp::Ordering::Greater => {
514                // Right row has no match
515                if matches!(join_type, JoinType::Right | JoinType::FullOuter)
516                    && !right_matched[right_idx]
517                {
518                    results.push(right_sorted[right_idx].clone());
519                }
520                right_idx += 1;
521            }
522            std::cmp::Ordering::Equal => {
523                // Match found - need to handle duplicates
524                let match_start_right = right_idx;
525
526                // Find all matching right rows
527                while right_idx < right_sorted.len() {
528                    let current_right_key = extract_key(&right_sorted[right_idx], &right_keys);
529                    if compare_keys(&left_key, &current_right_key) != std::cmp::Ordering::Equal {
530                        break;
531                    }
532
533                    left_matched[left_idx] = true;
534                    right_matched[right_idx] = true;
535
536                    let merged = merge_bindings(&left_sorted[left_idx], &right_sorted[right_idx]);
537                    results.push(merged);
538                    right_idx += 1;
539                }
540
541                // Check for more left rows with same key
542                left_idx += 1;
543                while left_idx < left_sorted.len() {
544                    let current_left_key = extract_key(&left_sorted[left_idx], &left_keys);
545                    if compare_keys(&current_left_key, &left_key) != std::cmp::Ordering::Equal {
546                        break;
547                    }
548
549                    // Match with all right rows in the group
550                    for right_row in right_sorted.iter().take(right_idx).skip(match_start_right) {
551                        left_matched[left_idx] = true;
552                        let merged = merge_bindings(&left_sorted[left_idx], right_row);
553                        results.push(merged);
554                    }
555                    left_idx += 1;
556                }
557
558                // Reset right index for next left group
559                right_idx = match_start_right;
560                if left_idx >= left_sorted.len() || {
561                    let next_left_key = extract_key(
562                        &left_sorted[left_idx.min(left_sorted.len() - 1)],
563                        &left_keys,
564                    );
565                    compare_keys(&next_left_key, &left_key) != std::cmp::Ordering::Equal
566                } {
567                    // Advance past the matching right rows
568                    while right_idx < right_sorted.len() {
569                        let current_right_key = extract_key(&right_sorted[right_idx], &right_keys);
570                        if compare_keys(&left_key, &current_right_key) != std::cmp::Ordering::Equal
571                        {
572                            break;
573                        }
574                        right_idx += 1;
575                    }
576                }
577            }
578        }
579    }
580
581    // Handle remaining unmatched rows
582    while left_idx < left_sorted.len() {
583        if matches!(join_type, JoinType::Left | JoinType::FullOuter) && !left_matched[left_idx] {
584            results.push(left_sorted[left_idx].clone());
585        }
586        left_idx += 1;
587    }
588
589    while right_idx < right_sorted.len() {
590        if matches!(join_type, JoinType::Right | JoinType::FullOuter) && !right_matched[right_idx] {
591            results.push(right_sorted[right_idx].clone());
592        }
593        right_idx += 1;
594    }
595
596    results
597}
598
599fn compare_by_keys(a: &Binding, b: &Binding, keys: &[Var]) -> std::cmp::Ordering {
600    for key in keys {
601        match (a.get(key), b.get(key)) {
602            (Some(av), Some(bv)) => {
603                let cmp = total_compare_values(av, bv);
604                if cmp != std::cmp::Ordering::Equal {
605                    return cmp;
606                }
607            }
608            (Some(_), None) => return std::cmp::Ordering::Less,
609            (None, Some(_)) => return std::cmp::Ordering::Greater,
610            (None, None) => {}
611        }
612    }
613    std::cmp::Ordering::Equal
614}
615
616fn compare_keys(a: &HashKey, b: &HashKey) -> std::cmp::Ordering {
617    for (av, bv) in a.0.iter().zip(b.0.iter()) {
618        match (av, bv) {
619            (Some(av), Some(bv)) => {
620                let cmp = total_compare_values(av, bv);
621                if cmp != std::cmp::Ordering::Equal {
622                    return cmp;
623                }
624            }
625            (Some(_), None) => return std::cmp::Ordering::Less,
626            (None, Some(_)) => return std::cmp::Ordering::Greater,
627            (None, None) => {}
628        }
629    }
630    std::cmp::Ordering::Equal
631}
632
633// ============================================================================
634// Binding Merge
635// ============================================================================
636
637/// Merge two bindings, preferring values from left
638fn merge_bindings(left: &Binding, right: &Binding) -> Binding {
639    // Start with left binding, then try to merge right
640    // The Binding::merge method handles this properly
641    if let Some(merged) = left.merge(right) {
642        merged
643    } else {
644        // If conflict, just return left (shouldn't happen in proper joins)
645        left.clone()
646    }
647}
648
649// ============================================================================
650// Unified Join Executor
651// ============================================================================
652
653/// Execute a join operation using the optimal strategy
654pub fn execute_join(
655    left: Vec<Binding>,
656    right: Vec<Binding>,
657    condition: JoinCondition,
658    join_type: JoinType,
659    stats: Option<JoinStats>,
660) -> Vec<Binding> {
661    // Determine strategy
662    let actual_stats = stats.unwrap_or(JoinStats {
663        left_cardinality: left.len(),
664        right_cardinality: right.len(),
665        left_sorted: false,
666        right_sorted: false,
667        condition_selectivity: 1.0,
668    });
669
670    let strategy = choose_strategy(&actual_stats, &condition);
671
672    match strategy {
673        JoinStrategy::Hash => hash_join(left, right, &condition, join_type),
674        JoinStrategy::NestedLoop => nested_loop_join(left, right, &condition, join_type),
675        JoinStrategy::Merge => merge_join(left, right, &condition, join_type),
676    }
677}
678
679// ============================================================================
680// Tests
681// ============================================================================
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    fn make_binding(pairs: &[(&str, &str)]) -> Binding {
688        // Build the binding using Binding::one and then merge
689        if pairs.is_empty() {
690            return Binding::empty();
691        }
692
693        let mut result = Binding::one(Var::new(pairs[0].0), Value::String(pairs[0].1.to_string()));
694
695        for (k, v) in pairs.iter().skip(1) {
696            let next = Binding::one(Var::new(k), Value::String(v.to_string()));
697            result = result.merge(&next).unwrap_or(result);
698        }
699
700        result
701    }
702
703    #[test]
704    fn test_inner_join() {
705        let left = vec![
706            make_binding(&[("id", "1"), ("name", "Alice")]),
707            make_binding(&[("id", "2"), ("name", "Bob")]),
708            make_binding(&[("id", "3"), ("name", "Charlie")]),
709        ];
710
711        let right = vec![
712            make_binding(&[("user_id", "1"), ("score", "100")]),
713            make_binding(&[("user_id", "2"), ("score", "90")]),
714            make_binding(&[("user_id", "4"), ("score", "80")]),
715        ];
716
717        let condition = JoinCondition::Eq(Var::new("id"), Var::new("user_id"));
718        let results = execute_join(left, right, condition, JoinType::Inner, None);
719
720        assert_eq!(results.len(), 2);
721        assert!(results
722            .iter()
723            .any(|b| b.get(&Var::new("name")) == Some(&Value::String("Alice".to_string()))));
724        assert!(results
725            .iter()
726            .any(|b| b.get(&Var::new("name")) == Some(&Value::String("Bob".to_string()))));
727    }
728
729    #[test]
730    fn test_left_join() {
731        let left = vec![
732            make_binding(&[("id", "1"), ("name", "Alice")]),
733            make_binding(&[("id", "2"), ("name", "Bob")]),
734            make_binding(&[("id", "3"), ("name", "Charlie")]),
735        ];
736
737        let right = vec![make_binding(&[("user_id", "1"), ("score", "100")])];
738
739        let condition = JoinCondition::Eq(Var::new("id"), Var::new("user_id"));
740        let results = execute_join(left, right, condition, JoinType::Left, None);
741
742        assert_eq!(results.len(), 3); // All left rows
743        assert!(results
744            .iter()
745            .any(|b| b.get(&Var::new("name")) == Some(&Value::String("Charlie".to_string()))));
746    }
747
748    #[test]
749    fn test_right_join() {
750        let left = vec![make_binding(&[("id", "1"), ("name", "Alice")])];
751
752        let right = vec![
753            make_binding(&[("user_id", "1"), ("score", "100")]),
754            make_binding(&[("user_id", "2"), ("score", "90")]),
755            make_binding(&[("user_id", "3"), ("score", "80")]),
756        ];
757
758        let condition = JoinCondition::Eq(Var::new("id"), Var::new("user_id"));
759        let results = execute_join(left, right, condition, JoinType::Right, None);
760
761        assert_eq!(results.len(), 3); // All right rows
762    }
763
764    #[test]
765    fn test_cross_join() {
766        let left = vec![make_binding(&[("a", "1")]), make_binding(&[("a", "2")])];
767
768        let right = vec![
769            make_binding(&[("b", "x")]),
770            make_binding(&[("b", "y")]),
771            make_binding(&[("b", "z")]),
772        ];
773
774        let results = execute_join(left, right, JoinCondition::None, JoinType::Cross, None);
775
776        assert_eq!(results.len(), 6); // 2 * 3 = 6
777    }
778
779    #[test]
780    fn test_merge_join() {
781        let left = vec![
782            make_binding(&[("id", "1"), ("name", "Alice")]),
783            make_binding(&[("id", "2"), ("name", "Bob")]),
784        ];
785
786        let right = vec![
787            make_binding(&[("id", "1"), ("dept", "Eng")]),
788            make_binding(&[("id", "2"), ("dept", "Sales")]),
789        ];
790
791        let condition = JoinCondition::Eq(Var::new("id"), Var::new("id"));
792        let stats = JoinStats {
793            left_cardinality: 2,
794            right_cardinality: 2,
795            left_sorted: true,
796            right_sorted: true,
797            condition_selectivity: 1.0,
798        };
799
800        let results = execute_join(left, right, condition, JoinType::Inner, Some(stats));
801        assert_eq!(results.len(), 2);
802    }
803
804    #[test]
805    fn test_strategy_selection() {
806        // Small tables -> nested loop
807        let stats = JoinStats {
808            left_cardinality: 10,
809            right_cardinality: 10,
810            left_sorted: false,
811            right_sorted: false,
812            condition_selectivity: 1.0,
813        };
814        assert_eq!(
815            choose_strategy(&stats, &JoinCondition::Eq(Var::new("a"), Var::new("b"))),
816            JoinStrategy::NestedLoop
817        );
818
819        // Large tables -> hash join
820        let stats = JoinStats {
821            left_cardinality: 10000,
822            right_cardinality: 10000,
823            left_sorted: false,
824            right_sorted: false,
825            condition_selectivity: 1.0,
826        };
827        assert_eq!(
828            choose_strategy(&stats, &JoinCondition::Eq(Var::new("a"), Var::new("b"))),
829            JoinStrategy::Hash
830        );
831
832        // Sorted tables -> merge join
833        let stats = JoinStats {
834            left_cardinality: 1000,
835            right_cardinality: 1000,
836            left_sorted: true,
837            right_sorted: true,
838            condition_selectivity: 1.0,
839        };
840        assert_eq!(
841            choose_strategy(&stats, &JoinCondition::Eq(Var::new("a"), Var::new("b"))),
842            JoinStrategy::Merge
843        );
844    }
845
846    // ===================== borrowed probe-key equivalence (#2013) ============
847
848    /// Bind `k` to the given value (or leave `k` unbound when `None`), plus a
849    /// side-local tag var so merged rows stay distinguishable and never conflict.
850    fn key_row(k: Option<Value>, tag_var: &str, tag: &str) -> Binding {
851        let row = Binding::one(Var::new(tag_var), Value::String(tag.to_string()));
852        match k {
853            Some(value) => row
854                .merge(&Binding::one(Var::new("k"), value))
855                .expect("no conflicting binding"),
856            None => row,
857        }
858    }
859
860    fn left_row(k: Option<Value>, tag: &str) -> Binding {
861        key_row(k, "lt", tag)
862    }
863
864    fn right_row(k: Option<Value>, tag: &str) -> Binding {
865        key_row(k, "rt", tag)
866    }
867
868    /// Order-independent fingerprint of a joined row (`Binding` is backed by a
869    /// `HashMap`, so its `Debug` order is not stable).
870    fn fingerprint(binding: &Binding) -> String {
871        let field = |name: &str| match binding.get(&Var::new(name)) {
872            Some(value) => format!("{value:?}"),
873            None => "-".to_string(),
874        };
875        format!("lt={} rt={} k={}", field("lt"), field("rt"), field("k"))
876    }
877
878    /// Reference semantics for an inner equi-join: every (left, right) pair
879    /// whose *owned* join keys compare equal. This is deliberately independent
880    /// of the hash table under test.
881    fn reference_inner_matches(left: &[Binding], right: &[Binding], keys: &[Var]) -> Vec<String> {
882        let mut out: Vec<String> = Vec::new();
883        for l in left {
884            for r in right {
885                if extract_key(l, keys) == extract_key(r, keys) {
886                    out.push(fingerprint(&merge_bindings(l, r)));
887                }
888            }
889        }
890        out.sort();
891        out
892    }
893
894    /// An absent column and an explicit `NULL` hash to the same bucket, so the
895    /// probe path *must* resolve the collision by element-wise equality — which
896    /// keeps them distinct, exactly as the old owned-`HashKey` map did.
897    #[test]
898    fn null_and_absent_keys_collide_in_hash_but_stay_distinct_in_equality() {
899        let keys = [Var::new("k")];
900        let null_row = left_row(Some(Value::Null), "null");
901        let absent_row = left_row(None, "absent");
902
903        let state = RandomState::new();
904        assert_eq!(
905            key_hash(&state, &null_row, &keys),
906            key_hash(&state, &absent_row, &keys),
907            "NULL and an absent column are expected to share a hash bucket"
908        );
909        assert!(key_matches(
910            &extract_key(&null_row, &keys),
911            &null_row,
912            &keys
913        ));
914        assert!(!key_matches(
915            &extract_key(&null_row, &keys),
916            &absent_row,
917            &keys
918        ));
919        assert!(!key_matches(
920            &extract_key(&absent_row, &keys),
921            &null_row,
922            &keys
923        ));
924    }
925
926    /// The borrowed probe key produces exactly the matches the owned key does —
927    /// over string keys, explicit `NULL`s, absent columns, and rows that share a
928    /// hash bucket while holding different values.
929    #[test]
930    fn hash_join_matches_are_identical_to_the_owned_key_reference() {
931        let left = vec![
932            left_row(Some(Value::String("a".into())), "L-a"),
933            left_row(Some(Value::Integer(1)), "L-1"),
934            left_row(Some(Value::Null), "L-null"),
935            left_row(None, "L-absent"),
936            left_row(Some(Value::String("a".into())), "L-a2"),
937        ];
938        let right = vec![
939            right_row(Some(Value::String("a".into())), "R-a"),
940            right_row(Some(Value::Null), "R-null"),
941            right_row(None, "R-absent"),
942            right_row(Some(Value::Boolean(true)), "R-true"),
943            right_row(Some(Value::Integer(1)), "R-1"),
944            right_row(Some(Value::Integer(1)), "R-1b"),
945        ];
946        let keys = [Var::new("k")];
947        let condition = JoinCondition::Eq(Var::new("k"), Var::new("k"));
948
949        // Cover both build-side choices: `left` is the smaller side (built), the
950        // swapped call builds on the right input, and equal-size inputs exercise
951        // the `<=` tie rule.
952        for (l, r) in [
953            (left.clone(), right.clone()),
954            (right.clone(), left.clone()),
955            (left.clone(), right[..left.len()].to_vec()),
956        ] {
957            let expected = reference_inner_matches(&l, &r, &keys);
958
959            let mut actual: Vec<String> = hash_join(l, r, &condition, JoinType::Inner)
960                .iter()
961                .map(fingerprint)
962                .collect();
963            actual.sort();
964
965            assert_eq!(
966                actual, expected,
967                "hash join diverged from the owned-key reference"
968            );
969        }
970    }
971}