Skip to main content

reifydb_engine/vm/volcano/join/
natural.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::collections::{HashMap, HashSet};
5
6use arrow_array::{Array, RecordBatch};
7use arrow_row::RowConverter;
8use reifydb_core::{
9	common::JoinType, error::diagnostic::operation, internal_error, value::column::headers::ColumnHeaders,
10};
11use reifydb_transaction::transaction::Transaction;
12use reifydb_value::{
13	error,
14	fragment::Fragment,
15	reifydb_assertions,
16	value::{system_columns::user_columns, value_type::ValueType},
17};
18use tracing::instrument;
19
20use super::common::{
21	JoinContext, JoinSlot, ensure_join_keyable, join_key_types, load_and_merge_all, materialize_join,
22	resolve_column_names, user_key_columns, user_views,
23};
24use crate::{
25	Result,
26	vm::volcano::{
27		key_rows::{key_arrays, key_rows},
28		query::{QueryContext, QueryNode},
29	},
30};
31
32pub struct NaturalJoinNode {
33	left: Box<dyn QueryNode>,
34	right: Box<dyn QueryNode>,
35	join_type: JoinType,
36	alias: Option<Fragment>,
37	left_name: Option<Fragment>,
38	fragment: Fragment,
39	headers: Option<ColumnHeaders>,
40	context: JoinContext,
41}
42
43impl NaturalJoinNode {
44	pub(crate) fn new(
45		left: Box<dyn QueryNode>,
46		right: Box<dyn QueryNode>,
47		join_type: JoinType,
48		alias: Option<Fragment>,
49		left_name: Option<Fragment>,
50		fragment: Fragment,
51	) -> Self {
52		Self {
53			left,
54			right,
55			join_type,
56			alias,
57			left_name,
58			fragment,
59			headers: None,
60			context: JoinContext::new(),
61		}
62	}
63
64	fn find_common_columns(left_columns: &RecordBatch, right_columns: &RecordBatch) -> Vec<(String, usize, usize)> {
65		let mut common_columns = Vec::new();
66
67		for (left_idx, (left_field, _)) in user_columns(left_columns).enumerate() {
68			for (right_idx, (right_field, _)) in user_columns(right_columns).enumerate() {
69				if left_field.name() == right_field.name() {
70					common_columns.push((left_field.name().to_string(), left_idx, right_idx));
71				}
72			}
73		}
74
75		common_columns
76	}
77}
78
79impl QueryNode for NaturalJoinNode {
80	#[instrument(name = "volcano::join::natural::initialize", level = "trace", skip_all)]
81	fn initialize<'a>(&mut self, rx: &mut Transaction<'a>, ctx: &QueryContext) -> Result<()> {
82		self.context.set(ctx);
83		self.left.initialize(rx, ctx)?;
84		self.right.initialize(rx, ctx)?;
85		Ok(())
86	}
87
88	#[instrument(name = "volcano::join::natural::next", level = "trace", skip_all)]
89	fn next<'a>(&mut self, rx: &mut Transaction<'a>, ctx: &mut QueryContext) -> Result<Option<RecordBatch>> {
90		reifydb_assertions! {
91			assert!(self.context.is_initialized(), "NaturalJoinNode::next() called before initialize()");
92		}
93
94		if self.headers.is_some() {
95			return Ok(None);
96		}
97
98		let left_columns = load_and_merge_all(&mut self.left, rx, ctx)?;
99		let right_columns = load_and_merge_all(&mut self.right, rx, ctx)?;
100
101		let left_rows = left_columns.num_rows();
102
103		let common_columns = Self::find_common_columns(&left_columns, &right_columns);
104
105		if common_columns.is_empty() {
106			let left = self.left_name.as_ref().map_or("the left input", |name| name.text());
107			let right = self.alias.as_ref().map_or("the right input", |name| name.text());
108			return Err(error!(operation::natural_join_no_shared_column(
109				self.fragment.clone(),
110				left,
111				right
112			)));
113		}
114
115		let excluded_right_cols: HashSet<usize> =
116			common_columns.iter().map(|(_, _, right_idx)| *right_idx).collect();
117
118		let excluded_indices: Vec<usize> = excluded_right_cols.iter().copied().collect();
119
120		let resolved =
121			resolve_column_names(&left_columns, &right_columns, &self.alias, Some(&excluded_indices));
122
123		let right_col_indices: Vec<usize> = common_columns.iter().map(|(_, _, ri)| *ri).collect();
124		let left_col_indices: Vec<usize> = common_columns.iter().map(|(_, li, _)| *li).collect();
125		let left_views = user_views(&left_columns)?;
126		let right_views = user_views(&right_columns)?;
127		ensure_join_keyable(&left_views, &left_col_indices, |_| self.fragment.clone())?;
128		ensure_join_keyable(&right_views, &right_col_indices, |_| self.fragment.clone())?;
129		let targets = join_key_types(&left_views, &left_col_indices, &right_views, &right_col_indices, |_| {
130			self.fragment.clone()
131		})?;
132
133		let (converter, hash_table) = Self::build(&right_columns, &right_col_indices, &targets)?;
134
135		let (left_picks, right_picks) = self.probe(
136			&left_columns,
137			&ProbeContext {
138				converter: &converter,
139				hash_table: &hash_table,
140				left_col_indices: &left_col_indices,
141				targets: &targets,
142				left_rows,
143			},
144		)?;
145
146		let columns = materialize_join(
147			&resolved.qualified_names,
148			&[JoinSlot {
149				columns: &left_columns,
150				picks: &left_picks,
151			}],
152			&right_columns,
153			&excluded_indices,
154			&right_picks,
155			0,
156		)?;
157
158		self.headers = Some(ColumnHeaders::from_batch(&columns));
159		Ok(Some(columns))
160	}
161
162	fn headers(&self) -> Option<ColumnHeaders> {
163		self.headers.clone()
164	}
165}
166
167type KeyIndex = HashMap<Box<[u8]>, Vec<usize>>;
168
169struct ProbeContext<'a> {
170	converter: &'a RowConverter,
171	hash_table: &'a KeyIndex,
172	left_col_indices: &'a [usize],
173	targets: &'a [ValueType],
174	left_rows: usize,
175}
176
177impl NaturalJoinNode {
178	#[instrument(level = "trace", skip_all, name = "volcano::join::natural::build")]
179	fn build(
180		right_columns: &RecordBatch,
181		right_col_indices: &[usize],
182		targets: &[ValueType],
183	) -> Result<(RowConverter, KeyIndex)> {
184		let (converter, arrays) = key_rows(&user_key_columns(right_columns, right_col_indices), targets)?;
185		let rows = converter
186			.convert_columns(&arrays)
187			.map_err(|e| internal_error!("Failed to build join keys: {}", e))?;
188		let mut hash_table: KeyIndex = HashMap::new();
189		for j in 0..right_columns.num_rows() {
190			if arrays.iter().any(|array| array.is_null(j)) {
191				continue;
192			}
193			let key = rows.row(j);
194			match hash_table.get_mut(key.as_ref()) {
195				Some(indices) => indices.push(j),
196				None => {
197					hash_table.insert(Box::from(key.as_ref()), vec![j]);
198				}
199			}
200		}
201		Ok((converter, hash_table))
202	}
203
204	#[instrument(level = "trace", skip_all, name = "volcano::join::natural::probe")]
205	fn probe(
206		&self,
207		left_columns: &RecordBatch,
208		probe_ctx: &ProbeContext,
209	) -> Result<(Vec<usize>, Vec<Option<usize>>)> {
210		let ProbeContext {
211			converter,
212			hash_table,
213			left_col_indices,
214			targets,
215			left_rows,
216		} = probe_ctx;
217		let arrays = key_arrays(&user_key_columns(left_columns, left_col_indices), targets);
218		let rows = converter
219			.convert_columns(&arrays)
220			.map_err(|e| internal_error!("Failed to build join keys: {}", e))?;
221
222		let mut left_picks: Vec<usize> = Vec::new();
223		let mut right_picks: Vec<Option<usize>> = Vec::new();
224
225		for i in 0..*left_rows {
226			let mut matched = false;
227
228			let candidates = if arrays.iter().any(|array| array.is_null(i)) {
229				None
230			} else {
231				hash_table.get(rows.row(i).as_ref())
232			};
233
234			if let Some(indices) = candidates {
235				for &j in indices {
236					left_picks.push(i);
237					right_picks.push(Some(j));
238					matched = true;
239				}
240			}
241
242			if !matched && matches!(self.join_type, JoinType::Left) {
243				left_picks.push(i);
244				right_picks.push(None);
245			}
246		}
247
248		Ok((left_picks, right_picks))
249	}
250}