Skip to main content

akar_processor/physical/
join_ops.rs

1//! Auto-extracted from physical_operator.rs
2use crate::physical::common::{hash_value_into, store_value_in_vector, value_hash};
3use crate::physical::types::{HashJoinTable, OperatorResult};
4use akar_common::types::{PhysicalTypeID, Value};
5use akar_common::vector::{DataChunk, ValueVector};
6use std::collections::HashMap;
7use std::hash::{Hash, Hasher};
8
9/// Hash a single DataChunk cell directly from its Arrow array, avoiding
10/// intermediate `Value` creation (especially the string `to_string()` alloc).
11/// Returns `None` for null values so callers can skip them like the old code
12/// skipped `Value::Null`.
13#[inline]
14fn hash_chunk_cell(chunk: &DataChunk, col: usize, row: usize) -> Option<u64> {
15    if col >= chunk.fields.len() || chunk.is_null(col, row) {
16        return None;
17    }
18    let mut hasher = ahash::AHasher::default();
19    match chunk.field_types[col] {
20        PhysicalTypeID::Int64 => {
21            let v = chunk.get_i64(col, row).unwrap_or(0);
22            v.hash(&mut hasher);
23        }
24        PhysicalTypeID::Int32 => {
25            let v = chunk.get_i32(col, row).unwrap_or(0);
26            v.hash(&mut hasher);
27        }
28        PhysicalTypeID::Double => {
29            let v = chunk.get_f64(col, row).unwrap_or(0.0);
30            v.to_bits().hash(&mut hasher);
31        }
32        PhysicalTypeID::Bool => {
33            let v = chunk.get_bool(col, row).unwrap_or(false);
34            v.hash(&mut hasher);
35        }
36        PhysicalTypeID::String => {
37            if let Some(s) = chunk.get_string(col, row) {
38                s.hash(&mut hasher);
39            }
40        }
41        _ => {
42            if let Some(val) = chunk.get_value(col, row) {
43                hash_value_into(&val, &mut hasher);
44            }
45        }
46    }
47    Some(hasher.finish())
48}
49
50/// Compare two DataChunk cells for join key equality without building Values.
51#[inline]
52fn chunk_cells_equal(
53    left: &DataChunk,
54    left_col: usize,
55    left_row: usize,
56    right: &DataChunk,
57    right_col: usize,
58    right_row: usize,
59) -> bool {
60    if left.is_null(left_col, left_row) || right.is_null(right_col, right_row) {
61        return left.is_null(left_col, left_row) && right.is_null(right_col, right_row);
62    }
63    match (left.field_types[left_col], right.field_types[right_col]) {
64        (PhysicalTypeID::Int64, PhysicalTypeID::Int64) => {
65            left.get_i64(left_col, left_row) == right.get_i64(right_col, right_row)
66        }
67        (PhysicalTypeID::Int32, PhysicalTypeID::Int32) => {
68            left.get_i32(left_col, left_row) == right.get_i32(right_col, right_row)
69        }
70        (PhysicalTypeID::Int64, PhysicalTypeID::Int32) | (PhysicalTypeID::Int32, PhysicalTypeID::Int64) => {
71            let a = left
72                .get_i64(left_col, left_row)
73                .or_else(|| left.get_i32(left_col, left_row).map(|v| v as i64));
74            let b = right
75                .get_i64(right_col, right_row)
76                .or_else(|| right.get_i32(right_col, right_row).map(|v| v as i64));
77            a == b
78        }
79        (PhysicalTypeID::Double, PhysicalTypeID::Double) => {
80            left.get_f64(left_col, left_row) == right.get_f64(right_col, right_row)
81        }
82        (PhysicalTypeID::Bool, PhysicalTypeID::Bool) => {
83            left.get_bool(left_col, left_row) == right.get_bool(right_col, right_row)
84        }
85        (PhysicalTypeID::String, PhysicalTypeID::String) => {
86            left.get_string(left_col, left_row) == right.get_string(right_col, right_row)
87        }
88        _ => left.get_value(left_col, left_row) == right.get_value(right_col, right_row),
89    }
90}
91// ==================== CrossProduct ====================
92
93/// Physical cross product (Cartesian product) operator.
94///
95/// Combines every row from the left side with every row from the right side.
96/// The left side is the first half of input chunks, the right side is the
97/// second half.
98pub struct PhysicalCrossProduct;
99
100impl PhysicalCrossProduct {
101    pub fn execute_binary(&self, left_chunks: &[DataChunk], right_chunks: &[DataChunk]) -> OperatorResult {
102        if left_chunks.is_empty() || right_chunks.is_empty() {
103            return Ok(vec![]);
104        }
105
106        // Count total rows on each side
107        let left_rows: usize = left_chunks.iter().map(|c| c.size).sum();
108        let right_rows: usize = right_chunks.iter().map(|c| c.size).sum();
109
110        if left_rows == 0 || right_rows == 0 {
111            return Ok(vec![]);
112        }
113
114        // Collect left and right values into column-major Vec<Vec<Value>>
115        let num_left_cols = left_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
116        let num_right_cols = right_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
117        let total_cols = num_left_cols + num_right_cols;
118        let total_rows = left_rows * right_rows;
119
120        let mut left_values: Vec<Vec<Value>> = (0..num_left_cols).map(|_| Vec::with_capacity(left_rows)).collect();
121        for chunk in left_chunks {
122            for col in 0..num_left_cols {
123                if chunk.fields.get(col).is_some() {
124                    for row in 0..chunk.size {
125                        left_values[col].push(chunk.get_value(col, row).unwrap_or(Value::Null));
126                    }
127                }
128            }
129        }
130
131        let mut right_values: Vec<Vec<Value>> = (0..num_right_cols).map(|_| Vec::with_capacity(right_rows)).collect();
132        for chunk in right_chunks {
133            for col in 0..num_right_cols {
134                if chunk.fields.get(col).is_some() {
135                    for row in 0..chunk.size {
136                        right_values[col].push(chunk.get_value(col, row).unwrap_or(Value::Null));
137                    }
138                }
139            }
140        }
141
142        // Build physical types and names for output
143        let mut output_types: Vec<PhysicalTypeID> = Vec::with_capacity(total_cols);
144        let mut field_names = Vec::with_capacity(total_cols);
145        for col in 0..num_left_cols {
146            if left_chunks[0].fields.get(col).is_some() {
147                output_types.push(left_chunks[0].field_types[col]);
148            }
149        }
150        for col in 0..num_right_cols {
151            if right_chunks[0].fields.get(col).is_some() {
152                output_types.push(right_chunks[0].field_types[col]);
153            }
154        }
155
156        if let Some(c) = left_chunks.first() {
157            field_names.extend(c.field_names.iter().cloned());
158        }
159        if let Some(c) = right_chunks.first() {
160            field_names.extend(c.field_names.iter().cloned());
161        }
162
163        // Build output vectors
164        let mut output_fields: Vec<ValueVector> = output_types
165            .iter()
166            .map(|t| ValueVector::new(*t, total_rows.max(1)))
167            .collect();
168
169        let mut out_row = 0usize;
170        for lr in 0..left_rows {
171            for rr in 0..right_rows {
172                for (col, field) in output_fields.iter_mut().enumerate().take(num_left_cols) {
173                    let val = &left_values[col][lr];
174                    let _ = field.set_value(out_row, val);
175                }
176                for col in 0..num_right_cols {
177                    let val = &right_values[col][rr];
178                    let _ = output_fields[num_left_cols + col].set_value(out_row, val);
179                }
180                out_row += 1;
181            }
182        }
183
184        for field in &mut output_fields {
185            field.resize(total_rows);
186        }
187
188        // Propagate field names from left ++ right sides
189        let mut output_names: Vec<String> = left_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default();
190        output_names.extend(right_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default());
191        let arrow_fields = output_fields
192            .iter()
193            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
194            .collect::<Vec<_>>();
195        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
196        Ok(vec![DataChunk {
197            fields: arrow_fields,
198            field_types: arrow_field_types,
199            size: total_rows,
200            field_names: output_names,
201            sel_vector: None,
202        }])
203    }
204}
205
206// ==================== SemiJoin ====================
207
208/// Physical semi-join: Returns left rows that have a matching join key in the right side.
209/// Only left-side columns are emitted (no right columns in output).
210pub struct PhysicalSemiJoin {
211    pub build_columns: Vec<u32>,
212    pub probe_columns: Vec<u32>,
213}
214
215impl PhysicalSemiJoin {
216    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
217        if build_chunks.is_empty() || probe_chunks.is_empty() {
218            return Ok(vec![]);
219        }
220
221        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
222        let probe_col = self.probe_columns.first().copied().unwrap_or(0) as usize;
223
224        // Build hash set of right-side keys
225        let mut hash_set: std::collections::HashSet<u64> = std::collections::HashSet::new();
226        for chunk in build_chunks {
227            for row in 0..chunk.size {
228                if chunk.fields.get(build_col).is_some() {
229                    let key = chunk.get_value(build_col, row).unwrap_or(Value::Null);
230                    if matches!(key, Value::Null) {
231                        continue;
232                    }
233                    hash_set.insert(value_hash(&key));
234                }
235            }
236        }
237
238        // Probe: emit left rows whose key is in hash_set
239        let num_probe_fields = probe_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
240        let mut probe_types: Vec<PhysicalTypeID> = Vec::with_capacity(num_probe_fields);
241        if let Some(first) = probe_chunks.first() {
242            for col in 0..first.num_fields() {
243                probe_types.push(first.field_types[col]);
244            }
245        }
246
247        let total_probe_rows: usize = probe_chunks.iter().map(|c| c.size).sum();
248        let mut match_rows: Vec<(usize, usize)> = Vec::with_capacity(total_probe_rows);
249        for (ci, chunk) in probe_chunks.iter().enumerate() {
250            for row in 0..chunk.size {
251                if chunk.fields.get(probe_col).is_some() {
252                    let key = chunk.get_value(probe_col, row).unwrap_or(Value::Null);
253                    if matches!(key, Value::Null) {
254                        continue;
255                    }
256                    if hash_set.contains(&value_hash(&key)) {
257                        match_rows.push((ci, row));
258                    }
259                }
260            }
261        }
262
263        if match_rows.is_empty() {
264            return Ok(vec![]);
265        }
266
267        // Build output with only left-side columns
268        let num_left_cols = probe_types.len();
269        let mut output_fields: Vec<ValueVector> = probe_types
270            .iter()
271            .map(|t| ValueVector::new(*t, match_rows.len().max(1)))
272            .collect();
273
274        for (out_idx, (ci, row)) in match_rows.iter().enumerate() {
275            if let Some(chunk) = probe_chunks.get(*ci) {
276                for (col, out_field) in output_fields.iter_mut().enumerate().take(num_left_cols) {
277                    if chunk.fields.get(col).is_some() {
278                        let val = chunk.get_value(col, *row).unwrap_or(Value::Null);
279                        let _ = out_field.set_value(out_idx, &val);
280                    }
281                }
282            }
283        }
284        for field in &mut output_fields {
285            field.resize(match_rows.len());
286        }
287        let arrow_fields = output_fields
288            .iter()
289            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
290            .collect::<Vec<_>>();
291        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
292        Ok(vec![DataChunk {
293            fields: arrow_fields,
294            field_types: arrow_field_types,
295            size: match_rows.len(),
296            field_names: vec![],
297            sel_vector: None,
298        }])
299    }
300}
301
302// ==================== AntiJoin ====================
303
304/// Physical anti-join: Returns left rows that have NO matching join key in the right side.
305/// Only left-side columns are emitted.
306pub struct PhysicalAntiJoin {
307    pub build_columns: Vec<u32>,
308    pub probe_columns: Vec<u32>,
309}
310
311impl PhysicalAntiJoin {
312    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
313        if probe_chunks.is_empty() {
314            return Ok(vec![]);
315        }
316        if build_chunks.is_empty() {
317            // If build is empty, AntiJoin returns all of probe
318            return Ok(probe_chunks.to_vec());
319        }
320
321        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
322        let probe_col = self.probe_columns.first().copied().unwrap_or(0) as usize;
323
324        // Build hash set of right-side keys
325        let mut hash_set: std::collections::HashSet<u64> = std::collections::HashSet::new();
326        for chunk in build_chunks {
327            for row in 0..chunk.size {
328                if chunk.fields.get(build_col).is_some() {
329                    let key = chunk.get_value(build_col, row).unwrap_or(Value::Null);
330                    if matches!(key, Value::Null) {
331                        continue;
332                    }
333                    hash_set.insert(value_hash(&key));
334                }
335            }
336        }
337
338        let num_probe_fields = probe_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
339        let mut probe_types: Vec<PhysicalTypeID> = Vec::with_capacity(num_probe_fields);
340        if let Some(first) = probe_chunks.first() {
341            for col in 0..first.num_fields() {
342                probe_types.push(first.field_types[col]);
343            }
344        }
345
346        let total_probe_rows: usize = probe_chunks.iter().map(|c| c.size).sum();
347        let mut non_match_rows: Vec<(usize, usize)> = Vec::with_capacity(total_probe_rows);
348        for (ci, chunk) in probe_chunks.iter().enumerate() {
349            for row in 0..chunk.size {
350                if let Some(_field) = chunk.fields.get(probe_col) {
351                    let key = chunk.get_value(probe_col, row).unwrap_or(Value::Null);
352                    if matches!(key, Value::Null) {
353                        continue;
354                    }
355                    if !hash_set.contains(&value_hash(&key)) {
356                        non_match_rows.push((ci, row));
357                    }
358                }
359            }
360        }
361
362        if non_match_rows.is_empty() {
363            return Ok(vec![]);
364        }
365
366        let num_left_cols = probe_types.len();
367        let mut output_fields: Vec<ValueVector> = probe_types
368            .iter()
369            .map(|t| ValueVector::new(*t, non_match_rows.len().max(1)))
370            .collect();
371
372        for (out_idx, (ci, row)) in non_match_rows.iter().enumerate() {
373            if let Some(chunk) = probe_chunks.get(*ci) {
374                for (col, out_field) in output_fields.iter_mut().enumerate().take(num_left_cols) {
375                    if let Some(_field) = chunk.fields.get(col) {
376                        let val = chunk.get_value(col, *row).unwrap_or(Value::Null);
377                        let _ = out_field.set_value(out_idx, &val);
378                    }
379                }
380            }
381        }
382        for field in &mut output_fields {
383            field.resize(non_match_rows.len());
384        }
385        let arrow_fields = output_fields
386            .iter()
387            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
388            .collect::<Vec<_>>();
389        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
390        Ok(vec![DataChunk {
391            fields: arrow_fields,
392            field_types: arrow_field_types,
393            size: non_match_rows.len(),
394            field_names: vec![],
395            sel_vector: None,
396        }])
397    }
398}
399
400// ==================== Intersect ====================
401
402/// Physical intersect operator.
403///
404/// For multi-pattern matching like `MATCH (a)-[:r1]->(b), (a)-[:r2]->(c)`:
405/// - Multiple build sides each produce a hash table keyed by the shared variable `a`
406/// - The probe side produces candidate values for `a`
407/// - For each probe key, all build hash tables are probed
408/// - The matching node ID lists are pairwise intersected (two-way sorted merge)
409/// - Only keys that appear in ALL build sides produce output
410///
411/// Implementation: a simplified version of the C++ `Intersect` (intersect.h).
412/// Builds hash tables from build chunks, probes with probe chunks, and does
413/// pairwise intersection using sorted node ID comparison.
414pub struct PhysicalIntersect {
415    /// Number of build hash tables (one per pattern).
416    pub num_build_sides: u32,
417    /// Column index of the key in the probe side.
418    pub probe_key_col: u32,
419    /// Column index of the key in each build side.
420    pub build_key_col: u32,
421}
422
423impl PhysicalIntersect {
424    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
425        let num_builds = self.num_build_sides.max(1) as usize;
426        if build_chunks.is_empty() || probe_chunks.is_empty() {
427            return Ok(vec![]);
428        }
429
430        // Partition the flat build chunk list into per-side groups. Each build
431        // side in the plan produces the same number of chunks, so we split evenly.
432        let chunk_group_size = (build_chunks.len() / num_builds).max(1);
433        let mut sides: Vec<Vec<DataChunk>> = Vec::with_capacity(num_builds);
434        for side in 0..num_builds {
435            let start = side * chunk_group_size;
436            let end = (start + chunk_group_size).min(build_chunks.len());
437            sides.push(build_chunks[start..end].to_vec());
438        }
439
440        self.execute_sides(&sides, probe_chunks)
441    }
442
443    /// Execute the intersect against independently-produced build sides.
444    ///
445    /// Each build side is hashed into its own table keyed on the shared node ID.
446    /// A probe row passes only when its key is present in EVERY build table, and
447    /// the output is the full cross product of the matching build rows (so a
448    /// shared node with `k` neighbors per side produces `k1 * k2 * ...` rows).
449    ///
450    /// Output column layout: `[probe columns] + [build side 1 columns] + ...`.
451    pub fn execute_sides(&self, build_sides: &[Vec<DataChunk>], probe_chunks: &[DataChunk]) -> OperatorResult {
452        let num_builds = build_sides.len().max(1);
453        if probe_chunks.is_empty() {
454            return Ok(vec![]);
455        }
456
457        let build_col = self.build_key_col as usize;
458        let probe_col = self.probe_key_col as usize;
459
460        // Build one hash table per side: key_hash → (key_value, Vec<(ci, row)>)
461        let mut build_tables: Vec<HashJoinTable> = Vec::with_capacity(num_builds);
462        let mut side_field_names: Vec<Vec<String>> = Vec::with_capacity(num_builds);
463        let mut side_field_counts: Vec<usize> = Vec::with_capacity(num_builds);
464
465        for side in build_sides {
466            let mut ht: HashJoinTable = HashMap::new();
467            let mut names: Vec<String> = Vec::new();
468            let mut count = 0usize;
469
470            for (ci, chunk) in side.iter().enumerate() {
471                if ci == 0 {
472                    names = chunk.field_names.clone();
473                    count = chunk.fields.len();
474                }
475                for row in 0..chunk.size {
476                    if chunk.fields.get(build_col).is_none() {
477                        continue;
478                    }
479                    let key = chunk.get_value(build_col, row).unwrap_or(Value::Null);
480                    if matches!(key, Value::Null) {
481                        continue;
482                    }
483                    let hash = value_hash(&key);
484                    ht.entry(hash).or_default().push((key, vec![(ci, row)]));
485                }
486            }
487
488            side_field_names.push(names);
489            side_field_counts.push(count);
490            build_tables.push(ht);
491        }
492
493        if build_tables.iter().any(|t| t.is_empty()) {
494            // A build side without data → no key can be in all sides → empty result
495            return Ok(vec![]);
496        }
497
498        let probe_field_names = probe_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default();
499        let probe_field_count = probe_chunks.first().map(|c| c.fields.len()).unwrap_or(0);
500        let mut output_rows: Vec<Vec<Value>> = Vec::new();
501
502        for (ci, chunk) in probe_chunks.iter().enumerate() {
503            let _ = ci;
504            for row in 0..chunk.size {
505                let probe_key = chunk.get_value(probe_col, row).unwrap_or(Value::Null);
506                if matches!(probe_key, Value::Null) {
507                    continue;
508                }
509                let probe_hash = value_hash(&probe_key);
510
511                // Collect matching (chunk_idx, row_idx) per build side.
512                let mut matches_per_side: Vec<Vec<(usize, usize)>> = Vec::with_capacity(num_builds);
513                let mut all_match = true;
514                for ht in &build_tables {
515                    let mut side_matches: Vec<(usize, usize)> = Vec::new();
516                    if let Some(bucket) = ht.get(&probe_hash) {
517                        for (stored_key, locations) in bucket {
518                            if stored_key == &probe_key {
519                                side_matches.extend(locations.iter().cloned());
520                            }
521                        }
522                    }
523                    if side_matches.is_empty() {
524                        all_match = false;
525                        break;
526                    }
527                    matches_per_side.push(side_matches);
528                }
529                if !all_match {
530                    continue;
531                }
532
533                // Cross product across build sides.
534                let mut combos: Vec<Vec<(usize, usize)>> = vec![vec![]];
535                for side_matches in &matches_per_side {
536                    let mut next = Vec::with_capacity(combos.len() * side_matches.len());
537                    for combo in &combos {
538                        for m in side_matches {
539                            let mut c = combo.clone();
540                            c.push(*m);
541                            next.push(c);
542                        }
543                    }
544                    combos = next;
545                }
546
547                let per_row_cols = probe_field_count + side_field_counts.iter().sum::<usize>();
548                for combo in combos {
549                    let mut row_values: Vec<Value> = Vec::with_capacity(per_row_cols);
550                    // Probe side values — all columns of the probe row.
551                    for col_in_probe in 0..probe_field_count {
552                        row_values.push(chunk.get_value(col_in_probe, row).unwrap_or(Value::Null));
553                    }
554                    // One build side's payload per combo entry.
555                    for (side_idx, &(b_ci, b_row)) in combo.iter().enumerate() {
556                        if let Some(side_chunk) = build_sides.get(side_idx).and_then(|s| s.get(b_ci)) {
557                            for col in 0..side_chunk.fields.len() {
558                                row_values.push(side_chunk.get_value(col, b_row).unwrap_or(Value::Null));
559                            }
560                        }
561                    }
562                    output_rows.push(row_values);
563                }
564            }
565        }
566
567        if output_rows.is_empty() {
568            return Ok(vec![]);
569        }
570
571        // Build output DataChunk (one row per field group)
572        let output_size = output_rows.len();
573        let mut output_fields: Vec<ValueVector> = Vec::with_capacity(output_rows.first().map(|r| r.len()).unwrap_or(0));
574
575        if let Some(first_row) = output_rows.first() {
576            for val in first_row {
577                let ptype = val.physical_type();
578                let mut vv = ValueVector::new(ptype, output_size);
579                vv.resize(output_size);
580                output_fields.push(vv);
581            }
582        }
583
584        for (out_idx, row_values) in output_rows.iter().enumerate() {
585            for (col, val) in row_values.iter().enumerate() {
586                if let Some(field) = output_fields.get_mut(col) {
587                    let _ = field.set_value(out_idx, val);
588                }
589            }
590        }
591
592        let arrow_fields = output_fields
593            .iter()
594            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
595            .collect::<Vec<_>>();
596        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
597
598        let mut field_names: Vec<String> = probe_field_names;
599        for names in &side_field_names {
600            field_names.extend(names.iter().cloned());
601        }
602
603        Ok(vec![DataChunk {
604            fields: arrow_fields,
605            field_types: arrow_field_types,
606            field_names,
607            size: output_size,
608            sel_vector: None,
609        }])
610    }
611}
612
613// ==================== JoinHashTable ====================
614
615/// A hash table for hash join operations with parallel build support.
616///
617/// Optimized with:
618/// - `ahash` for fast integer hashing (3-5× faster than SipHash)
619/// - Flat bucket design: `HashMap<u64, Vec<(usize, usize)>>` — no Value cloning in buckets
620/// - Pre-sized hash map based on total build rows (zero reallocations)
621/// - Bulk output construction instead of per-row set_value
622pub struct JoinHashTable {
623    build_columns: Vec<u32>,
624    probe_columns: Vec<u32>,
625}
626
627impl JoinHashTable {
628    pub fn new(build_columns: Vec<u32>, probe_columns: Vec<u32>) -> Self {
629        Self {
630            build_columns,
631            probe_columns,
632        }
633    }
634
635    /// Build phase: create a flat hash table mapping key hashes to (chunk_idx, row_idx) pairs.
636    /// The hash table is pre-sized and uses ahash for fast integer hashing.
637    pub fn build(&self, build_chunks: &[DataChunk]) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
638        let total_rows: usize = build_chunks.iter().map(|c| c.size).sum();
639        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
640
641        if total_rows > 1000 {
642            self.build_parallel(build_chunks, build_col, total_rows)
643        } else {
644            self.build_sequential(build_chunks, build_col, total_rows)
645        }
646    }
647
648    fn build_sequential(
649        &self,
650        build_chunks: &[DataChunk],
651        build_col: usize,
652        total_rows: usize,
653    ) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
654        // Pre-size to ~75% load factor to avoid rehashing
655        let mut table: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
656            hashbrown::HashMap::with_capacity(total_rows * 4 / 3);
657
658        for (ci, chunk) in build_chunks.iter().enumerate() {
659            for row in 0..chunk.size {
660                let Some(hash) = hash_chunk_cell(chunk, build_col, row) else {
661                    continue;
662                };
663                table
664                    .entry(hash)
665                    .or_insert_with(|| Vec::with_capacity(4))
666                    .push((ci, row));
667            }
668        }
669        table
670    }
671
672    fn build_parallel(
673        &self,
674        build_chunks: &[DataChunk],
675        build_col: usize,
676        total_rows: usize,
677    ) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
678        use rayon::prelude::*;
679
680        let tables: Vec<hashbrown::HashMap<u64, Vec<(usize, usize)>>> = build_chunks
681            .par_iter()
682            .enumerate()
683            .map(|(ci, chunk)| {
684                let mut local: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
685                    hashbrown::HashMap::with_capacity(chunk.size * 4 / 3);
686                for row in 0..chunk.size {
687                    let Some(hash) = hash_chunk_cell(chunk, build_col, row) else {
688                        continue;
689                    };
690                    local
691                        .entry(hash)
692                        .or_insert_with(|| Vec::with_capacity(4))
693                        .push((ci, row));
694                }
695                local
696            })
697            .collect();
698
699        // Merge: pre-size the final table
700        let mut merged: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
701            hashbrown::HashMap::with_capacity(total_rows * 4 / 3);
702        for local in tables {
703            for (hash, locations) in local {
704                merged
705                    .entry(hash)
706                    .or_insert_with(|| Vec::with_capacity(locations.len()))
707                    .extend(locations);
708            }
709        }
710        merged
711    }
712
713    /// Probe phase: for each probe row, look up matching build rows by key hash,
714    /// then verify key equality. Outputs combined build+probe columns.
715    pub fn probe(
716        &self,
717        hash_table: &hashbrown::HashMap<u64, Vec<(usize, usize)>>,
718        build_chunks: &[DataChunk],
719        probe_chunks: &[DataChunk],
720    ) -> OperatorResult {
721        let probe_col = self.probe_columns.first().copied().unwrap_or(0) as usize;
722        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
723
724        // Determine output schema from build + probe
725        let num_build_fields = build_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
726        let num_probe_fields = probe_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
727        let total_cols = num_build_fields + num_probe_fields;
728
729        if total_cols == 0 {
730            return Ok(Vec::new());
731        }
732
733        // Collect output types
734        let mut output_types: Vec<PhysicalTypeID> = Vec::with_capacity(total_cols);
735        if let Some(bc) = build_chunks.first() {
736            for col in 0..bc.num_fields() {
737                output_types.push(bc.field_types[col]);
738            }
739        }
740        if let Some(pc) = probe_chunks.first() {
741            for col in 0..pc.num_fields() {
742                output_types.push(pc.field_types[col]);
743            }
744        }
745
746        let total_probe_rows: usize = probe_chunks.iter().map(|c| c.size).sum();
747        let mut matches: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(total_probe_rows);
748
749        for (pci, chunk) in probe_chunks.iter().enumerate() {
750            for row in 0..chunk.size {
751                let Some(probe_hash) = hash_chunk_cell(chunk, probe_col, row) else {
752                    continue;
753                };
754
755                if let Some(locations) = hash_table.get(&probe_hash) {
756                    // Verify key equality for each candidate
757                    for &(bci, brow) in locations {
758                        if chunk_cells_equal(&build_chunks[bci], build_col, brow, chunk, probe_col, row) {
759                            matches.push((bci, brow, pci, row));
760                        }
761                    }
762                }
763            }
764        }
765
766        if matches.is_empty() {
767            return Ok(Vec::new());
768        }
769
770        // Second pass: build output DataChunk from collected matches
771        let num_rows = matches.len();
772        let mut result_fields: Vec<ValueVector> = output_types
773            .iter()
774            .map(|t| {
775                let mut v = ValueVector::new(*t, num_rows);
776                v.resize(num_rows);
777                v
778            })
779            .collect();
780
781        for (out_row, &(bci, brow, pci, prow)) in matches.iter().enumerate() {
782            // Copy build-side columns
783            for col in 0..num_build_fields {
784                if let Some(_field) = build_chunks[bci].fields.get(col) {
785                    let val = build_chunks[bci].get_value(col, brow).unwrap_or(Value::Null);
786                    if matches!(val, Value::Null) {
787                        result_fields[col].set_null(out_row, true);
788                    } else {
789                        store_value_in_vector(&mut result_fields[col], out_row, &val)?;
790                    }
791                }
792            }
793            // Copy probe-side columns
794            for col in 0..num_probe_fields {
795                if let Some(_field) = probe_chunks[pci].fields.get(col) {
796                    let val = probe_chunks[pci].get_value(col, prow).unwrap_or(Value::Null);
797                    if matches!(val, Value::Null) {
798                        result_fields[num_build_fields + col].set_null(out_row, true);
799                    } else {
800                        store_value_in_vector(&mut result_fields[num_build_fields + col], out_row, &val)?;
801                    }
802                }
803            }
804        }
805
806        let arrow_fields = result_fields
807            .iter()
808            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
809            .collect::<Vec<_>>();
810        let arrow_field_types = result_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
811        Ok(vec![DataChunk {
812            fields: arrow_fields,
813            field_types: arrow_field_types,
814            size: num_rows,
815            field_names: vec![],
816            sel_vector: None,
817        }])
818    }
819}
820
821// ==================== HashJoin ====================
822
823pub struct PhysicalHashJoin {
824    pub build_columns: Vec<u32>,
825    pub probe_columns: Vec<u32>,
826}
827
828impl PhysicalHashJoin {
829    pub fn new(build_columns: Vec<u32>, probe_columns: Vec<u32>) -> Self {
830        Self {
831            build_columns,
832            probe_columns,
833        }
834    }
835}
836
837impl PhysicalHashJoin {
838    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
839        if build_chunks.is_empty() || probe_chunks.is_empty() {
840            return Ok(vec![]);
841        }
842
843        // Use JoinHashTable for parallel build
844        let join_table = JoinHashTable::new(self.build_columns.clone(), self.probe_columns.clone());
845        let hash_table = join_table.build(build_chunks);
846        let mut result = join_table.probe(&hash_table, build_chunks, probe_chunks)?;
847
848        // Propagate field names
849        if !result.is_empty() {
850            let mut output_names: Vec<String> = build_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default();
851            output_names.extend(probe_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default());
852            result[0].field_names = output_names;
853        }
854
855        Ok(result)
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862
863    fn make_i64_chunk(values: &[i64]) -> DataChunk {
864        let mut v = ValueVector::new(PhysicalTypeID::Int64, values.len().max(1));
865        for (i, val) in values.iter().enumerate() {
866            v.set_i64(i, *val);
867        }
868        v.resize(values.len());
869        let ptype = v.physical_type();
870        let fields = vec![akar_common::arrow_vector::ArrowVector::from_legacy(&v).array];
871        DataChunk::new(fields, vec![ptype])
872    }
873
874    #[test]
875    fn test_intersect_execute_sides_cross_product() {
876        let intersect = PhysicalIntersect {
877            num_build_sides: 2,
878            probe_key_col: 0,
879            build_key_col: 0,
880        };
881        let build1 = make_i64_chunk(&[1, 1, 5]);
882        let build2 = make_i64_chunk(&[1, 1, 1, 7]);
883        let probe = make_i64_chunk(&[1, 2]);
884        let sides = vec![vec![build1], vec![build2]];
885        let result = intersect.execute_sides(&sides, &[probe]).unwrap();
886        assert!(!result.is_empty(), "expected non-empty result");
887        assert_eq!(result[0].size, 6, "expected 2x3 cross product for probe key 1");
888        assert_eq!(result[0].fields.len(), 3, "probe + 2 build columns");
889    }
890
891    #[test]
892    fn test_intersect_execute_sides_key_resolution() {
893        let mut probe_v = ValueVector::new(PhysicalTypeID::Int64, 2);
894        probe_v.set_i64(0, 10);
895        probe_v.set_i64(1, 20);
896        let mut probe_id = ValueVector::new(PhysicalTypeID::Int64, 2);
897        probe_id.set_i64(0, 1);
898        probe_id.set_i64(1, 2);
899        let ptype = probe_v.physical_type();
900        let probe_fields = vec![
901            akar_common::arrow_vector::ArrowVector::from_legacy(&probe_v).array,
902            akar_common::arrow_vector::ArrowVector::from_legacy(&probe_id).array,
903        ];
904        let mut probe = DataChunk::new(probe_fields, vec![ptype, ptype]);
905        probe.field_names = vec!["a.other".into(), "a.id".into()];
906
907        let mut build_v = ValueVector::new(PhysicalTypeID::Int64, 2);
908        build_v.set_i64(0, 30);
909        build_v.set_i64(1, 40);
910        let mut build_id = ValueVector::new(PhysicalTypeID::Int64, 2);
911        build_id.set_i64(0, 1);
912        build_id.set_i64(1, 1);
913        let build_fields = vec![
914            akar_common::arrow_vector::ArrowVector::from_legacy(&build_v).array,
915            akar_common::arrow_vector::ArrowVector::from_legacy(&build_id).array,
916        ];
917        let mut build = DataChunk::new(build_fields, vec![ptype, ptype]);
918        build.field_names = vec!["a.other".into(), "a.id".into()];
919
920        let intersect = PhysicalIntersect {
921            num_build_sides: 1,
922            probe_key_col: 1,
923            build_key_col: 1,
924        };
925        let result = intersect.execute_sides(&[vec![build]], &[probe]).unwrap();
926        assert!(!result.is_empty(), "expected non-empty result");
927        assert_eq!(
928            result[0].size, 2,
929            "probe id 1 matches both build rows; id 2 matches nothing"
930        );
931        assert_eq!(
932            result[0].field_names,
933            vec![
934                "a.other".to_string(),
935                "a.id".to_string(),
936                "a.other".to_string(),
937                "a.id".to_string()
938            ]
939        );
940    }
941}