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
445        // evenly; the leftover chunks (len % num_builds) are distributed one
446        // each to the first sides so no chunk is dropped and no slice out of
447        // range occurs when len < num_builds.
448        let base = build_chunks.len() / num_builds;
449        let extra = build_chunks.len() % num_builds;
450        let mut sides: Vec<Vec<DataChunk>> = Vec::with_capacity(num_builds);
451        let mut start = 0usize;
452        for side in 0..num_builds {
453            let size = base + usize::from(side < extra);
454            let end = start + size;
455            sides.push(build_chunks[start..end].to_vec());
456            start = end;
457        }
458
459        self.execute_sides(&sides, probe_chunks)
460    }
461
462    /// Execute the intersect against independently-produced build sides.
463    ///
464    /// Each build side is hashed into its own table keyed on the shared node ID.
465    /// A probe row passes only when its key is present in EVERY build table, and
466    /// the output is the full cross product of the matching build rows (so a
467    /// shared node with `k` neighbors per side produces `k1 * k2 * ...` rows).
468    ///
469    /// Output column layout: `[probe columns] + [build side 1 columns] + ...`.
470    pub fn execute_sides(&self, build_sides: &[Vec<DataChunk>], probe_chunks: &[DataChunk]) -> OperatorResult {
471        let num_builds = build_sides.len().max(1);
472        if probe_chunks.is_empty() {
473            return Ok(vec![]);
474        }
475
476        let build_col = self.build_key_col as usize;
477        let probe_col = self.probe_key_col as usize;
478
479        // Build one hash table per side: key_hash → (key_value, Vec<(ci, row)>)
480        let mut build_tables: Vec<HashJoinTable> = Vec::with_capacity(num_builds);
481        let mut side_field_names: Vec<Vec<String>> = Vec::with_capacity(num_builds);
482        let mut side_field_counts: Vec<usize> = Vec::with_capacity(num_builds);
483
484        for side in build_sides {
485            let mut ht: HashJoinTable = HashMap::new();
486            let mut names: Vec<String> = Vec::new();
487            let mut count = 0usize;
488
489            for (ci, chunk) in side.iter().enumerate() {
490                if ci == 0 {
491                    names = chunk.field_names.clone();
492                    count = chunk.fields.len();
493                }
494                for row in 0..chunk.size {
495                    if chunk.fields.get(build_col).is_none() {
496                        continue;
497                    }
498                    let key = chunk.get_value(build_col, row).unwrap_or(Value::Null);
499                    if matches!(key, Value::Null) {
500                        continue;
501                    }
502                    let hash = value_hash(&key);
503                    ht.entry(hash).or_default().push((key, vec![(ci, row)]));
504                }
505            }
506
507            side_field_names.push(names);
508            side_field_counts.push(count);
509            build_tables.push(ht);
510        }
511
512        if build_tables.iter().any(|t| t.is_empty()) {
513            // A build side without data → no key can be in all sides → empty result
514            return Ok(vec![]);
515        }
516
517        let probe_field_names = probe_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default();
518        let probe_field_count = probe_chunks.first().map(|c| c.fields.len()).unwrap_or(0);
519        let mut output_rows: Vec<Vec<Value>> = Vec::new();
520
521        for (ci, chunk) in probe_chunks.iter().enumerate() {
522            let _ = ci;
523            for row in 0..chunk.size {
524                let probe_key = chunk.get_value(probe_col, row).unwrap_or(Value::Null);
525                if matches!(probe_key, Value::Null) {
526                    continue;
527                }
528                let probe_hash = value_hash(&probe_key);
529
530                // Collect matching (chunk_idx, row_idx) per build side.
531                let mut matches_per_side: Vec<Vec<(usize, usize)>> = Vec::with_capacity(num_builds);
532                let mut all_match = true;
533                for ht in &build_tables {
534                    let mut side_matches: Vec<(usize, usize)> = Vec::new();
535                    if let Some(bucket) = ht.get(&probe_hash) {
536                        for (stored_key, locations) in bucket {
537                            if stored_key == &probe_key {
538                                side_matches.extend(locations.iter().cloned());
539                            }
540                        }
541                    }
542                    if side_matches.is_empty() {
543                        all_match = false;
544                        break;
545                    }
546                    matches_per_side.push(side_matches);
547                }
548                if !all_match {
549                    continue;
550                }
551
552                // Cross product across build sides.
553                let mut combos: Vec<Vec<(usize, usize)>> = vec![vec![]];
554                for side_matches in &matches_per_side {
555                    let mut next = Vec::with_capacity(combos.len() * side_matches.len());
556                    for combo in &combos {
557                        for m in side_matches {
558                            let mut c = combo.clone();
559                            c.push(*m);
560                            next.push(c);
561                        }
562                    }
563                    combos = next;
564                }
565
566                let per_row_cols = probe_field_count + side_field_counts.iter().sum::<usize>();
567                for combo in combos {
568                    let mut row_values: Vec<Value> = Vec::with_capacity(per_row_cols);
569                    // Probe side values — all columns of the probe row.
570                    for col_in_probe in 0..probe_field_count {
571                        row_values.push(chunk.get_value(col_in_probe, row).unwrap_or(Value::Null));
572                    }
573                    // One build side's payload per combo entry.
574                    for (side_idx, &(b_ci, b_row)) in combo.iter().enumerate() {
575                        if let Some(side_chunk) = build_sides.get(side_idx).and_then(|s| s.get(b_ci)) {
576                            for col in 0..side_chunk.fields.len() {
577                                row_values.push(side_chunk.get_value(col, b_row).unwrap_or(Value::Null));
578                            }
579                        }
580                    }
581                    output_rows.push(row_values);
582                }
583            }
584        }
585
586        if output_rows.is_empty() {
587            return Ok(vec![]);
588        }
589
590        // Build output DataChunk (one row per field group)
591        let output_size = output_rows.len();
592        let mut output_fields: Vec<ValueVector> = Vec::with_capacity(output_rows.first().map(|r| r.len()).unwrap_or(0));
593
594        if let Some(first_row) = output_rows.first() {
595            for val in first_row {
596                let ptype = val.physical_type();
597                let mut vv = ValueVector::new(ptype, output_size);
598                vv.resize(output_size);
599                output_fields.push(vv);
600            }
601        }
602
603        for (out_idx, row_values) in output_rows.iter().enumerate() {
604            for (col, val) in row_values.iter().enumerate() {
605                if let Some(field) = output_fields.get_mut(col) {
606                    let _ = field.set_value(out_idx, val);
607                }
608            }
609        }
610
611        let arrow_fields = output_fields
612            .iter()
613            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
614            .collect::<Vec<_>>();
615        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
616
617        let mut field_names: Vec<String> = probe_field_names;
618        for names in &side_field_names {
619            field_names.extend(names.iter().cloned());
620        }
621
622        Ok(vec![DataChunk {
623            fields: arrow_fields,
624            field_types: arrow_field_types,
625            field_names,
626            size: output_size,
627            sel_vector: None,
628        }])
629    }
630}
631
632// ==================== JoinHashTable ====================
633
634/// A hash table for hash join operations with parallel build support.
635///
636/// Optimized with:
637/// - `ahash` for fast integer hashing (3-5× faster than SipHash)
638/// - Flat bucket design: `HashMap<u64, Vec<(usize, usize)>>` — no Value cloning in buckets
639/// - Pre-sized hash map based on total build rows (zero reallocations)
640/// - Bulk output construction instead of per-row set_value
641pub struct JoinHashTable {
642    build_columns: Vec<u32>,
643    probe_columns: Vec<u32>,
644}
645
646impl JoinHashTable {
647    pub fn new(build_columns: Vec<u32>, probe_columns: Vec<u32>) -> Self {
648        Self {
649            build_columns,
650            probe_columns,
651        }
652    }
653
654    /// Build phase: create a flat hash table mapping key hashes to (chunk_idx, row_idx) pairs.
655    /// The hash table is pre-sized and uses ahash for fast integer hashing.
656    pub fn build(&self, build_chunks: &[DataChunk]) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
657        let total_rows: usize = build_chunks.iter().map(|c| c.size).sum();
658        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
659
660        if total_rows > 1000 {
661            self.build_parallel(build_chunks, build_col, total_rows)
662        } else {
663            self.build_sequential(build_chunks, build_col, total_rows)
664        }
665    }
666
667    fn build_sequential(
668        &self,
669        build_chunks: &[DataChunk],
670        build_col: usize,
671        total_rows: usize,
672    ) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
673        // Pre-size to ~75% load factor to avoid rehashing
674        let mut table: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
675            hashbrown::HashMap::with_capacity(total_rows * 4 / 3);
676
677        for (ci, chunk) in build_chunks.iter().enumerate() {
678            for row in 0..chunk.size {
679                let Some(hash) = hash_chunk_cell(chunk, build_col, row) else {
680                    continue;
681                };
682                table
683                    .entry(hash)
684                    .or_insert_with(|| Vec::with_capacity(4))
685                    .push((ci, row));
686            }
687        }
688        table
689    }
690
691    fn build_parallel(
692        &self,
693        build_chunks: &[DataChunk],
694        build_col: usize,
695        total_rows: usize,
696    ) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
697        use rayon::prelude::*;
698
699        let tables: Vec<hashbrown::HashMap<u64, Vec<(usize, usize)>>> = build_chunks
700            .par_iter()
701            .enumerate()
702            .map(|(ci, chunk)| {
703                let mut local: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
704                    hashbrown::HashMap::with_capacity(chunk.size * 4 / 3);
705                for row in 0..chunk.size {
706                    let Some(hash) = hash_chunk_cell(chunk, build_col, row) else {
707                        continue;
708                    };
709                    local
710                        .entry(hash)
711                        .or_insert_with(|| Vec::with_capacity(4))
712                        .push((ci, row));
713                }
714                local
715            })
716            .collect();
717
718        // Merge: pre-size the final table
719        let mut merged: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
720            hashbrown::HashMap::with_capacity(total_rows * 4 / 3);
721        for local in tables {
722            for (hash, locations) in local {
723                merged
724                    .entry(hash)
725                    .or_insert_with(|| Vec::with_capacity(locations.len()))
726                    .extend(locations);
727            }
728        }
729        merged
730    }
731
732    /// Probe phase: for each probe row, look up matching build rows by key hash,
733    /// then verify key equality. Outputs combined build+probe columns.
734    pub fn probe(
735        &self,
736        hash_table: &hashbrown::HashMap<u64, Vec<(usize, usize)>>,
737        build_chunks: &[DataChunk],
738        probe_chunks: &[DataChunk],
739    ) -> OperatorResult {
740        let probe_col = self.probe_columns.first().copied().unwrap_or(0) as usize;
741        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
742
743        // Determine output schema from build + probe
744        let num_build_fields = build_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
745        let num_probe_fields = probe_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
746        let total_cols = num_build_fields + num_probe_fields;
747
748        if total_cols == 0 {
749            return Ok(Vec::new());
750        }
751
752        // Collect output types
753        let mut output_types: Vec<PhysicalTypeID> = Vec::with_capacity(total_cols);
754        if let Some(bc) = build_chunks.first() {
755            for col in 0..bc.num_fields() {
756                output_types.push(bc.field_types[col]);
757            }
758        }
759        if let Some(pc) = probe_chunks.first() {
760            for col in 0..pc.num_fields() {
761                output_types.push(pc.field_types[col]);
762            }
763        }
764
765        let total_probe_rows: usize = probe_chunks.iter().map(|c| c.size).sum();
766        let mut matches: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(total_probe_rows);
767
768        for (pci, chunk) in probe_chunks.iter().enumerate() {
769            for row in 0..chunk.size {
770                let Some(probe_hash) = hash_chunk_cell(chunk, probe_col, row) else {
771                    continue;
772                };
773
774                if let Some(locations) = hash_table.get(&probe_hash) {
775                    // Verify key equality for each candidate
776                    for &(bci, brow) in locations {
777                        if chunk_cells_equal(&build_chunks[bci], build_col, brow, chunk, probe_col, row) {
778                            matches.push((bci, brow, pci, row));
779                        }
780                    }
781                }
782            }
783        }
784
785        if matches.is_empty() {
786            return Ok(Vec::new());
787        }
788
789        // Second pass: build output DataChunk from collected matches using Arrow
790        // `take` over per-column concatenations. Unlike the legacy per-cell
791        // Value round-trip, `take` preserves complex values (map/struct/list)
792        // that `store_value_in_vector` would drop to NULL (P53.26).
793        let num_rows = matches.len();
794
795        // Global row offsets so `(chunk_idx, row)` → index in the concatenation.
796        let mut build_offsets: Vec<usize> = Vec::with_capacity(build_chunks.len());
797        let mut probe_offsets: Vec<usize> = Vec::with_capacity(probe_chunks.len());
798        let mut acc = 0usize;
799        for c in build_chunks {
800            build_offsets.push(acc);
801            acc += c.size;
802        }
803        let mut acc = 0usize;
804        for c in probe_chunks {
805            probe_offsets.push(acc);
806            acc += c.size;
807        }
808        let build_take: arrow::array::UInt32Array = matches
809            .iter()
810            .map(|&(bci, brow, _, _)| (build_offsets[bci] + brow) as u32)
811            .collect();
812        let probe_take: arrow::array::UInt32Array = matches
813            .iter()
814            .map(|&(_, _, pci, prow)| (probe_offsets[pci] + prow) as u32)
815            .collect();
816
817        let mut result_fields: Vec<ArrayRef> = Vec::with_capacity(total_cols);
818        for col in 0..num_build_fields {
819            let parts: Vec<ArrayRef> = build_chunks.iter().map(|c| c.fields[col].clone()).collect();
820            let concat = concat_parts(parts)?;
821            result_fields.push(arrow::compute::take(concat.as_ref(), &build_take, None).map_err(|e| e.to_string())?);
822        }
823        for col in 0..num_probe_fields {
824            let parts: Vec<ArrayRef> = probe_chunks.iter().map(|c| c.fields[col].clone()).collect();
825            let concat = concat_parts(parts)?;
826            result_fields.push(arrow::compute::take(concat.as_ref(), &probe_take, None).map_err(|e| e.to_string())?);
827        }
828
829        Ok(vec![DataChunk {
830            fields: result_fields,
831            field_types: output_types,
832            size: num_rows,
833            field_names: vec![],
834            sel_vector: None,
835        }])
836    }
837}
838
839/// Concatenate the same column across chunks into one Arrow array.
840fn concat_parts(parts: Vec<ArrayRef>) -> Result<ArrayRef, String> {
841    if parts.len() == 1 {
842        Ok(parts.into_iter().next().unwrap())
843    } else {
844        let refs: Vec<&dyn arrow::array::Array> = parts.iter().map(|a| a.as_ref()).collect();
845        arrow::compute::concat(&refs).map_err(|e| e.to_string())
846    }
847}
848
849// ==================== HashJoin ====================
850
851pub struct PhysicalHashJoin {
852    pub build_columns: Vec<u32>,
853    pub probe_columns: Vec<u32>,
854}
855
856impl PhysicalHashJoin {
857    pub fn new(build_columns: Vec<u32>, probe_columns: Vec<u32>) -> Self {
858        Self {
859            build_columns,
860            probe_columns,
861        }
862    }
863}
864
865impl PhysicalHashJoin {
866    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
867        if build_chunks.is_empty() || probe_chunks.is_empty() {
868            return Ok(vec![]);
869        }
870
871        // Use JoinHashTable for parallel build
872        let join_table = JoinHashTable::new(self.build_columns.clone(), self.probe_columns.clone());
873        let hash_table = join_table.build(build_chunks);
874        let mut result = join_table.probe(&hash_table, build_chunks, probe_chunks)?;
875
876        // Propagate field names
877        if !result.is_empty() {
878            let mut output_names: Vec<String> = build_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default();
879            output_names.extend(probe_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default());
880            result[0].field_names = output_names;
881        }
882
883        Ok(result)
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    fn make_i64_chunk(values: &[i64]) -> DataChunk {
892        let mut v = ValueVector::new(PhysicalTypeID::Int64, values.len().max(1));
893        for (i, val) in values.iter().enumerate() {
894            v.set_i64(i, *val);
895        }
896        v.resize(values.len());
897        let ptype = v.physical_type();
898        let fields = vec![akar_common::arrow_vector::ArrowVector::from_legacy(&v).array];
899        DataChunk::new(fields, vec![ptype])
900    }
901
902    fn make_u64_chunk(values: &[u64]) -> DataChunk {
903        let mut v = ValueVector::new(PhysicalTypeID::UInt64, values.len().max(1));
904        for (i, val) in values.iter().enumerate() {
905            let _ = v.set_value(i, &Value::UInt64(*val));
906        }
907        v.resize(values.len());
908        let ptype = v.physical_type();
909        let fields = vec![akar_common::arrow_vector::ArrowVector::from_legacy(&v).array];
910        DataChunk::new(fields, vec![ptype])
911    }
912
913    /// Two Int64 columns: column 0 is a non-key label, column 1 is the key.
914    fn make_two_col_chunk(rows: &[(i64, i64)]) -> DataChunk {
915        let mut label = ValueVector::new(PhysicalTypeID::Int64, rows.len().max(1));
916        let mut id = ValueVector::new(PhysicalTypeID::Int64, rows.len().max(1));
917        for (i, (l, v)) in rows.iter().enumerate() {
918            label.set_i64(i, *l);
919            id.set_i64(i, *v);
920        }
921        label.resize(rows.len());
922        id.resize(rows.len());
923        let ptype = label.physical_type();
924        let fields = vec![
925            akar_common::arrow_vector::ArrowVector::from_legacy(&label).array,
926            akar_common::arrow_vector::ArrowVector::from_legacy(&id).array,
927        ];
928        let mut chunk = DataChunk::new(fields, vec![ptype, ptype]);
929        chunk.field_names = vec!["label".into(), "id".into()];
930        chunk
931    }
932
933    #[test]
934    fn test_semi_join_hash_collision_no_false_match() {
935        // `Int64(7)` and `UInt64(7)` hash to the same bucket but are not equal.
936        // Hash-only matching would falsely emit the probe row.
937        let build = make_i64_chunk(&[7]);
938        let probe = make_u64_chunk(&[7]);
939        let semi = PhysicalSemiJoin {
940            build_columns: vec![0],
941            probe_columns: vec![0],
942        };
943        let result = semi.execute_binary(&[build], &[probe]).unwrap();
944        assert!(
945            result.is_empty(),
946            "hash collision must not produce a semi-join match, got {:?}",
947            result
948        );
949    }
950
951    #[test]
952    fn test_anti_join_hash_collision_keeps_probe() {
953        let build = make_i64_chunk(&[7]);
954        let probe = make_u64_chunk(&[7]);
955        let anti = PhysicalAntiJoin {
956            build_columns: vec![0],
957            probe_columns: vec![0],
958        };
959        let result = anti.execute_binary(&[build], &[probe]).unwrap();
960        assert_eq!(result[0].size, 1, "hash collision must not drop the probe row");
961    }
962
963    #[test]
964    fn test_semi_join_uses_key_column() {
965        // Key is column 1; column 0 must not be treated as the join key.
966        let build = make_two_col_chunk(&[(5, 10), (5, 20)]);
967        let probe = make_two_col_chunk(&[(1, 10), (2, 30), (3, 20)]);
968        let semi = PhysicalSemiJoin {
969            build_columns: vec![1],
970            probe_columns: vec![1],
971        };
972        let result = semi.execute_binary(&[build], &[probe]).unwrap();
973        assert_eq!(result[0].size, 2, "probe rows with id 10 and 20 should match");
974        let got = result[0].get_i64(1, 0).unwrap_or(0);
975        assert_eq!(got, 10, "first matched row id");
976        let got = result[0].get_i64(1, 1).unwrap_or(0);
977        assert_eq!(got, 20, "second matched row id");
978    }
979
980    #[test]
981    fn test_anti_join_uses_key_column() {
982        let build = make_two_col_chunk(&[(5, 10), (5, 20)]);
983        let probe = make_two_col_chunk(&[(1, 10), (2, 30), (3, 20)]);
984        let anti = PhysicalAntiJoin {
985            build_columns: vec![1],
986            probe_columns: vec![1],
987        };
988        let result = anti.execute_binary(&[build], &[probe]).unwrap();
989        assert_eq!(result[0].size, 1, "only probe row with id 30 should remain");
990        let got = result[0].get_i64(1, 0).unwrap_or(0);
991        assert_eq!(got, 30, "remaining row id");
992    }
993
994    #[test]
995    fn test_intersect_execute_sides_cross_product() {
996        let intersect = PhysicalIntersect {
997            num_build_sides: 2,
998            probe_key_col: 0,
999            build_key_col: 0,
1000        };
1001        let build1 = make_i64_chunk(&[1, 1, 5]);
1002        let build2 = make_i64_chunk(&[1, 1, 1, 7]);
1003        let probe = make_i64_chunk(&[1, 2]);
1004        let sides = vec![vec![build1], vec![build2]];
1005        let result = intersect.execute_sides(&sides, &[probe]).unwrap();
1006        assert!(!result.is_empty(), "expected non-empty result");
1007        assert_eq!(result[0].size, 6, "expected 2x3 cross product for probe key 1");
1008        assert_eq!(result[0].fields.len(), 3, "probe + 2 build columns");
1009    }
1010
1011    #[test]
1012    fn test_intersect_execute_sides_key_resolution() {
1013        let mut probe_v = ValueVector::new(PhysicalTypeID::Int64, 2);
1014        probe_v.set_i64(0, 10);
1015        probe_v.set_i64(1, 20);
1016        let mut probe_id = ValueVector::new(PhysicalTypeID::Int64, 2);
1017        probe_id.set_i64(0, 1);
1018        probe_id.set_i64(1, 2);
1019        let ptype = probe_v.physical_type();
1020        let probe_fields = vec![
1021            akar_common::arrow_vector::ArrowVector::from_legacy(&probe_v).array,
1022            akar_common::arrow_vector::ArrowVector::from_legacy(&probe_id).array,
1023        ];
1024        let mut probe = DataChunk::new(probe_fields, vec![ptype, ptype]);
1025        probe.field_names = vec!["a.other".into(), "a.id".into()];
1026
1027        let mut build_v = ValueVector::new(PhysicalTypeID::Int64, 2);
1028        build_v.set_i64(0, 30);
1029        build_v.set_i64(1, 40);
1030        let mut build_id = ValueVector::new(PhysicalTypeID::Int64, 2);
1031        build_id.set_i64(0, 1);
1032        build_id.set_i64(1, 1);
1033        let build_fields = vec![
1034            akar_common::arrow_vector::ArrowVector::from_legacy(&build_v).array,
1035            akar_common::arrow_vector::ArrowVector::from_legacy(&build_id).array,
1036        ];
1037        let mut build = DataChunk::new(build_fields, vec![ptype, ptype]);
1038        build.field_names = vec!["a.other".into(), "a.id".into()];
1039
1040        let intersect = PhysicalIntersect {
1041            num_build_sides: 1,
1042            probe_key_col: 1,
1043            build_key_col: 1,
1044        };
1045        let result = intersect.execute_sides(&[vec![build]], &[probe]).unwrap();
1046        assert!(!result.is_empty(), "expected non-empty result");
1047        assert_eq!(
1048            result[0].size, 2,
1049            "probe id 1 matches both build rows; id 2 matches nothing"
1050        );
1051        assert_eq!(
1052            result[0].field_names,
1053            vec![
1054                "a.other".to_string(),
1055                "a.id".to_string(),
1056                "a.other".to_string(),
1057                "a.id".to_string()
1058            ]
1059        );
1060    }
1061}