Skip to main content

radixdb_executor/operators/
hash_join.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Streaming hash join operator.
16//!
17//! This operator implements hash join with the following key optimizations:
18//!
19//! 1. **Streaming Probe Side**: Only the build side is materialized.
20//!    The probe side streams through without full materialization.
21//!
22//! 2. **Pre-allocated Hash Table**: The hash table is sized upfront
23//!    based on build side cardinality, avoiding resizing.
24//!
25//! 3. **Zero-Copy Output**: Uses CompositeRow to combine rows without
26//!    cloning values until final materialization is needed.
27//!
28//! # Join Types
29//!
30//! - INNER: Only matching rows
31//! - LEFT OUTER: All left rows, matched right or NULLs
32//! - RIGHT OUTER: All right rows, matched left or NULLs
33//! - FULL OUTER: All rows from both sides
34
35use crate::context::check_current_query_cancelled;
36use crate::expression::JoinFilter;
37use crate::hash_table::{
38    hash_keys_with, JoinHashState, JoinHashTable, ProbeCursor, DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
39};
40use crate::operator::{ColumnInfo, ColumnSource, JoinProjection, Operator, RowRef};
41use radixdb_core::value::NULL_VALUE;
42use radixdb_core::CompactArc;
43use radixdb_core::{Result, Row};
44
45/// Pre-computed column names to avoid format! allocations in hot paths.
46/// Covers most common cases (up to 32 columns).
47const BUILD_COLUMN_NAMES: [&str; 32] = [
48    "build_0", "build_1", "build_2", "build_3", "build_4", "build_5", "build_6", "build_7",
49    "build_8", "build_9", "build_10", "build_11", "build_12", "build_13", "build_14", "build_15",
50    "build_16", "build_17", "build_18", "build_19", "build_20", "build_21", "build_22", "build_23",
51    "build_24", "build_25", "build_26", "build_27", "build_28", "build_29", "build_30", "build_31",
52];
53
54/// Get a build column name efficiently, using pre-computed names when possible.
55#[inline]
56fn get_build_column_name(i: usize) -> String {
57    if i < BUILD_COLUMN_NAMES.len() {
58        BUILD_COLUMN_NAMES[i].to_string()
59    } else {
60        format!("build_{}", i)
61    }
62}
63
64#[inline]
65fn verify_probe_build_key_equality(
66    probe: &RowRef,
67    build: &Row,
68    probe_indices: &[usize],
69    build_indices: &[usize],
70) -> bool {
71    debug_assert_eq!(probe_indices.len(), build_indices.len());
72
73    probe_indices
74        .iter()
75        .zip(build_indices.iter())
76        .all(|(&probe_idx, &build_idx)| {
77            let (Some(probe_value), Some(build_value)) =
78                (probe.get(probe_idx), build.get(build_idx))
79            else {
80                return false;
81            };
82            !probe_value.is_null() && !build_value.is_null() && probe_value == build_value
83        })
84}
85
86/// Which side of the join to use as the build side.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum JoinSide {
89    /// Use left side as build (right as probe)
90    Left,
91    /// Use right side as build (left as probe)
92    Right,
93}
94
95/// Type of join to perform.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum JoinType {
98    /// INNER JOIN - only matching rows
99    Inner,
100    /// LEFT OUTER JOIN - all left rows
101    Left,
102    /// RIGHT OUTER JOIN - all right rows
103    Right,
104    /// FULL OUTER JOIN - all rows from both sides
105    Full,
106    /// CROSS JOIN - cartesian product
107    Cross,
108    /// SEMI JOIN - return left rows that have at least one match (for EXISTS)
109    Semi,
110    /// ANTI JOIN - return left rows that have NO matches (for NOT EXISTS)
111    Anti,
112}
113
114impl JoinType {
115    /// Parse join type from string (as used in parser AST).
116    /// Optimized to avoid allocation - uses byte-level case-insensitive matching.
117    pub fn parse(s: &str) -> Self {
118        // Fast path: check first byte to avoid scanning entire string
119        let bytes = s.as_bytes();
120        for (i, &b) in bytes.iter().enumerate() {
121            // Case-insensitive byte matching: ASCII letters | 32 lowercases.
122            // Each arm is gated by a guard so clippy's collapsible_match is satisfied.
123            match b | 32 {
124                b'l' if i + 4 <= bytes.len()
125                    && (bytes[i + 1] | 32) == b'e'
126                    && (bytes[i + 2] | 32) == b'f'
127                    && (bytes[i + 3] | 32) == b't' =>
128                {
129                    return JoinType::Left;
130                }
131                b'r' if i + 5 <= bytes.len()
132                    && (bytes[i + 1] | 32) == b'i'
133                    && (bytes[i + 2] | 32) == b'g'
134                    && (bytes[i + 3] | 32) == b'h'
135                    && (bytes[i + 4] | 32) == b't' =>
136                {
137                    return JoinType::Right;
138                }
139                b'f' if i + 4 <= bytes.len()
140                    && (bytes[i + 1] | 32) == b'u'
141                    && (bytes[i + 2] | 32) == b'l'
142                    && (bytes[i + 3] | 32) == b'l' =>
143                {
144                    return JoinType::Full;
145                }
146                b'c' if i + 5 <= bytes.len()
147                    && (bytes[i + 1] | 32) == b'r'
148                    && (bytes[i + 2] | 32) == b'o'
149                    && (bytes[i + 3] | 32) == b's'
150                    && (bytes[i + 4] | 32) == b's' =>
151                {
152                    return JoinType::Cross;
153                }
154                b's' if i + 4 <= bytes.len()
155                    && (bytes[i + 1] | 32) == b'e'
156                    && (bytes[i + 2] | 32) == b'm'
157                    && (bytes[i + 3] | 32) == b'i' =>
158                {
159                    return JoinType::Semi;
160                }
161                b'a' if i + 4 <= bytes.len()
162                    && (bytes[i + 1] | 32) == b'n'
163                    && (bytes[i + 2] | 32) == b't'
164                    && (bytes[i + 3] | 32) == b'i' =>
165                {
166                    return JoinType::Anti;
167                }
168                _ => {}
169            }
170        }
171        JoinType::Inner
172    }
173
174    /// Alias for parse() - used by parallel.rs for compatibility.
175    #[allow(clippy::should_implement_trait)]
176    pub fn from_str(s: &str) -> Self {
177        Self::parse(s)
178    }
179
180    /// Check if this join needs unmatched probe rows (NULL-extended).
181    /// Used by parallel hash join.
182    pub fn needs_unmatched_probe(&self, swapped: bool) -> bool {
183        match self {
184            JoinType::Inner | JoinType::Cross | JoinType::Semi => false,
185            JoinType::Anti => !swapped, // ANTI: unmatched probe rows (when not swapped)
186            JoinType::Left => !swapped, // LEFT JOIN: unmatched left (probe when not swapped)
187            JoinType::Right => swapped, // RIGHT JOIN: unmatched right (probe when swapped)
188            JoinType::Full => true,     // FULL JOIN: always needs unmatched rows
189        }
190    }
191
192    /// Check if this join needs unmatched build rows (NULL-extended).
193    /// Used by parallel hash join.
194    pub fn needs_unmatched_build(&self, swapped: bool) -> bool {
195        match self {
196            JoinType::Inner | JoinType::Cross | JoinType::Semi | JoinType::Anti => false,
197            JoinType::Left => swapped, // LEFT JOIN: unmatched left (build when swapped)
198            JoinType::Right => !swapped, // RIGHT JOIN: unmatched right (build when not swapped)
199            JoinType::Full => true,    // FULL JOIN: always needs unmatched rows
200        }
201    }
202
203    /// Check if this is a semi-join (EXISTS semantics).
204    pub fn is_semi(&self) -> bool {
205        matches!(self, JoinType::Semi)
206    }
207
208    /// Check if this is an anti-join (NOT EXISTS semantics).
209    pub fn is_anti(&self) -> bool {
210        matches!(self, JoinType::Anti)
211    }
212}
213
214/// Streaming hash join operator.
215///
216/// The join proceeds in two phases:
217///
218/// 1. **Build Phase** (in `open()`):
219///    - Materialize the build side (smaller side)
220///    - Build hash table on join keys
221///
222/// 2. **Probe Phase** (in `next()`):
223///    - Stream through probe side one row at a time
224///    - Lookup matches in hash table
225///    - Return combined rows
226///
227/// For OUTER joins, additional tracking is used to ensure unmatched
228/// rows are returned with NULL padding.
229pub struct HashJoinOperator {
230    // Input operators
231    left: Box<dyn Operator>,
232    right: Box<dyn Operator>,
233
234    // Join configuration
235    join_type: JoinType,
236    build_side: JoinSide,
237    left_key_indices: Vec<usize>,
238    right_key_indices: Vec<usize>,
239    residual_filters: Vec<JoinFilter>,
240
241    // Build phase state (populated in open())
242    // Uses CompactArc<Vec<Row>> to enable zero-copy sharing with CTE results.
243    // When dropped, only decrements refcount (O(1)) instead of deallocating rows.
244    build_rows: CompactArc<Vec<Row>>,
245    hash_table: Option<std::sync::Arc<JoinHashTable>>,
246    hash_state_max_bytes: usize,
247    scan_fallback: bool,
248
249    // Output schema
250    schema: Vec<ColumnInfo>,
251    left_col_count: usize,
252    right_col_count: usize,
253    projection: Option<JoinProjection>,
254    projection_columns: Option<CompactArc<[ColumnSource]>>,
255
256    // Probe phase state
257    // Stores probe row directly - clones only when needed for 1:N scenarios
258    current_probe_row: Option<RowRef>,
259    current_probe_cursor: ProbeCursor,
260    current_scan_idx: usize,
261    pending_build_idx: Option<usize>,
262    probe_had_match: bool,
263
264    // For OUTER joins: track which build rows were matched
265    build_matched: Vec<bool>,
266    returning_unmatched_build: bool,
267    unmatched_build_idx: usize,
268
269    // For self-join optimization
270    is_self_join: bool,
271    self_join_probe_idx: usize,
272
273    // Cached NULL rows for OUTER joins (avoid per-row allocation)
274    cached_null_build: Option<Row>,
275    cached_null_probe: Option<Row>,
276
277    // State tracking
278    opened: bool,
279    probe_exhausted: bool,
280
281    // Operator-local counters used by the bounded JOIN instrumentation owner.
282    // They are read once after execution; no global atomic is touched per row.
283    observed_probe_rows: u64,
284    observed_candidate_rows: u64,
285    observed_deferred_probe_rows: u64,
286    observed_deferred_output_rows: u64,
287    deferred_metrics_recorded: bool,
288}
289
290impl HashJoinOperator {
291    /// Create a new hash join operator.
292    ///
293    /// # Arguments
294    /// * `left` - Left input operator
295    /// * `right` - Right input operator
296    /// * `join_type` - Type of join (INNER, LEFT, RIGHT, FULL)
297    /// * `left_key_indices` - Column indices for left join keys
298    /// * `right_key_indices` - Column indices for right join keys
299    /// * `build_side` - Which side to use as build (typically smaller)
300    pub fn new(
301        left: Box<dyn Operator>,
302        right: Box<dyn Operator>,
303        join_type: JoinType,
304        left_key_indices: Vec<usize>,
305        right_key_indices: Vec<usize>,
306        build_side: JoinSide,
307    ) -> Self {
308        // Build schema
309        // For Semi/Anti joins, only return left (probe) columns
310        let mut schema = Vec::new();
311        if join_type.is_semi() || join_type.is_anti() {
312            schema.extend(left.schema().iter().cloned());
313        } else {
314            schema.extend(left.schema().iter().cloned());
315            schema.extend(right.schema().iter().cloned());
316        }
317
318        let left_col_count = left.schema().len();
319        let right_col_count = right.schema().len();
320
321        Self {
322            left,
323            right,
324            join_type,
325            build_side,
326            left_key_indices,
327            right_key_indices,
328            residual_filters: Vec::new(),
329            build_rows: CompactArc::new(Vec::new()),
330            hash_table: None,
331            hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
332            scan_fallback: false,
333            schema,
334            left_col_count,
335            right_col_count,
336            projection: None,
337            projection_columns: None,
338            current_probe_row: None,
339            current_probe_cursor: ProbeCursor::default(),
340            current_scan_idx: 0,
341            pending_build_idx: None,
342            probe_had_match: false,
343            build_matched: Vec::new(),
344            returning_unmatched_build: false,
345            unmatched_build_idx: 0,
346            is_self_join: false,
347            self_join_probe_idx: 0,
348            cached_null_build: None,
349            cached_null_probe: None,
350            opened: false,
351            probe_exhausted: false,
352            observed_probe_rows: 0,
353            observed_candidate_rows: 0,
354            observed_deferred_probe_rows: 0,
355            observed_deferred_output_rows: 0,
356            deferred_metrics_recorded: false,
357        }
358    }
359
360    /// Create a hash join operator with pre-built hash table and rows.
361    ///
362    /// This avoids the build phase in `open()` since the hash table is already
363    /// constructed. Used by streaming joins where hash table and bloom filter
364    /// are built together in a single pass for efficiency.
365    ///
366    /// # Arguments
367    /// * `probe` - Probe side operator (will be iterated during join)
368    /// * `hash_state` - Exact build rows, key layout and their pre-built table
369    /// * `join_type` - Type of join
370    /// * `left_key_indices` - Key indices for the logical left side
371    /// * `right_key_indices` - Key indices for the logical right side
372    /// * `build_is_left` - Whether build side is left (for schema ordering)
373    pub fn with_prebuilt(
374        probe: Box<dyn Operator>,
375        hash_state: JoinHashState,
376        join_type: JoinType,
377        left_key_indices: Vec<usize>,
378        right_key_indices: Vec<usize>,
379        build_is_left: bool,
380        build_col_count: usize,
381    ) -> Result<Self> {
382        let build_key_indices = if build_is_left {
383            &left_key_indices
384        } else {
385            &right_key_indices
386        };
387        if !hash_state.matches(hash_state.build_rows(), build_key_indices) {
388            return Err(radixdb_core::Error::internal(
389                "pre-built join hash state key layout mismatch",
390            ));
391        }
392        let build_rows = CompactArc::clone(hash_state.build_rows());
393        let hash_table = std::sync::Arc::clone(hash_state.table());
394        let probe_col_count = probe.schema().len();
395
396        // Build schema based on build side position
397        // Pre-allocate schema with known capacity
398        let total_cols = build_col_count + probe_col_count;
399        let mut schema = Vec::with_capacity(total_cols);
400        let (left_col_count, right_col_count) = if build_is_left {
401            // Build is left: [build_cols, probe_cols]
402            for i in 0..build_col_count {
403                schema.push(ColumnInfo::new(get_build_column_name(i)));
404            }
405            schema.extend(probe.schema().iter().cloned());
406            (build_col_count, probe_col_count)
407        } else {
408            // Build is right: [probe_cols, build_cols]
409            schema.extend(probe.schema().iter().cloned());
410            for i in 0..build_col_count {
411                schema.push(ColumnInfo::new(get_build_column_name(i)));
412            }
413            (probe_col_count, build_col_count)
414        };
415
416        let build_side = if build_is_left {
417            JoinSide::Left
418        } else {
419            JoinSide::Right
420        };
421
422        // Track matched builds for OUTER joins
423        let build_matched = if matches!(join_type, JoinType::Full)
424            || (matches!(join_type, JoinType::Left) && build_is_left)
425            || (matches!(join_type, JoinType::Right) && !build_is_left)
426        {
427            vec![false; build_rows.len()]
428        } else {
429            Vec::new()
430        };
431
432        // Store probe operator in the non-build side slot
433        let (left, right) = if build_is_left {
434            // Build is left, probe is right
435            (
436                Box::new(crate::operator::EmptyOperator::new()) as Box<dyn Operator>,
437                probe,
438            )
439        } else {
440            // Build is right, probe is left
441            (
442                probe,
443                Box::new(crate::operator::EmptyOperator::new()) as Box<dyn Operator>,
444            )
445        };
446
447        Ok(Self {
448            left,
449            right,
450            join_type,
451            build_side,
452            left_key_indices,
453            right_key_indices,
454            residual_filters: Vec::new(),
455            build_rows,
456            hash_table: Some(hash_table),
457            hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
458            scan_fallback: false,
459            schema,
460            left_col_count,
461            right_col_count,
462            projection: None,
463            projection_columns: None,
464            current_probe_row: None,
465            current_probe_cursor: ProbeCursor::default(),
466            current_scan_idx: 0,
467            pending_build_idx: None,
468            probe_had_match: false,
469            build_matched,
470            returning_unmatched_build: false,
471            unmatched_build_idx: 0,
472            is_self_join: false,
473            self_join_probe_idx: 0,
474            cached_null_build: None,
475            cached_null_probe: None,
476            opened: false,
477            probe_exhausted: false,
478            observed_probe_rows: 0,
479            observed_candidate_rows: 0,
480            observed_deferred_probe_rows: 0,
481            observed_deferred_output_rows: 0,
482            deferred_metrics_recorded: false,
483        })
484    }
485
486    /// Create an optimized self-join operator.
487    ///
488    /// For self-joins (t1 JOIN t1), this avoids scanning the table twice
489    /// by reusing the same materialized data for both build and probe.
490    pub fn self_join(
491        input: Box<dyn Operator>,
492        join_type: JoinType,
493        left_key_indices: Vec<usize>,
494        right_key_indices: Vec<usize>,
495    ) -> Self {
496        // For self-join, schema is input schema duplicated
497        let mut schema = Vec::new();
498        schema.extend(input.schema().iter().cloned());
499        schema.extend(input.schema().iter().cloned());
500
501        let col_count = input.schema().len();
502
503        // We'll use left as the input, right will be unused
504        Self {
505            left: input,
506            right: Box::new(crate::operator::EmptyOperator::new()),
507            join_type,
508            build_side: JoinSide::Left, // Build from the single input
509            left_key_indices,
510            right_key_indices,
511            residual_filters: Vec::new(),
512            build_rows: CompactArc::new(Vec::new()),
513            hash_table: None,
514            hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
515            scan_fallback: false,
516            schema,
517            left_col_count: col_count,
518            right_col_count: col_count,
519            projection: None,
520            projection_columns: None,
521            current_probe_row: None,
522            current_probe_cursor: ProbeCursor::default(),
523            current_scan_idx: 0,
524            pending_build_idx: None,
525            probe_had_match: false,
526            build_matched: Vec::new(),
527            returning_unmatched_build: false,
528            unmatched_build_idx: 0,
529            is_self_join: true,
530            self_join_probe_idx: 0,
531            cached_null_build: None,
532            cached_null_probe: None,
533            opened: false,
534            probe_exhausted: false,
535            observed_probe_rows: 0,
536            observed_candidate_rows: 0,
537            observed_deferred_probe_rows: 0,
538            observed_deferred_output_rows: 0,
539            deferred_metrics_recorded: false,
540        }
541    }
542
543    pub(crate) fn observed_probe_rows(&self) -> u64 {
544        self.observed_probe_rows
545    }
546
547    pub(crate) fn observed_candidate_rows(&self) -> u64 {
548        self.observed_candidate_rows
549    }
550
551    pub(crate) fn used_scan_fallback(&self) -> bool {
552        self.scan_fallback
553    }
554
555    pub(crate) fn with_hash_state_max_bytes(mut self, max_bytes: usize) -> Self {
556        self.hash_state_max_bytes = max_bytes;
557        self
558    }
559
560    fn publish_deferred_metrics(&mut self) {
561        if self.deferred_metrics_recorded {
562            return;
563        }
564        radixdb_storage::instrumentation::record_join_deferred_rows(
565            self.observed_deferred_output_rows,
566            self.observed_deferred_probe_rows,
567        );
568        self.deferred_metrics_recorded = true;
569    }
570
571    #[inline]
572    fn observe_output(&mut self, row: RowRef) -> RowRef {
573        if row.is_deferred() {
574            self.observed_deferred_output_rows =
575                self.observed_deferred_output_rows.saturating_add(1);
576        }
577        row
578    }
579
580    /// Set projection pushdown configuration.
581    ///
582    /// When set, the operator creates projected rows directly from the left/right
583    /// sources instead of materializing a full combined join row and projecting it
584    /// later. `ColumnSource::Outer` means the logical left side and
585    /// `ColumnSource::Inner` means the logical right side.
586    pub fn with_projection(
587        mut self,
588        columns: Vec<ColumnSource>,
589        projected_schema: Vec<ColumnInfo>,
590    ) -> Self {
591        self.projection_columns = Some(CompactArc::from(columns.clone()));
592        self.projection = Some(JoinProjection { columns });
593        self.schema = projected_schema;
594        self
595    }
596
597    /// Attach the non-equality part of `ON` to the hash match-state owner.
598    /// Equality hash hits do not count as matches until every residual accepts
599    /// the same virtual left/right pair.
600    pub fn with_residual_filters(mut self, filters: Vec<JoinFilter>) -> Self {
601        self.residual_filters = filters;
602        self
603    }
604
605    #[inline]
606    fn candidate_passes_residual(&self, probe_row: &RowRef, build_idx: usize) -> Result<bool> {
607        if self.residual_filters.is_empty() {
608            return Ok(true);
609        }
610        let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
611        let (left, right) = match self.build_side {
612            JoinSide::Left => (&build_row, probe_row),
613            JoinSide::Right => (probe_row, &build_row),
614        };
615        for filter in &self.residual_filters {
616            if !filter.matches_row_refs_checked(left, right)? {
617                return Ok(false);
618            }
619        }
620        Ok(true)
621    }
622
623    /// Get the key indices based on which side is build vs probe.
624    fn build_key_indices(&self) -> &[usize] {
625        match self.build_side {
626            JoinSide::Left => &self.left_key_indices,
627            JoinSide::Right => &self.right_key_indices,
628        }
629    }
630
631    fn probe_key_indices(&self) -> &[usize] {
632        match self.build_side {
633            JoinSide::Left => &self.right_key_indices,
634            JoinSide::Right => &self.left_key_indices,
635        }
636    }
637
638    #[inline]
639    fn project_left_right_refs(&self, left: RowRef, right: RowRef) -> RowRef {
640        RowRef::projected(
641            left,
642            right,
643            CompactArc::clone(
644                self.projection_columns
645                    .as_ref()
646                    .expect("projection columns must exist when projection is enabled"),
647            ),
648        )
649    }
650
651    #[inline]
652    fn project_probe_build_match(&self, probe_row: RowRef, build_idx: usize) -> RowRef {
653        let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
654        match self.build_side {
655            JoinSide::Left => self.project_left_right_refs(build_row, probe_row),
656            JoinSide::Right => self.project_left_right_refs(probe_row, build_row),
657        }
658    }
659
660    #[inline]
661    fn project_probe_without_build(&self, probe_row: RowRef, null_build: Row) -> RowRef {
662        let null_build = RowRef::owned(null_build);
663        match self.build_side {
664            JoinSide::Left => self.project_left_right_refs(null_build, probe_row),
665            JoinSide::Right => self.project_left_right_refs(probe_row, null_build),
666        }
667    }
668
669    #[inline]
670    fn project_build_without_probe(&self, build_idx: usize, null_probe: Row) -> RowRef {
671        let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
672        let null_probe = RowRef::owned(null_probe);
673        match self.build_side {
674            JoinSide::Left => self.project_left_right_refs(build_row, null_probe),
675            JoinSide::Right => self.project_left_right_refs(null_probe, build_row),
676        }
677    }
678
679    /// Get cached NULL row for the build side (used in OUTER joins).
680    /// Caches the row on first call to avoid per-row allocation.
681    #[inline]
682    fn null_build_row(&mut self) -> Row {
683        if let Some(ref row) = self.cached_null_build {
684            return row.clone();
685        }
686        let count = match self.build_side {
687            JoinSide::Left => self.left_col_count,
688            JoinSide::Right => self.right_col_count,
689        };
690        let row = Row::from_values(vec![NULL_VALUE; count]);
691        self.cached_null_build = Some(row.clone());
692        row
693    }
694
695    /// Get cached NULL row for the probe side (used in OUTER joins).
696    /// Caches the row on first call to avoid per-row allocation.
697    #[inline]
698    fn null_probe_row(&mut self) -> Row {
699        if let Some(ref row) = self.cached_null_probe {
700            return row.clone();
701        }
702        let count = match self.build_side {
703            JoinSide::Left => self.right_col_count,
704            JoinSide::Right => self.left_col_count,
705        };
706        let row = Row::from_values(vec![NULL_VALUE; count]);
707        self.cached_null_probe = Some(row.clone());
708        row
709    }
710
711    /// Combine probe row with a build row by index.
712    /// Uses DirectBuildComposite to avoid cloning build rows - stores Arc reference instead.
713    /// OPTIMIZATION: Uses DirectBuildComposite (no Arc allocation for probe row).
714    #[inline]
715    fn combine_rows_direct(&self, probe_row: RowRef, build_idx: usize) -> RowRef {
716        if self.projection.is_some() {
717            return self.project_probe_build_match(probe_row, build_idx);
718        }
719
720        // probe_is_left determines output column order:
721        // - true: output = [probe, build]
722        // - false: output = [build, probe]
723        let probe_is_left = matches!(self.build_side, JoinSide::Right);
724        RowRef::direct_build_composite(
725            probe_row.into_owned(),
726            CompactArc::clone(&self.build_rows),
727            build_idx,
728            probe_is_left,
729        )
730    }
731
732    /// Combine probe and build rows into a RowRef without allocation.
733    /// Uses CompositeRow to defer materialization until needed.
734    /// Used for OUTER join unmatched rows where we need an actual null row.
735    #[inline]
736    fn combine_rows_ref(&self, probe_row: RowRef, build_row: Row) -> RowRef {
737        if self.projection.is_some() {
738            let build_row = RowRef::owned(build_row);
739            return match self.build_side {
740                JoinSide::Left => self.project_left_right_refs(build_row, probe_row),
741                JoinSide::Right => self.project_left_right_refs(probe_row, build_row),
742            };
743        }
744
745        let probe_row = probe_row.into_owned();
746
747        match self.build_side {
748            JoinSide::Left => {
749                // Build is left, probe is right
750                // Output: [build_row, probe_row] = [left, right]
751                RowRef::Composite(crate::operator::CompositeRow::new(build_row, probe_row))
752            }
753            JoinSide::Right => {
754                // Build is right, probe is left
755                // Output: [probe_row, build_row] = [left, right]
756                RowRef::Composite(crate::operator::CompositeRow::new(probe_row, build_row))
757            }
758        }
759    }
760
761    /// Get the next probe row (from probe operator or self-join iteration).
762    fn next_probe_row(&mut self) -> Result<Option<RowRef>> {
763        if self.is_self_join {
764            // For self-join, iterate over the materialized build rows
765            if self.self_join_probe_idx >= self.build_rows.len() {
766                return Ok(None);
767            }
768            let row = self.build_rows[self.self_join_probe_idx].clone();
769            self.self_join_probe_idx += 1;
770            Ok(Some(RowRef::owned(row)))
771        } else {
772            // Normal case: get from probe operator
773            let probe_op = match self.build_side {
774                JoinSide::Left => &mut self.right,
775                JoinSide::Right => &mut self.left,
776            };
777
778            probe_op.next()
779        }
780    }
781
782    #[inline]
783    fn next_build_candidate(&mut self) -> Option<usize> {
784        if let Some(pending) = self.pending_build_idx.take() {
785            return Some(pending);
786        }
787        if self.scan_fallback {
788            if self.current_scan_idx >= self.build_rows.len() {
789                return None;
790            }
791            let candidate = self.current_scan_idx;
792            self.current_scan_idx += 1;
793            Some(candidate)
794        } else {
795            self.hash_table
796                .as_ref()
797                .expect("opened hash join must own a table")
798                .probe_next(&mut self.current_probe_cursor)
799        }
800    }
801
802    #[inline]
803    fn prefetch_build_candidate(&mut self) {
804        self.pending_build_idx = if self.scan_fallback {
805            if self.current_scan_idx < self.build_rows.len() {
806                let candidate = self.current_scan_idx;
807                self.current_scan_idx += 1;
808                Some(candidate)
809            } else {
810                None
811            }
812        } else {
813            self.hash_table
814                .as_ref()
815                .expect("opened hash join must own a table")
816                .probe_next(&mut self.current_probe_cursor)
817        };
818    }
819}
820
821impl Operator for HashJoinOperator {
822    fn open(&mut self) -> Result<()> {
823        self.observed_probe_rows = 0;
824        self.observed_candidate_rows = 0;
825        self.observed_deferred_probe_rows = 0;
826        self.observed_deferred_output_rows = 0;
827        self.deferred_metrics_recorded = false;
828        if let Some(projection) = &self.projection {
829            projection.validate(self.left_col_count, self.right_col_count, self.schema.len())?;
830        }
831        // Check if hash table was pre-built (via with_prebuilt constructor)
832        if self.hash_table.is_some() {
833            // Pre-built case: only need to open the probe side
834            // Build side is already materialized
835            let probe_op = match self.build_side {
836                JoinSide::Left => &mut self.right,
837                JoinSide::Right => &mut self.left,
838            };
839            if let Err(error) = probe_op.open() {
840                let _ = probe_op.close();
841                return Err(error);
842            }
843            self.opened = true;
844            return Ok(());
845        }
846
847        // Standard case: open both inputs and build hash table
848        if let Err(error) = self.left.open() {
849            let _ = self.left.close();
850            return Err(error);
851        }
852        if !self.is_self_join {
853            if let Err(error) = self.right.open() {
854                let _ = self.right.close();
855                let _ = self.left.close();
856                return Err(error);
857            }
858        }
859
860        let open_result = (|| {
861            check_current_query_cancelled()?;
862
863            // Materialize build side
864            let build_op = match self.build_side {
865                JoinSide::Left => &mut self.left,
866                JoinSide::Right => &mut self.right,
867            };
868
869            // Collect all build rows
870            let mut build_rows = Vec::new();
871            while let Some(row_ref) = build_op.next()? {
872                if build_rows.len() & 0xff == 0 {
873                    check_current_query_cancelled()?;
874                }
875                build_rows.push(row_ref.into_owned());
876            }
877
878            // Admit the additional hash index before allocating it. The build
879            // batch is still a valid bounded-scan relation when the index does
880            // not fit, so correctness does not depend on allocator success.
881            let build_key_indices = self.build_key_indices().to_vec();
882            let hash_table =
883                if JoinHashTable::fits_retained_budget(build_rows.len(), self.hash_state_max_bytes)
884                {
885                    Some(std::sync::Arc::new(JoinHashTable::build(
886                        &build_rows,
887                        &build_key_indices,
888                    )))
889                } else {
890                    self.scan_fallback = true;
891                    None
892                };
893
894            // Track which build rows match for OUTER joins that need unmatched BUILD rows:
895            // - FULL: always need unmatched rows from both sides
896            // - LEFT with build_side=Left: unmatched LEFT (build) rows need NULLs
897            // - RIGHT with build_side=Right: unmatched RIGHT (build) rows need NULLs
898            let needs_build_tracking = matches!(self.join_type, JoinType::Full)
899                || (matches!(self.join_type, JoinType::Left) && self.build_side == JoinSide::Left)
900                || (matches!(self.join_type, JoinType::Right)
901                    && self.build_side == JoinSide::Right)
902                || (self.is_self_join && !matches!(self.join_type, JoinType::Inner));
903            if needs_build_tracking {
904                self.build_matched = vec![false; build_rows.len()];
905            }
906
907            // Wrap in CompactArc for zero-copy drop (only refcount decrement, not deallocation)
908            self.build_rows = CompactArc::new(build_rows);
909            self.hash_table = hash_table;
910            self.opened = true;
911
912            Ok(())
913        })();
914        if let Err(error) = open_result {
915            let _ = self.close();
916            return Err(error);
917        }
918        Ok(())
919    }
920
921    fn next(&mut self) -> Result<Option<RowRef>> {
922        check_current_query_cancelled()?;
923        if !self.opened {
924            return Err(radixdb_core::Error::internal(
925                "HashJoinOperator::next called before open",
926            ));
927        }
928
929        // If we're returning unmatched build rows (for FULL/RIGHT OUTER)
930        if self.returning_unmatched_build {
931            while self.unmatched_build_idx < self.build_rows.len() {
932                if self.unmatched_build_idx & 0xff == 0 {
933                    check_current_query_cancelled()?;
934                }
935                let idx = self.unmatched_build_idx;
936                self.unmatched_build_idx += 1;
937
938                if !self.build_matched[idx] {
939                    if self.projection.is_some() {
940                        let null_probe = self.null_probe_row();
941                        let row = self.project_build_without_probe(idx, null_probe);
942                        return Ok(Some(self.observe_output(row)));
943                    }
944                    let build_row = self.build_rows[idx].clone();
945                    let null_probe = self.null_probe_row();
946                    let row = self.combine_rows_ref(RowRef::owned(null_probe), build_row);
947                    return Ok(Some(self.observe_output(row)));
948                }
949            }
950            return Ok(None);
951        }
952
953        let mut scanned_probe_rows = 0_usize;
954        loop {
955            // Try to return the next match for the current probe row. The
956            // cursor walks the bucket in-place; no per-probe Vec of candidate
957            // row indices is allocated or retained.
958            while self.current_probe_row.is_some() {
959                if self.observed_candidate_rows & 0xff == 0 {
960                    check_current_query_cancelled()?;
961                }
962                let Some(build_idx) = self.next_build_candidate() else {
963                    break;
964                };
965                self.observed_candidate_rows = self.observed_candidate_rows.saturating_add(1);
966
967                let build_row = &self.build_rows[build_idx];
968
969                // Verify actual key equality (handle hash collisions)
970                if verify_probe_build_key_equality(
971                    self.current_probe_row.as_ref().unwrap(),
972                    build_row,
973                    self.probe_key_indices(),
974                    self.build_key_indices(),
975                ) {
976                    if !self.candidate_passes_residual(
977                        self.current_probe_row.as_ref().unwrap(),
978                        build_idx,
979                    )? {
980                        continue;
981                    }
982                    self.probe_had_match = true;
983
984                    // SEMI JOIN: Return probe row only (no build columns), then skip remaining matches
985                    if self.join_type.is_semi() {
986                        let probe_row = self.current_probe_row.take().unwrap();
987                        self.pending_build_idx = None;
988                        self.current_probe_cursor = ProbeCursor::default();
989                        if self.projection.is_some() {
990                            let row = self.project_probe_build_match(probe_row, build_idx);
991                            return Ok(Some(self.observe_output(row)));
992                        }
993                        return Ok(Some(self.observe_output(probe_row)));
994                    }
995
996                    // ANTI JOIN: Found a match, so this probe row should NOT be returned
997                    // Just skip remaining matches and move to next probe row
998                    if self.join_type.is_anti() {
999                        self.current_probe_row = None;
1000                        self.pending_build_idx = None;
1001                        self.current_probe_cursor = ProbeCursor::default();
1002                        continue;
1003                    }
1004
1005                    // Mark build row as matched (for OUTER joins)
1006                    if !self.build_matched.is_empty() {
1007                        self.build_matched[build_idx] = true;
1008                    }
1009
1010                    // Peek one hash candidate without allocating. Take probe
1011                    // ownership only when the bucket is exhausted; otherwise
1012                    // retain the candidate for the next call and clone the
1013                    // probe row for this 1:N output.
1014                    self.prefetch_build_candidate();
1015                    let probe_row = if self.pending_build_idx.is_none() {
1016                        // No more matches - take ownership (zero-copy)
1017                        self.current_probe_row.take().unwrap()
1018                    } else {
1019                        // More potential matches - clone probe row (rare 1:N case)
1020                        self.current_probe_row.as_ref().unwrap().clone()
1021                    };
1022                    let row = self.combine_rows_direct(probe_row, build_idx);
1023                    return Ok(Some(self.observe_output(row)));
1024                }
1025            }
1026
1027            // Handle unmatched probe row
1028            // - ANTI JOIN: Return probe row when NO match found
1029            // - OUTER JOINs: Return probe row with NULL build columns
1030            if self.join_type.is_anti() && !self.probe_had_match {
1031                if let Some(probe_row) = self.current_probe_row.take() {
1032                    if self.projection.is_some() {
1033                        let null_build = self.null_build_row();
1034                        let row = self.project_probe_without_build(probe_row, null_build);
1035                        return Ok(Some(self.observe_output(row)));
1036                    }
1037                    return Ok(Some(self.observe_output(probe_row)));
1038                }
1039            }
1040
1041            // Handle unmatched probe row for OUTER joins
1042            // Output unmatched probe rows only when probe side needs "all rows":
1043            // - FULL: all rows from both sides
1044            // - RIGHT with build_side=Left: probe=right, need all right rows
1045            // - LEFT with build_side=Right: probe=left, need all left rows
1046            let needs_unmatched_probe = matches!(self.join_type, JoinType::Full)
1047                || (matches!(self.join_type, JoinType::Right) && self.build_side == JoinSide::Left)
1048                || (matches!(self.join_type, JoinType::Left) && self.build_side == JoinSide::Right);
1049
1050            if needs_unmatched_probe && !self.probe_had_match {
1051                if let Some(probe_row) = self.current_probe_row.take() {
1052                    if self.projection.is_some() {
1053                        let null_build = self.null_build_row();
1054                        let row = self.project_probe_without_build(probe_row, null_build);
1055                        return Ok(Some(self.observe_output(row)));
1056                    }
1057                    let null_build = self.null_build_row();
1058                    let row = self.combine_rows_ref(probe_row, null_build);
1059                    return Ok(Some(self.observe_output(row)));
1060                }
1061            }
1062
1063            // Get next probe row (must be done before borrowing hash_table)
1064            if scanned_probe_rows & 0xff == 0 {
1065                check_current_query_cancelled()?;
1066            }
1067            let next_probe = self.next_probe_row()?;
1068            scanned_probe_rows = scanned_probe_rows.saturating_add(1);
1069            match next_probe {
1070                Some(probe_row) => {
1071                    self.observed_probe_rows = self.observed_probe_rows.saturating_add(1);
1072                    if probe_row.is_deferred() {
1073                        self.observed_deferred_probe_rows =
1074                            self.observed_deferred_probe_rows.saturating_add(1);
1075                    }
1076                    if self.scan_fallback {
1077                        self.current_scan_idx = 0;
1078                        self.current_probe_cursor = ProbeCursor::default();
1079                    } else {
1080                        // Compute the hash only for the admitted table path.
1081                        let probe_key_indices = self.probe_key_indices();
1082                        let hash = hash_keys_with(probe_key_indices, |idx| probe_row.get(idx));
1083                        self.current_probe_cursor = self
1084                            .hash_table
1085                            .as_ref()
1086                            .expect("opened hash join must own a table")
1087                            .probe_cursor(hash);
1088                    }
1089                    self.pending_build_idx = None;
1090
1091                    // Store probe row directly (no Arc wrapping needed)
1092                    self.current_probe_row = Some(probe_row);
1093                    self.probe_had_match = false;
1094                }
1095                None => {
1096                    // Probe side exhausted
1097                    self.probe_exhausted = true;
1098
1099                    // Return unmatched build rows for OUTER joins where build side
1100                    // corresponds to the "all rows" side of the join:
1101                    // - FULL: all rows from both sides
1102                    // - LEFT with build_side=Left: all left (build) rows
1103                    // - RIGHT with build_side=Right: all right (build) rows
1104                    if !self.build_matched.is_empty() {
1105                        self.returning_unmatched_build = true;
1106                        self.unmatched_build_idx = 0;
1107                        // Recursive call to handle unmatched build rows
1108                        return self.next();
1109                    }
1110
1111                    return Ok(None);
1112                }
1113            }
1114        }
1115    }
1116
1117    fn close(&mut self) -> Result<()> {
1118        self.publish_deferred_metrics();
1119        let left = self.left.close();
1120        let right = if self.is_self_join {
1121            Ok(())
1122        } else {
1123            self.right.close()
1124        };
1125        left.and(right)
1126    }
1127
1128    fn schema(&self) -> &[ColumnInfo] {
1129        &self.schema
1130    }
1131
1132    fn estimated_rows(&self) -> Option<usize> {
1133        // Rough estimate: min of both sides (for INNER)
1134        // Could be refined with statistics
1135        let left_est = self.left.estimated_rows()?;
1136        let right_est = self.right.estimated_rows()?;
1137
1138        Some(match self.join_type {
1139            JoinType::Inner => left_est.min(right_est),
1140            JoinType::Left => left_est,
1141            JoinType::Right => right_est,
1142            JoinType::Full => left_est + right_est,
1143            JoinType::Cross => left_est * right_est,
1144            JoinType::Semi => left_est.min(right_est), // At most all left rows
1145            JoinType::Anti => left_est,                // At most all left rows
1146        })
1147    }
1148
1149    fn name(&self) -> &str {
1150        match self.join_type {
1151            JoinType::Inner => "HashJoin (INNER)",
1152            JoinType::Left => "HashJoin (LEFT)",
1153            JoinType::Right => "HashJoin (RIGHT)",
1154            JoinType::Full => "HashJoin (FULL)",
1155            JoinType::Cross => "HashJoin (CROSS)",
1156            JoinType::Semi => "HashJoin (SEMI)",
1157            JoinType::Anti => "HashJoin (ANTI)",
1158        }
1159    }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165    use crate::operator::MaterializedOperator;
1166    use radixdb_core::Value;
1167    use radixdb_storage::instrumentation;
1168
1169    fn make_rows(data: Vec<Vec<i64>>) -> Vec<Row> {
1170        data.into_iter()
1171            .map(|vals| Row::from_values(vals.into_iter().map(Value::integer).collect()))
1172            .collect()
1173    }
1174
1175    fn make_operator(data: Vec<Vec<i64>>, cols: Vec<&str>) -> Box<dyn Operator> {
1176        let rows = make_rows(data);
1177        let schema = cols.into_iter().map(ColumnInfo::new).collect();
1178        Box::new(MaterializedOperator::new(rows, schema))
1179    }
1180
1181    fn collect_results(op: &mut dyn Operator) -> Result<Vec<Row>> {
1182        let mut results = Vec::new();
1183        op.open()?;
1184        while let Some(row_ref) = op.next()? {
1185            results.push(row_ref.into_owned());
1186        }
1187        op.close()?;
1188        Ok(results)
1189    }
1190
1191    fn canonical_rows(rows: Vec<Row>) -> Vec<String> {
1192        let mut rows = rows
1193            .into_iter()
1194            .map(|row| format!("{row:?}"))
1195            .collect::<Vec<_>>();
1196        rows.sort_unstable();
1197        rows
1198    }
1199
1200    fn execute_budget_case(
1201        join_type: JoinType,
1202        build_side: JoinSide,
1203        max_bytes: usize,
1204    ) -> (Vec<String>, bool) {
1205        let left = make_operator(
1206            vec![vec![1, 10], vec![1, 11], vec![2, 20], vec![4, 40]],
1207            vec!["id", "left_value"],
1208        );
1209        let right = make_operator(
1210            vec![vec![1, 100], vec![1, 101], vec![3, 300]],
1211            vec!["id", "right_value"],
1212        );
1213        let mut join = HashJoinOperator::new(left, right, join_type, vec![0], vec![0], build_side)
1214            .with_hash_state_max_bytes(max_bytes);
1215        let rows = collect_results(&mut join).unwrap();
1216        (canonical_rows(rows), join.used_scan_fallback())
1217    }
1218
1219    #[test]
1220    fn bounded_scan_fallback_matches_hash_semantics_for_all_join_types() {
1221        let cases = [
1222            (JoinType::Inner, JoinSide::Right),
1223            (JoinType::Left, JoinSide::Right),
1224            (JoinType::Right, JoinSide::Left),
1225            (JoinType::Full, JoinSide::Right),
1226            (JoinType::Semi, JoinSide::Right),
1227            (JoinType::Anti, JoinSide::Right),
1228        ];
1229
1230        for (join_type, build_side) in cases {
1231            let (hashed, hash_fallback) = execute_budget_case(join_type, build_side, usize::MAX);
1232            let (scanned, scan_fallback) = execute_budget_case(join_type, build_side, 0);
1233            assert!(!hash_fallback, "{join_type:?} unexpectedly used fallback");
1234            assert!(scan_fallback, "{join_type:?} did not use fallback");
1235            assert_eq!(scanned, hashed, "{join_type:?} fallback changed results");
1236        }
1237    }
1238
1239    #[test]
1240    fn test_inner_join() {
1241        let left = make_operator(
1242            vec![vec![1, 10], vec![2, 20], vec![3, 30]],
1243            vec!["id", "value"],
1244        );
1245        let right = make_operator(vec![vec![1, 100], vec![3, 300]], vec!["id", "data"]);
1246
1247        let mut join = HashJoinOperator::new(
1248            left,
1249            right,
1250            JoinType::Inner,
1251            vec![0], // left key: id
1252            vec![0], // right key: id
1253            JoinSide::Right,
1254        );
1255
1256        let results = collect_results(&mut join).unwrap();
1257
1258        // Should have 2 matches: id=1 and id=3
1259        assert_eq!(results.len(), 2);
1260
1261        // Verify first match (id=1)
1262        let row1 = &results[0];
1263        assert_eq!(row1.get(0), Some(&Value::integer(1)));
1264        assert_eq!(row1.get(1), Some(&Value::integer(10)));
1265        assert_eq!(row1.get(2), Some(&Value::integer(1)));
1266        assert_eq!(row1.get(3), Some(&Value::integer(100)));
1267    }
1268
1269    #[test]
1270    fn test_inner_join_projection_materializes_selected_columns_only() {
1271        let left = make_operator(
1272            vec![vec![1, 10, 1000], vec![2, 20, 2000], vec![3, 30, 3000]],
1273            vec!["id", "value", "unused_left"],
1274        );
1275        let right = make_operator(
1276            vec![vec![1, 100, 9000], vec![3, 300, 7000]],
1277            vec!["id", "data", "unused_right"],
1278        );
1279
1280        let projected_schema = vec![ColumnInfo::new("value"), ColumnInfo::new("data")];
1281        let mut join = HashJoinOperator::new(
1282            left,
1283            right,
1284            JoinType::Inner,
1285            vec![0],
1286            vec![0],
1287            JoinSide::Right,
1288        )
1289        .with_projection(
1290            vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
1291            projected_schema,
1292        );
1293
1294        assert_eq!(join.schema().len(), 2);
1295        let results = collect_results(&mut join).unwrap();
1296
1297        assert_eq!(results.len(), 2);
1298        assert!(results.iter().all(|row| row.len() == 2));
1299        assert_eq!(results[0].get(0), Some(&Value::integer(10)));
1300        assert_eq!(results[0].get(1), Some(&Value::integer(100)));
1301        assert_eq!(results[1].get(0), Some(&Value::integer(30)));
1302        assert_eq!(results[1].get(1), Some(&Value::integer(300)));
1303    }
1304
1305    #[test]
1306    fn projected_hash_chain_keeps_probe_rows_deferred_between_edges() {
1307        let first_left = make_operator(vec![vec![1, 10], vec![2, 20]], vec!["id", "payload"]);
1308        let first_right = make_operator(vec![vec![1, 100], vec![2, 200]], vec!["id", "dictionary"]);
1309        let first = HashJoinOperator::new(
1310            first_left,
1311            first_right,
1312            JoinType::Inner,
1313            vec![0],
1314            vec![0],
1315            JoinSide::Right,
1316        )
1317        .with_projection(
1318            vec![
1319                ColumnSource::Outer(0),
1320                ColumnSource::Outer(1),
1321                ColumnSource::Inner(1),
1322            ],
1323            vec![
1324                ColumnInfo::new("id"),
1325                ColumnInfo::new("payload"),
1326                ColumnInfo::new("dictionary"),
1327            ],
1328        );
1329
1330        let second_right = make_operator(vec![vec![1, 1000], vec![2, 2000]], vec!["id", "leaf"]);
1331        let mut second = HashJoinOperator::new(
1332            Box::new(first),
1333            second_right,
1334            JoinType::Inner,
1335            vec![0],
1336            vec![0],
1337            JoinSide::Right,
1338        )
1339        .with_projection(
1340            vec![
1341                ColumnSource::Outer(1),
1342                ColumnSource::Outer(2),
1343                ColumnSource::Inner(1),
1344            ],
1345            vec![
1346                ColumnInfo::new("payload"),
1347                ColumnInfo::new("dictionary"),
1348                ColumnInfo::new("leaf"),
1349            ],
1350        );
1351
1352        instrumentation::begin_join_execution_probe();
1353        second.open().unwrap();
1354        let first_output = second.next().unwrap().unwrap();
1355        assert!(first_output.is_deferred());
1356        assert_eq!(first_output.get(0), Some(&Value::integer(10)));
1357        assert_eq!(first_output.get(1), Some(&Value::integer(100)));
1358        assert_eq!(first_output.get(2), Some(&Value::integer(1000)));
1359        assert_eq!(first_output.into_owned().len(), 3);
1360
1361        let second_output = second.next().unwrap().unwrap();
1362        assert!(second_output.is_deferred());
1363        assert_eq!(second_output.get(0), Some(&Value::integer(20)));
1364        assert_eq!(second_output.get(1), Some(&Value::integer(200)));
1365        assert_eq!(second_output.get(2), Some(&Value::integer(2000)));
1366        assert!(second.next().unwrap().is_none());
1367        second.close().unwrap();
1368
1369        let probe = instrumentation::end_join_execution_probe();
1370        assert_eq!(probe.deferred_rows, 4);
1371        assert_eq!(probe.deferred_rows_consumed, 2);
1372    }
1373
1374    #[test]
1375    fn public_hash_join_rejects_invalid_projection_before_reading_rows() {
1376        let left = make_operator(vec![vec![1]], vec!["left_id"]);
1377        let right = make_operator(vec![vec![1]], vec!["right_id"]);
1378        let mut join = HashJoinOperator::new(
1379            left,
1380            right,
1381            JoinType::Inner,
1382            vec![0],
1383            vec![0],
1384            JoinSide::Right,
1385        )
1386        .with_projection(
1387            vec![ColumnSource::Inner(1)],
1388            vec![ColumnInfo::new("invalid")],
1389        );
1390
1391        assert!(join.open().is_err());
1392    }
1393
1394    #[test]
1395    fn test_left_join() {
1396        let left = make_operator(
1397            vec![vec![1, 10], vec![2, 20], vec![3, 30]],
1398            vec!["id", "value"],
1399        );
1400        let right = make_operator(vec![vec![1, 100]], vec!["id", "data"]);
1401
1402        let mut join = HashJoinOperator::new(
1403            left,
1404            right,
1405            JoinType::Left,
1406            vec![0],
1407            vec![0],
1408            JoinSide::Right,
1409        );
1410
1411        let results = collect_results(&mut join).unwrap();
1412
1413        // Should have 3 rows: id=1 matched, id=2 and id=3 with NULLs
1414        assert_eq!(results.len(), 3);
1415
1416        // Check that id=2 has NULLs on right side
1417        let row2 = results
1418            .iter()
1419            .find(|r| r.get(0) == Some(&Value::integer(2)))
1420            .unwrap();
1421        assert!(row2.get(2).unwrap().is_null());
1422        assert!(row2.get(3).unwrap().is_null());
1423    }
1424
1425    #[test]
1426    fn test_left_join_projection_uses_sparse_null_build_side() {
1427        let left = make_operator(
1428            vec![vec![1, 10], vec![2, 20], vec![3, 30]],
1429            vec!["id", "value"],
1430        );
1431        let right = make_operator(vec![vec![1, 100]], vec!["id", "data"]);
1432
1433        let mut join = HashJoinOperator::new(
1434            left,
1435            right,
1436            JoinType::Left,
1437            vec![0],
1438            vec![0],
1439            JoinSide::Right,
1440        )
1441        .with_projection(
1442            vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
1443            vec![ColumnInfo::new("value"), ColumnInfo::new("data")],
1444        );
1445
1446        let results = collect_results(&mut join).unwrap();
1447
1448        assert_eq!(results.len(), 3);
1449        let row2 = results
1450            .iter()
1451            .find(|row| row.get(0) == Some(&Value::integer(20)))
1452            .unwrap();
1453        assert_eq!(row2.len(), 2);
1454        assert!(row2.get(1).unwrap().is_null());
1455    }
1456
1457    #[test]
1458    fn test_self_join() {
1459        let input = make_operator(
1460            vec![vec![1, 10], vec![2, 10], vec![3, 20]],
1461            vec!["id", "age"],
1462        );
1463
1464        // Self-join on age (find pairs with same age)
1465        let mut join = HashJoinOperator::self_join(
1466            input,
1467            JoinType::Inner,
1468            vec![1], // left key: age
1469            vec![1], // right key: age
1470        );
1471
1472        let results = collect_results(&mut join).unwrap();
1473
1474        // id=1 and id=2 both have age=10, so we get:
1475        // (1,10) x (1,10), (1,10) x (2,10), (2,10) x (1,10), (2,10) x (2,10)
1476        // = 4 matches for age=10
1477        // id=3 has age=20, matches only itself = 1 match
1478        // Total = 5
1479        assert_eq!(results.len(), 5);
1480    }
1481
1482    #[test]
1483    fn test_empty_build() {
1484        let left = make_operator(vec![vec![1, 10], vec![2, 20]], vec!["id", "value"]);
1485        let right = make_operator(vec![], vec!["id", "data"]);
1486
1487        let mut join = HashJoinOperator::new(
1488            left,
1489            right,
1490            JoinType::Inner,
1491            vec![0],
1492            vec![0],
1493            JoinSide::Right,
1494        );
1495
1496        let results = collect_results(&mut join).unwrap();
1497        assert_eq!(results.len(), 0);
1498    }
1499
1500    #[test]
1501    fn test_multi_key_join() {
1502        let left = make_operator(
1503            vec![vec![1, 10, 100], vec![1, 20, 200], vec![2, 10, 300]],
1504            vec!["a", "b", "val"],
1505        );
1506        let right = make_operator(
1507            vec![vec![1, 10, 1000], vec![1, 20, 2000]],
1508            vec!["a", "b", "data"],
1509        );
1510
1511        let mut join = HashJoinOperator::new(
1512            left,
1513            right,
1514            JoinType::Inner,
1515            vec![0, 1], // left keys: a, b
1516            vec![0, 1], // right keys: a, b
1517            JoinSide::Right,
1518        );
1519
1520        let results = collect_results(&mut join).unwrap();
1521
1522        // Should match (1,10) and (1,20)
1523        assert_eq!(results.len(), 2);
1524    }
1525
1526    #[test]
1527    fn test_semi_join() {
1528        // Left: users with id 1, 2, 3
1529        let left = make_operator(
1530            vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1531            vec!["id", "value"],
1532        );
1533        // Right: orders for users 1 and 3 (user 1 has 2 orders)
1534        let right = make_operator(
1535            vec![vec![1, 10], vec![1, 20], vec![3, 30]],
1536            vec!["user_id", "order_id"],
1537        );
1538
1539        let mut join = HashJoinOperator::new(
1540            left,
1541            right,
1542            JoinType::Semi,
1543            vec![0], // left key: id
1544            vec![0], // right key: user_id
1545            JoinSide::Right,
1546        );
1547
1548        let results = collect_results(&mut join).unwrap();
1549
1550        // Semi join: return users who have at least one order
1551        // User 1 has 2 orders but should only appear once
1552        // User 2 has no orders - should NOT appear
1553        // User 3 has 1 order - should appear
1554        assert_eq!(results.len(), 2);
1555
1556        // Schema should only have left columns
1557        assert_eq!(join.schema().len(), 2);
1558
1559        // Verify we got users 1 and 3
1560        let ids: Vec<i64> = results
1561            .iter()
1562            .map(|r| r.get(0).unwrap().as_int64().unwrap())
1563            .collect();
1564        assert!(ids.contains(&1));
1565        assert!(ids.contains(&3));
1566        assert!(!ids.contains(&2));
1567    }
1568
1569    #[test]
1570    fn test_semi_join_projection_returns_requested_probe_columns_only() {
1571        let left = make_operator(
1572            vec![vec![1, 100, 1000], vec![2, 200, 2000], vec![3, 300, 3000]],
1573            vec!["id", "value", "unused_left"],
1574        );
1575        let right = make_operator(
1576            vec![vec![1, 10], vec![1, 20], vec![3, 30]],
1577            vec!["user_id", "order_id"],
1578        );
1579
1580        let mut join = HashJoinOperator::new(
1581            left,
1582            right,
1583            JoinType::Semi,
1584            vec![0],
1585            vec![0],
1586            JoinSide::Right,
1587        )
1588        .with_projection(vec![ColumnSource::Outer(1)], vec![ColumnInfo::new("value")]);
1589
1590        let results = collect_results(&mut join).unwrap();
1591
1592        assert_eq!(join.schema().len(), 1);
1593        assert_eq!(results.len(), 2);
1594        assert!(results.iter().all(|row| row.len() == 1));
1595        let values: Vec<i64> = results
1596            .iter()
1597            .map(|row| row.get(0).unwrap().as_int64().unwrap())
1598            .collect();
1599        assert!(values.contains(&100));
1600        assert!(values.contains(&300));
1601        assert!(!values.contains(&200));
1602    }
1603
1604    #[test]
1605    fn test_anti_join() {
1606        // Left: users with id 1, 2, 3
1607        let left = make_operator(
1608            vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1609            vec!["id", "value"],
1610        );
1611        // Right: orders for users 1 and 3
1612        let right = make_operator(vec![vec![1, 10], vec![3, 30]], vec!["user_id", "order_id"]);
1613
1614        let mut join = HashJoinOperator::new(
1615            left,
1616            right,
1617            JoinType::Anti,
1618            vec![0], // left key: id
1619            vec![0], // right key: user_id
1620            JoinSide::Right,
1621        );
1622
1623        let results = collect_results(&mut join).unwrap();
1624
1625        // Anti join: return users who have NO orders
1626        // User 1 has orders - should NOT appear
1627        // User 2 has no orders - should appear
1628        // User 3 has orders - should NOT appear
1629        assert_eq!(results.len(), 1);
1630
1631        // Schema should only have left columns
1632        assert_eq!(join.schema().len(), 2);
1633
1634        // Verify we only got user 2
1635        let row = &results[0];
1636        assert_eq!(row.get(0), Some(&Value::integer(2)));
1637        assert_eq!(row.get(1), Some(&Value::integer(200)));
1638    }
1639
1640    #[test]
1641    fn test_anti_join_projection_returns_requested_probe_columns_only() {
1642        let left = make_operator(
1643            vec![vec![1, 100, 1000], vec![2, 200, 2000], vec![3, 300, 3000]],
1644            vec!["id", "value", "unused_left"],
1645        );
1646        let right = make_operator(vec![vec![1, 10], vec![3, 30]], vec!["user_id", "order_id"]);
1647
1648        let mut join = HashJoinOperator::new(
1649            left,
1650            right,
1651            JoinType::Anti,
1652            vec![0],
1653            vec![0],
1654            JoinSide::Right,
1655        )
1656        .with_projection(vec![ColumnSource::Outer(1)], vec![ColumnInfo::new("value")]);
1657
1658        let results = collect_results(&mut join).unwrap();
1659
1660        assert_eq!(join.schema().len(), 1);
1661        assert_eq!(results.len(), 1);
1662        assert_eq!(results[0].len(), 1);
1663        assert_eq!(results[0].get(0), Some(&Value::integer(200)));
1664    }
1665
1666    #[test]
1667    fn test_anti_join_empty_right() {
1668        // Left: users with id 1, 2, 3
1669        let left = make_operator(
1670            vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1671            vec!["id", "value"],
1672        );
1673        // Right: no orders
1674        let right = make_operator(vec![], vec!["user_id", "order_id"]);
1675
1676        let mut join = HashJoinOperator::new(
1677            left,
1678            right,
1679            JoinType::Anti,
1680            vec![0],
1681            vec![0],
1682            JoinSide::Right,
1683        );
1684
1685        let results = collect_results(&mut join).unwrap();
1686
1687        // Anti join with empty right: all left rows should be returned
1688        assert_eq!(results.len(), 3);
1689    }
1690
1691    #[test]
1692    fn test_semi_join_empty_right() {
1693        // Left: users with id 1, 2, 3
1694        let left = make_operator(
1695            vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1696            vec!["id", "value"],
1697        );
1698        // Right: no orders
1699        let right = make_operator(vec![], vec!["user_id", "order_id"]);
1700
1701        let mut join = HashJoinOperator::new(
1702            left,
1703            right,
1704            JoinType::Semi,
1705            vec![0],
1706            vec![0],
1707            JoinSide::Right,
1708        );
1709
1710        let results = collect_results(&mut join).unwrap();
1711
1712        // Semi join with empty right: no left rows should be returned
1713        assert_eq!(results.len(), 0);
1714    }
1715}