Skip to main content

akar_processor/physical/
join_ops.rs

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