Skip to main content

uqa_execution/join/
mod.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Physical relational join operators.
8
9mod canonical_index;
10mod direct_index;
11mod nested_loop;
12mod row_store;
13
14use smallvec::SmallVec;
15use uqa_core::Value;
16use uqa_sql::ast::JoinKind;
17use uqa_sql::expr::truthy;
18use uqa_sql::ResultRow;
19
20use crate::distinct::{encode_non_null_key, EncodedKey};
21use crate::{
22    Batch, ExecError, ExecResult, PhysicalOperator, PhysicalRow, ProjectedPredicate, RowSchema,
23    ScalarExpr, SharedExpressionEvaluator, SpillBuffer,
24};
25
26use canonical_index::{HybridHashIndex, MatchFlags, MemoryMatchSummary};
27use direct_index::{direct_unique_match, positional_key_hash, DirectHashIndex};
28use row_store::HybridRowStore;
29
30pub use nested_loop::NestedLoopJoin;
31
32#[cfg(test)]
33use canonical_index::{stable_hash, DiskHashIndex, HASH_BUCKETS};
34
35const DEFAULT_JOIN_WORK_MEM_BYTES: usize = 64 * 1024 * 1024;
36
37fn output_schema(
38    left: &RowSchema,
39    right: &RowSchema,
40    left_nulls: &ResultRow,
41    right_nulls: &ResultRow,
42) -> RowSchema {
43    RowSchema::join(
44        left,
45        right,
46        left_nulls.keys().chain(right_nulls.keys()).cloned(),
47    )
48}
49
50fn push_output_row(
51    output: &mut SpillBuffer,
52    pending: &mut Vec<PhysicalRow>,
53    schema: &RowSchema,
54    row: PhysicalRow,
55) -> ExecResult<()> {
56    pending.push(row);
57    if pending.len() == crate::batch::DEFAULT_BATCH_SIZE {
58        output.push(Batch::from_physical_rows(
59            schema.clone(),
60            std::mem::take(pending),
61        ))?;
62        pending.reserve(crate::batch::DEFAULT_BATCH_SIZE);
63    }
64    Ok(())
65}
66
67fn join_io_error(operation: &str, error: impl std::fmt::Display) -> ExecError {
68    ExecError::Other(format!("join spill {operation}: {error}"))
69}
70
71fn simple_key_positions(schema: &RowSchema, expressions: &[ScalarExpr]) -> Option<Vec<usize>> {
72    expressions
73        .iter()
74        .map(|expression| match expression {
75            ScalarExpr::Column(column) => schema.position(column),
76            ScalarExpr::QualifiedColumn { qualifier, column } => {
77                schema.qualified_position(qualifier, column)
78            }
79            _ => None,
80        })
81        .collect()
82}
83
84/// Equality join backed by a canonical SQL-key hash table. SQL NULL keys never
85/// match. An optional residual predicate is evaluated on hash candidates
86/// before either side is marked matched, preserving mixed equijoin/non-equality
87/// `ON` semantics for every outer-join shape.
88pub struct HashJoin<'a> {
89    left: Box<dyn PhysicalOperator + 'a>,
90    right: Box<dyn PhysicalOperator + 'a>,
91    kind: JoinKind,
92    left_keys: Vec<ScalarExpr>,
93    right_keys: Vec<ScalarExpr>,
94    left_key_positions: Option<Vec<usize>>,
95    right_key_positions: Option<Vec<usize>>,
96    predicate: Option<ScalarExpr>,
97    prepared_predicate: Option<ProjectedPredicate>,
98    evaluator: SharedExpressionEvaluator<'a>,
99    left_nulls: PhysicalRow,
100    right_nulls: PhysicalRow,
101    schema: RowSchema,
102    estimated_cardinality: Option<u64>,
103    build_left: bool,
104    work_mem_bytes: usize,
105    output: Option<crate::spill::SpillDrain>,
106    streaming_unique: Option<UniqueHashJoinState>,
107    output_spilled: SpillState,
108    right_input_spilled: SpillState,
109    hash_index_spilled: SpillState,
110}
111
112#[derive(Clone, Copy, Default, Eq, PartialEq)]
113enum SpillState {
114    #[default]
115    InMemory,
116    Spilled,
117}
118
119impl SpillState {
120    fn is_spilled(self) -> bool {
121        matches!(self, Self::Spilled)
122    }
123}
124
125impl From<bool> for SpillState {
126    fn from(spilled: bool) -> Self {
127        if spilled {
128            Self::Spilled
129        } else {
130            Self::InMemory
131        }
132    }
133}
134
135/// State retained while an in-memory unique-key inner join streams its probe
136/// side. At most one output row can be produced per probe row, so the join can
137/// preserve batch backpressure without a cardinality-sized output buffer.
138struct UniqueHashJoinState {
139    build_rows: HybridRowStore,
140    hash_index: UniqueHashIndex,
141    build_left: bool,
142}
143
144enum UniqueHashIndex {
145    /// Simple column keys keep only hashes and row positions. Candidate keys
146    /// are verified against the original build row slots.
147    Direct(DirectHashIndex),
148    /// Evaluated expressions and spill fallback retain canonical byte keys.
149    Encoded(HybridHashIndex),
150}
151
152impl<'a> HashJoin<'a> {
153    #[expect(
154        clippy::too_many_arguments,
155        reason = "keeps join keys and schema aligned"
156    )]
157    pub fn new(
158        left: Box<dyn PhysicalOperator + 'a>,
159        right: Box<dyn PhysicalOperator + 'a>,
160        kind: JoinKind,
161        left_keys: Vec<ScalarExpr>,
162        right_keys: Vec<ScalarExpr>,
163        evaluator: SharedExpressionEvaluator<'a>,
164        left_nulls: ResultRow,
165        right_nulls: ResultRow,
166    ) -> Self {
167        Self::new_with_work_mem(
168            left,
169            right,
170            kind,
171            left_keys,
172            right_keys,
173            evaluator,
174            left_nulls,
175            right_nulls,
176            DEFAULT_JOIN_WORK_MEM_BYTES,
177        )
178    }
179
180    #[expect(
181        clippy::too_many_arguments,
182        reason = "keeps join keys and schema aligned"
183    )]
184    pub fn new_with_work_mem(
185        left: Box<dyn PhysicalOperator + 'a>,
186        right: Box<dyn PhysicalOperator + 'a>,
187        kind: JoinKind,
188        left_keys: Vec<ScalarExpr>,
189        right_keys: Vec<ScalarExpr>,
190        evaluator: SharedExpressionEvaluator<'a>,
191        left_nulls: ResultRow,
192        right_nulls: ResultRow,
193        work_mem_bytes: usize,
194    ) -> Self {
195        Self::new_with_work_mem_and_predicate(
196            left,
197            right,
198            kind,
199            left_keys,
200            right_keys,
201            None,
202            evaluator,
203            left_nulls,
204            right_nulls,
205            work_mem_bytes,
206        )
207    }
208
209    #[expect(
210        clippy::too_many_arguments,
211        reason = "keeps join keys and schema aligned"
212    )]
213    pub fn new_with_work_mem_and_predicate(
214        left: Box<dyn PhysicalOperator + 'a>,
215        right: Box<dyn PhysicalOperator + 'a>,
216        kind: JoinKind,
217        left_keys: Vec<ScalarExpr>,
218        right_keys: Vec<ScalarExpr>,
219        predicate: Option<ScalarExpr>,
220        evaluator: SharedExpressionEvaluator<'a>,
221        left_nulls: ResultRow,
222        right_nulls: ResultRow,
223        work_mem_bytes: usize,
224    ) -> Self {
225        let left_key_positions = simple_key_positions(left.row_schema(), &left_keys);
226        let right_key_positions = simple_key_positions(right.row_schema(), &right_keys);
227        let schema = output_schema(
228            left.row_schema(),
229            right.row_schema(),
230            &left_nulls,
231            &right_nulls,
232        );
233        let left_nulls = PhysicalRow::nulls(left.row_schema().physical_width());
234        let right_nulls = PhysicalRow::nulls(right.row_schema().physical_width());
235        let left_cardinality = left.estimated_cardinality();
236        let right_cardinality = right.estimated_cardinality();
237        let build_left = matches!(kind, JoinKind::Inner)
238            && left_cardinality
239                .zip(right_cardinality)
240                .is_some_and(|(left, right)| left < right);
241        let estimated_cardinality = left_cardinality
242            .zip(right_cardinality)
243            .map(|(left, right)| match kind {
244                JoinKind::Inner => left.max(right),
245                JoinKind::Left => left,
246                JoinKind::Right => right,
247                JoinKind::Full => left.saturating_add(right),
248                JoinKind::Cross => left.saturating_mul(right),
249            });
250        let prepared_predicate = predicate.as_ref().and_then(|predicate| {
251            ProjectedPredicate::compile_with_schema(predicate, &schema, &[])
252                .ok()
253                .flatten()
254        });
255        Self {
256            left,
257            right,
258            kind,
259            left_keys,
260            right_keys,
261            left_key_positions,
262            right_key_positions,
263            predicate,
264            prepared_predicate,
265            evaluator,
266            left_nulls,
267            right_nulls,
268            schema,
269            estimated_cardinality,
270            build_left,
271            work_mem_bytes,
272            output: None,
273            streaming_unique: None,
274            output_spilled: SpillState::InMemory,
275            right_input_spilled: SpillState::InMemory,
276            hash_index_spilled: SpillState::InMemory,
277        }
278    }
279
280    /// Construct a hash join while preparing supported residual predicates
281    /// against its composite output schema. Parameters and constant LIKE
282    /// patterns are folded exactly once before any candidate row is probed.
283    #[expect(
284        clippy::too_many_arguments,
285        reason = "keeps join keys and schema aligned"
286    )]
287    pub fn try_new_with_work_mem_and_predicate(
288        left: Box<dyn PhysicalOperator + 'a>,
289        right: Box<dyn PhysicalOperator + 'a>,
290        kind: JoinKind,
291        left_keys: Vec<ScalarExpr>,
292        right_keys: Vec<ScalarExpr>,
293        predicate: Option<ScalarExpr>,
294        evaluator: SharedExpressionEvaluator<'a>,
295        left_nulls: ResultRow,
296        right_nulls: ResultRow,
297        work_mem_bytes: usize,
298        params: &[uqa_sql::SQLParam],
299    ) -> ExecResult<Self> {
300        let mut join = Self::new_with_work_mem_and_predicate(
301            left,
302            right,
303            kind,
304            left_keys,
305            right_keys,
306            predicate,
307            evaluator,
308            left_nulls,
309            right_nulls,
310            work_mem_bytes,
311        );
312        join.prepared_predicate = join
313            .predicate
314            .as_ref()
315            .map(|predicate| {
316                ProjectedPredicate::compile_with_schema(predicate, &join.schema, params)
317            })
318            .transpose()?
319            .flatten();
320        Ok(join)
321    }
322
323    pub fn output_has_spilled(&self) -> bool {
324        self.output_spilled.is_spilled()
325    }
326
327    pub fn right_input_has_spilled(&self) -> bool {
328        self.right_input_spilled.is_spilled()
329    }
330
331    pub fn hash_index_has_spilled(&self) -> bool {
332        self.hash_index_spilled.is_spilled()
333    }
334
335    pub fn builds_left_input(&self) -> bool {
336        self.build_left
337    }
338
339    fn rebuild_encoded_index(
340        &self,
341        rows: &mut HybridRowStore,
342        expressions: &[ScalarExpr],
343        positions: &[usize],
344        budget_bytes: usize,
345    ) -> ExecResult<HybridHashIndex> {
346        let schema = rows.schema.clone();
347        let mut index = HybridHashIndex::new(budget_bytes);
348        for row_index in 0..rows.len() {
349            let key = rows.with_row(row_index, |row| {
350                self.key(expressions, Some(positions), row, &schema)
351            })?;
352            if let Some(key) = key {
353                index.insert(key, row_index)?;
354            }
355        }
356        Ok(index)
357    }
358
359    #[expect(
360        clippy::too_many_lines,
361        reason = "join driver keeps key layout, NULL policy, and output order aligned"
362    )]
363    fn open_build_left(&mut self, state_budget: usize, output_budget: usize) -> ExecResult<()> {
364        debug_assert!(matches!(self.kind, JoinKind::Inner));
365        let left_budget = state_budget / 2;
366        let hash_budget = state_budget.saturating_sub(left_budget);
367        let left_schema = self.left.row_schema().clone();
368        let mut left = HybridRowStore::new(left_schema, left_budget);
369        let direct_positions = self
370            .predicate
371            .is_none()
372            .then_some(())
373            .and(self.left_key_positions.as_deref())
374            .zip(self.right_key_positions.as_deref());
375        let mut direct_index = direct_positions.map(|_| DirectHashIndex::new(hash_budget));
376        let mut encoded_index = direct_index
377            .is_none()
378            .then(|| HybridHashIndex::new(hash_budget));
379        self.left.open()?;
380        while let Some(batch) = self.left.next()? {
381            for row in batch.rows {
382                let index = left.len();
383                if let (Some(direct), Some((positions, _))) =
384                    (direct_index.as_mut(), direct_positions)
385                {
386                    if let Some(hash) =
387                        positional_key_hash(direct.hasher(), &batch.schema, &row, positions)?
388                    {
389                        direct.insert(hash, index)?;
390                    }
391                } else if let Some(key) = self.key(
392                    &self.left_keys,
393                    self.left_key_positions.as_deref(),
394                    &row,
395                    &batch.schema,
396                )? {
397                    encoded_index
398                        .as_mut()
399                        .ok_or_else(|| ExecError::Other("join hash index is missing".into()))?
400                        .insert(key, index)?;
401                }
402                left.push(row)?;
403            }
404        }
405        self.right_input_spilled = SpillState::InMemory;
406
407        let mut output = SpillBuffer::new(output_budget);
408        if left.len() == 0 {
409            self.output = Some(output.drain()?);
410            return Ok(());
411        }
412
413        let direct_is_unique = direct_index.as_ref().is_some_and(|direct| {
414            direct_positions.is_some_and(|(positions, _)| {
415                !left.has_spilled() && direct.keys_are_unique(&left, &left.schema, positions)
416            })
417        });
418        if direct_is_unique {
419            self.right.open()?;
420            self.streaming_unique = Some(UniqueHashJoinState {
421                build_rows: left,
422                hash_index: UniqueHashIndex::Direct(
423                    direct_index
424                        .take()
425                        .ok_or_else(|| ExecError::Other("direct join index is missing".into()))?,
426                ),
427                build_left: true,
428            });
429            return Ok(());
430        }
431
432        let mut left_by_key = match encoded_index {
433            Some(index) => index,
434            None => {
435                let (positions, _) = direct_positions
436                    .ok_or_else(|| ExecError::Other("direct join positions are missing".into()))?;
437                self.rebuild_encoded_index(&mut left, &self.left_keys, positions, hash_budget)?
438            }
439        };
440        self.hash_index_spilled = left_by_key.has_spilled().into();
441        if self.predicate.is_none() && !left.has_spilled() && left_by_key.is_memory_unique() {
442            self.right.open()?;
443            self.streaming_unique = Some(UniqueHashJoinState {
444                build_rows: left,
445                hash_index: UniqueHashIndex::Encoded(left_by_key),
446                build_left: true,
447            });
448            return Ok(());
449        }
450        let mut pending = Vec::with_capacity(crate::batch::DEFAULT_BATCH_SIZE);
451
452        self.right.open()?;
453        while let Some(batch) = self.right.next()? {
454            for right_row in batch.rows {
455                let Some(key) = self.key(
456                    &self.right_keys,
457                    self.right_key_positions.as_deref(),
458                    &right_row,
459                    &batch.schema,
460                )?
461                else {
462                    continue;
463                };
464                if self.predicate.is_none() {
465                    match left_by_key.memory_match_summary(&key) {
466                        Some(MemoryMatchSummary::Absent) => continue,
467                        Some(MemoryMatchSummary::Single(index)) => {
468                            let merged = left.with_row(index, |left_row| {
469                                Ok(PhysicalRow::concat_right_owned(left_row, right_row))
470                            })?;
471                            push_output_row(&mut output, &mut pending, &self.schema, merged)?;
472                            continue;
473                        }
474                        Some(MemoryMatchSummary::Multiple) | None => {}
475                    }
476                }
477                left_by_key.for_each_match(&key, &mut |index| {
478                    left.with_row(index, |left_row| {
479                        let merged = PhysicalRow::concat(left_row, &right_row);
480                        if self.matches(&merged)? {
481                            push_output_row(&mut output, &mut pending, &self.schema, merged)?;
482                        }
483                        Ok(())
484                    })
485                })?;
486            }
487        }
488        if !pending.is_empty() {
489            output.push(Batch::from_physical_rows(self.schema.clone(), pending))?;
490        }
491        self.output_spilled = output.has_spilled().into();
492        self.output = Some(output.drain()?);
493        Ok(())
494    }
495
496    fn key(
497        &self,
498        expressions: &[ScalarExpr],
499        positions: Option<&[usize]>,
500        row: &PhysicalRow,
501        schema: &RowSchema,
502    ) -> ExecResult<Option<EncodedKey>> {
503        if let Some(positions) = positions {
504            let view = schema.view(row);
505            return encode_non_null_key(positions.iter().map(|position| view.value_at(*position)));
506        }
507        let mut values = SmallVec::<[Value; 4]>::with_capacity(expressions.len());
508        for expression in expressions {
509            let value = self.evaluator.evaluate_physical(expression, schema, row)?;
510            if matches!(value, Value::Null) {
511                return Ok(None);
512            }
513            values.push(value);
514        }
515        encode_non_null_key(values.iter().map(Some))
516    }
517
518    fn matches(&self, row: &PhysicalRow) -> ExecResult<bool> {
519        if let Some(predicate) = self.prepared_predicate.as_ref() {
520            return Ok(predicate.keep_row(&self.schema.view(row))?);
521        }
522        self.predicate.as_ref().map_or(Ok(true), |predicate| {
523            Ok(truthy(&self.evaluator.evaluate_physical(
524                predicate,
525                &self.schema,
526                row,
527            )?))
528        })
529    }
530
531    fn next_streaming_unique(
532        &mut self,
533        state: &mut UniqueHashJoinState,
534    ) -> ExecResult<Option<Batch>> {
535        loop {
536            let next = if state.build_left {
537                self.right.next()?
538            } else {
539                self.left.next()?
540            };
541            let Some(batch) = next else {
542                return Ok(None);
543            };
544            let mut output = Vec::with_capacity(batch.rows.len());
545            for probe_row in batch.rows {
546                let index = match &state.hash_index {
547                    UniqueHashIndex::Direct(index) => {
548                        let (build_positions, probe_positions) = if state.build_left {
549                            (
550                                self.left_key_positions.as_deref(),
551                                self.right_key_positions.as_deref(),
552                            )
553                        } else {
554                            (
555                                self.right_key_positions.as_deref(),
556                                self.left_key_positions.as_deref(),
557                            )
558                        };
559                        let (Some(build_positions), Some(probe_positions)) =
560                            (build_positions, probe_positions)
561                        else {
562                            return Err(ExecError::Other(
563                                "direct join key positions are missing".into(),
564                            ));
565                        };
566                        direct_unique_match(
567                            index,
568                            &state.build_rows,
569                            build_positions,
570                            &batch.schema,
571                            &probe_row,
572                            probe_positions,
573                        )?
574                    }
575                    UniqueHashIndex::Encoded(index) => {
576                        let expressions = if state.build_left {
577                            &self.right_keys
578                        } else {
579                            &self.left_keys
580                        };
581                        let positions = if state.build_left {
582                            self.right_key_positions.as_deref()
583                        } else {
584                            self.left_key_positions.as_deref()
585                        };
586                        let Some(key) =
587                            self.key(expressions, positions, &probe_row, &batch.schema)?
588                        else {
589                            continue;
590                        };
591                        match index.memory_match_summary(&key) {
592                            Some(MemoryMatchSummary::Single(index)) => Some(index),
593                            _ => None,
594                        }
595                    }
596                };
597                let Some(index) = index else { continue };
598                let merged = if state.build_left {
599                    state.build_rows.with_row(index, |build_row| {
600                        Ok(PhysicalRow::concat_right_owned(build_row, probe_row))
601                    })?
602                } else {
603                    state.build_rows.with_row(index, |build_row| {
604                        Ok(PhysicalRow::concat_left_owned(probe_row, build_row))
605                    })?
606                };
607                output.push(merged);
608            }
609            if !output.is_empty() {
610                return Ok(Some(Batch::from_physical_rows(self.schema.clone(), output)));
611            }
612        }
613    }
614}
615
616impl PhysicalOperator for HashJoin<'_> {
617    fn row_schema(&self) -> &RowSchema {
618        &self.schema
619    }
620
621    fn estimated_cardinality(&self) -> Option<u64> {
622        self.estimated_cardinality
623    }
624
625    #[expect(
626        clippy::too_many_lines,
627        reason = "join driver keeps key layout, NULL policy, and output order aligned"
628    )]
629    fn open(&mut self) -> ExecResult<()> {
630        self.output = None;
631        self.streaming_unique = None;
632        self.output_spilled = SpillState::InMemory;
633        self.right_input_spilled = SpillState::InMemory;
634        self.hash_index_spilled = SpillState::InMemory;
635
636        let state_budget = self.work_mem_bytes / 2;
637        let output_budget = self.work_mem_bytes.saturating_sub(state_budget);
638        if self.build_left {
639            return self.open_build_left(state_budget, output_budget);
640        }
641        let right_budget = state_budget / 2;
642        let hash_budget = state_budget.saturating_sub(right_budget);
643        let right_schema = self.right.row_schema().clone();
644        let mut right = HybridRowStore::new(right_schema, right_budget);
645        let direct_positions = (matches!(self.kind, JoinKind::Inner) && self.predicate.is_none())
646            .then_some(())
647            .and(self.right_key_positions.as_deref())
648            .zip(self.left_key_positions.as_deref());
649        let mut direct_index = direct_positions.map(|_| DirectHashIndex::new(hash_budget));
650        let mut encoded_index = direct_index
651            .is_none()
652            .then(|| HybridHashIndex::new(hash_budget));
653        self.right.open()?;
654        while let Some(batch) = self.right.next()? {
655            for row in batch.rows {
656                let index = right.len();
657                if let (Some(direct), Some((positions, _))) =
658                    (direct_index.as_mut(), direct_positions)
659                {
660                    if let Some(hash) =
661                        positional_key_hash(direct.hasher(), &batch.schema, &row, positions)?
662                    {
663                        direct.insert(hash, index)?;
664                    }
665                } else if let Some(key) = self.key(
666                    &self.right_keys,
667                    self.right_key_positions.as_deref(),
668                    &row,
669                    &batch.schema,
670                )? {
671                    encoded_index
672                        .as_mut()
673                        .ok_or_else(|| ExecError::Other("join hash index is missing".into()))?
674                        .insert(key, index)?;
675                }
676                right.push(row)?;
677            }
678        }
679        self.right_input_spilled = right.has_spilled().into();
680
681        if right.len() == 0 && matches!(self.kind, JoinKind::Inner) {
682            let mut output = SpillBuffer::new(output_budget);
683            self.output = Some(output.drain()?);
684            return Ok(());
685        }
686
687        let direct_is_unique = direct_index.as_ref().is_some_and(|direct| {
688            direct_positions.is_some_and(|(positions, _)| {
689                !right.has_spilled() && direct.keys_are_unique(&right, &right.schema, positions)
690            })
691        });
692        if direct_is_unique {
693            self.left.open()?;
694            self.streaming_unique = Some(UniqueHashJoinState {
695                build_rows: right,
696                hash_index: UniqueHashIndex::Direct(
697                    direct_index
698                        .take()
699                        .ok_or_else(|| ExecError::Other("direct join index is missing".into()))?,
700                ),
701                build_left: false,
702            });
703            return Ok(());
704        }
705
706        let mut right_by_key = match encoded_index {
707            Some(index) => index,
708            None => {
709                let (positions, _) = direct_positions
710                    .ok_or_else(|| ExecError::Other("direct join positions are missing".into()))?;
711                self.rebuild_encoded_index(&mut right, &self.right_keys, positions, hash_budget)?
712            }
713        };
714        self.hash_index_spilled = right_by_key.has_spilled().into();
715        if matches!(self.kind, JoinKind::Inner)
716            && self.predicate.is_none()
717            && !right.has_spilled()
718            && right_by_key.is_memory_unique()
719        {
720            self.left.open()?;
721            self.streaming_unique = Some(UniqueHashJoinState {
722                build_rows: right,
723                hash_index: UniqueHashIndex::Encoded(right_by_key),
724                build_left: false,
725            });
726            return Ok(());
727        }
728
729        let mut matched_right = matches!(self.kind, JoinKind::Right | JoinKind::Full)
730            .then(|| MatchFlags::new(right.len()))
731            .transpose()?;
732        let mut output = SpillBuffer::new(output_budget);
733        let mut pending = Vec::with_capacity(crate::batch::DEFAULT_BATCH_SIZE);
734
735        self.left.open()?;
736        while let Some(batch) = self.left.next()? {
737            for left_row in batch.rows {
738                let mut matched_left = false;
739                if let Some(key) = self.key(
740                    &self.left_keys,
741                    self.left_key_positions.as_deref(),
742                    &left_row,
743                    &batch.schema,
744                )? {
745                    if self.predicate.is_none() {
746                        match right_by_key.memory_match_summary(&key) {
747                            Some(MemoryMatchSummary::Absent) => {
748                                if matches!(self.kind, JoinKind::Left | JoinKind::Full) {
749                                    push_output_row(
750                                        &mut output,
751                                        &mut pending,
752                                        &self.schema,
753                                        PhysicalRow::concat_left_owned(left_row, &self.right_nulls),
754                                    )?;
755                                }
756                                continue;
757                            }
758                            Some(MemoryMatchSummary::Single(index)) => {
759                                let merged = right.with_row(index, |right_row| {
760                                    Ok(PhysicalRow::concat_left_owned(left_row, right_row))
761                                })?;
762                                push_output_row(&mut output, &mut pending, &self.schema, merged)?;
763                                if let Some(flags) = matched_right.as_mut() {
764                                    flags.mark(index)?;
765                                }
766                                continue;
767                            }
768                            Some(MemoryMatchSummary::Multiple) | None => {}
769                        }
770                    }
771                    right_by_key.for_each_match(&key, &mut |index| {
772                        right.with_row(index, |right_row| {
773                            let merged = PhysicalRow::concat(&left_row, right_row);
774                            if self.matches(&merged)? {
775                                push_output_row(&mut output, &mut pending, &self.schema, merged)?;
776                                if let Some(flags) = matched_right.as_mut() {
777                                    flags.mark(index)?;
778                                }
779                                matched_left = true;
780                            }
781                            Ok(())
782                        })
783                    })?;
784                }
785                if !matched_left && matches!(self.kind, JoinKind::Left | JoinKind::Full) {
786                    push_output_row(
787                        &mut output,
788                        &mut pending,
789                        &self.schema,
790                        PhysicalRow::concat_left_owned(left_row, &self.right_nulls),
791                    )?;
792                }
793            }
794        }
795
796        if matches!(self.kind, JoinKind::Right | JoinKind::Full) {
797            let matched_right = matched_right.as_mut().ok_or_else(|| {
798                ExecError::Other("right/full hash join has no match flags".into())
799            })?;
800            for index in 0..right.len() {
801                if !matched_right.is_marked(index)? {
802                    right.with_row(index, |right_row| {
803                        push_output_row(
804                            &mut output,
805                            &mut pending,
806                            &self.schema,
807                            PhysicalRow::concat(&self.left_nulls, right_row),
808                        )
809                    })?;
810                }
811            }
812        }
813        if !pending.is_empty() {
814            output.push(Batch::from_physical_rows(self.schema.clone(), pending))?;
815        }
816        self.output_spilled = output.has_spilled().into();
817        self.output = Some(output.drain()?);
818        Ok(())
819    }
820
821    fn next(&mut self) -> ExecResult<Option<Batch>> {
822        if let Some(mut state) = self.streaming_unique.take() {
823            let result = self.next_streaming_unique(&mut state);
824            self.streaming_unique = Some(state);
825            return result;
826        }
827        self.output
828            .as_mut()
829            .map_or(Ok(None), |output| output.next().transpose())
830    }
831
832    fn close(&mut self) -> ExecResult<()> {
833        self.output = None;
834        self.streaming_unique = None;
835        let left = self.left.close();
836        let right = self.right.close();
837        crate::physical::with_cleanup(left, right, "close right hash-join input")
838    }
839}
840
841#[cfg(test)]
842mod tests;