Skip to main content

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