1use uqa_sql::ast::JoinKind;
10use uqa_sql::expr::truthy;
11use uqa_sql::ResultRow;
12
13use super::{
14 output_schema, push_output_row, HybridRowStore, MatchFlags, DEFAULT_JOIN_WORK_MEM_BYTES,
15};
16use crate::{
17 Batch, ExecError, ExecResult, PhysicalOperator, PhysicalRow, RowSchema, ScalarExpr,
18 SharedExpressionEvaluator, SpillBuffer,
19};
20
21pub struct NestedLoopJoin<'a> {
26 left: Box<dyn PhysicalOperator + 'a>,
27 right: Box<dyn PhysicalOperator + 'a>,
28 kind: JoinKind,
29 predicate: Option<ScalarExpr>,
30 evaluator: SharedExpressionEvaluator<'a>,
31 left_nulls: PhysicalRow,
32 right_nulls: PhysicalRow,
33 schema: RowSchema,
34 work_mem_bytes: usize,
35 output: Option<crate::spill::SpillDrain>,
36 output_spilled: bool,
37 right_input_spilled: bool,
38}
39
40impl<'a> NestedLoopJoin<'a> {
41 pub fn new(
42 left: Box<dyn PhysicalOperator + 'a>,
43 right: Box<dyn PhysicalOperator + 'a>,
44 kind: JoinKind,
45 predicate: Option<ScalarExpr>,
46 evaluator: SharedExpressionEvaluator<'a>,
47 left_nulls: ResultRow,
48 right_nulls: ResultRow,
49 ) -> Self {
50 Self::new_with_work_mem(
51 left,
52 right,
53 kind,
54 predicate,
55 evaluator,
56 left_nulls,
57 right_nulls,
58 DEFAULT_JOIN_WORK_MEM_BYTES,
59 )
60 }
61
62 #[expect(
63 clippy::too_many_arguments,
64 reason = "keeps join keys and schema aligned"
65 )]
66 pub fn new_with_work_mem(
67 left: Box<dyn PhysicalOperator + 'a>,
68 right: Box<dyn PhysicalOperator + 'a>,
69 kind: JoinKind,
70 predicate: Option<ScalarExpr>,
71 evaluator: SharedExpressionEvaluator<'a>,
72 left_nulls: ResultRow,
73 right_nulls: ResultRow,
74 work_mem_bytes: usize,
75 ) -> Self {
76 let schema = output_schema(
77 left.row_schema(),
78 right.row_schema(),
79 &left_nulls,
80 &right_nulls,
81 );
82 let left_nulls = PhysicalRow::nulls(left.row_schema().physical_width());
83 let right_nulls = PhysicalRow::nulls(right.row_schema().physical_width());
84 Self {
85 left,
86 right,
87 kind,
88 predicate,
89 evaluator,
90 left_nulls,
91 right_nulls,
92 schema,
93 work_mem_bytes,
94 output: None,
95 output_spilled: false,
96 right_input_spilled: false,
97 }
98 }
99
100 pub fn output_has_spilled(&self) -> bool {
101 self.output_spilled
102 }
103
104 pub fn right_input_has_spilled(&self) -> bool {
107 self.right_input_spilled
108 }
109
110 fn matches(&self, row: &PhysicalRow) -> ExecResult<bool> {
111 match self.predicate.as_ref() {
112 None => Ok(true),
113 Some(predicate) => Ok(truthy(&self.evaluator.evaluate_physical(
114 predicate,
115 &self.schema,
116 row,
117 )?)),
118 }
119 }
120}
121
122impl PhysicalOperator for NestedLoopJoin<'_> {
123 fn row_schema(&self) -> &RowSchema {
124 &self.schema
125 }
126
127 fn open(&mut self) -> ExecResult<()> {
128 self.output = None;
129 self.output_spilled = false;
130 self.right_input_spilled = false;
131
132 let right_budget = self.work_mem_bytes / 2;
133 let output_budget = self.work_mem_bytes.saturating_sub(right_budget);
134 let right_schema = self.right.row_schema().clone();
135 let mut right = HybridRowStore::new(right_schema, right_budget);
136 self.right.open()?;
137 while let Some(batch) = self.right.next()? {
138 for row in batch.rows {
139 right.push(row)?;
140 }
141 }
142 self.right_input_spilled = right.has_spilled();
143 let mut matched_right = matches!(self.kind, JoinKind::Right | JoinKind::Full)
144 .then(|| MatchFlags::new(right.len()))
145 .transpose()?;
146 let mut output = SpillBuffer::new(output_budget);
147 let mut pending = Vec::with_capacity(crate::batch::DEFAULT_BATCH_SIZE);
148
149 self.left.open()?;
150 while let Some(batch) = self.left.next()? {
151 for left_row in batch.rows {
152 let mut matched_left = false;
153 for right_index in 0..right.len() {
154 right.with_row(right_index, |right_row| {
155 let merged = PhysicalRow::concat(&left_row, right_row);
156 if self.matches(&merged)? {
157 push_output_row(&mut output, &mut pending, &self.schema, merged)?;
158 matched_left = true;
159 if let Some(flags) = matched_right.as_mut() {
160 flags.mark(right_index)?;
161 }
162 }
163 Ok(())
164 })?;
165 }
166 if !matched_left && matches!(self.kind, JoinKind::Left | JoinKind::Full) {
167 push_output_row(
168 &mut output,
169 &mut pending,
170 &self.schema,
171 PhysicalRow::concat_left_owned(left_row, &self.right_nulls),
172 )?;
173 }
174 }
175 }
176
177 if matches!(self.kind, JoinKind::Right | JoinKind::Full) {
178 let matched_right = matched_right.as_mut().ok_or_else(|| {
179 ExecError::Other("right/full nested-loop join has no match flags".into())
180 })?;
181 for right_index in 0..right.len() {
182 if !matched_right.is_marked(right_index)? {
183 right.with_row(right_index, |right_row| {
184 push_output_row(
185 &mut output,
186 &mut pending,
187 &self.schema,
188 PhysicalRow::concat(&self.left_nulls, right_row),
189 )
190 })?;
191 }
192 }
193 }
194 if !pending.is_empty() {
195 output.push(Batch::from_physical_rows(self.schema.clone(), pending))?;
196 }
197 self.output_spilled = output.has_spilled();
198 self.output = Some(output.drain()?);
199 Ok(())
200 }
201
202 fn next(&mut self) -> ExecResult<Option<Batch>> {
203 self.output
204 .as_mut()
205 .map_or(Ok(None), |output| output.next().transpose())
206 }
207
208 fn close(&mut self) -> ExecResult<()> {
209 self.output = None;
210 let left = self.left.close();
211 let right = self.right.close();
212 crate::physical::with_cleanup(left, right, "close right nested-loop join input")
213 }
214}