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 map of right-side keys. Store the actual values per hash
225        // bucket so probes verify equality — hash-only matching would let
226        // colliding values from different tables falsely match (e.g.
227        // `InternalID` hashes by offset only).
228        let mut hash_map: HashMap<u64, Vec<Value>> = HashMap::new();
229        for chunk in build_chunks {
230            for row in 0..chunk.size {
231                if chunk.fields.get(build_col).is_some() {
232                    let key = chunk.get_value(build_col, row).unwrap_or(Value::Null);
233                    if matches!(key, Value::Null) {
234                        continue;
235                    }
236                    hash_map.entry(value_hash(&key)).or_default().push(key);
237                }
238            }
239        }
240
241        // Probe: emit left rows whose key is in hash_map (with equality check)
242        let num_probe_fields = probe_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
243        let mut probe_types: Vec<PhysicalTypeID> = Vec::with_capacity(num_probe_fields);
244        if let Some(first) = probe_chunks.first() {
245            for col in 0..first.num_fields() {
246                probe_types.push(first.field_types[col]);
247            }
248        }
249
250        let total_probe_rows: usize = probe_chunks.iter().map(|c| c.size).sum();
251        let mut match_rows: Vec<(usize, usize)> = Vec::with_capacity(total_probe_rows);
252        for (ci, chunk) in probe_chunks.iter().enumerate() {
253            for row in 0..chunk.size {
254                if chunk.fields.get(probe_col).is_some() {
255                    let key = chunk.get_value(probe_col, row).unwrap_or(Value::Null);
256                    if matches!(key, Value::Null) {
257                        continue;
258                    }
259                    let matched = hash_map.get(&value_hash(&key)).map_or(false, |bucket| {
260                        bucket.iter().any(|k| *k == key)
261                    });
262                    if matched {
263                        match_rows.push((ci, row));
264                    }
265                }
266            }
267        }
268
269        if match_rows.is_empty() {
270            return Ok(vec![]);
271        }
272
273        // Build output with only left-side columns
274        let num_left_cols = probe_types.len();
275        let mut output_fields: Vec<ValueVector> = probe_types
276            .iter()
277            .map(|t| ValueVector::new(*t, match_rows.len().max(1)))
278            .collect();
279
280        for (out_idx, (ci, row)) in match_rows.iter().enumerate() {
281            if let Some(chunk) = probe_chunks.get(*ci) {
282                for (col, out_field) in output_fields.iter_mut().enumerate().take(num_left_cols) {
283                    if chunk.fields.get(col).is_some() {
284                        let val = chunk.get_value(col, *row).unwrap_or(Value::Null);
285                        let _ = out_field.set_value(out_idx, &val);
286                    }
287                }
288            }
289        }
290        for field in &mut output_fields {
291            field.resize(match_rows.len());
292        }
293        let arrow_fields = output_fields
294            .iter()
295            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
296            .collect::<Vec<_>>();
297        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
298        Ok(vec![DataChunk {
299            fields: arrow_fields,
300            field_types: arrow_field_types,
301            size: match_rows.len(),
302            field_names: vec![],
303            sel_vector: None,
304        }])
305    }
306}
307
308// ==================== AntiJoin ====================
309
310/// Physical anti-join: Returns left rows that have NO matching join key in the right side.
311/// Only left-side columns are emitted.
312pub struct PhysicalAntiJoin {
313    pub build_columns: Vec<u32>,
314    pub probe_columns: Vec<u32>,
315}
316
317impl PhysicalAntiJoin {
318    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
319        if probe_chunks.is_empty() {
320            return Ok(vec![]);
321        }
322        if build_chunks.is_empty() {
323            // If build is empty, AntiJoin returns all of probe
324            return Ok(probe_chunks.to_vec());
325        }
326
327        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
328        let probe_col = self.probe_columns.first().copied().unwrap_or(0) as usize;
329
330        // Build hash map of right-side keys. Store the actual values per hash
331        // bucket so probes verify equality — hash-only matching would let
332        // colliding values from different tables falsely match (e.g.
333        // `InternalID` hashes by offset only).
334        let mut hash_map: HashMap<u64, Vec<Value>> = HashMap::new();
335        for chunk in build_chunks {
336            for row in 0..chunk.size {
337                if chunk.fields.get(build_col).is_some() {
338                    let key = chunk.get_value(build_col, row).unwrap_or(Value::Null);
339                    if matches!(key, Value::Null) {
340                        continue;
341                    }
342                    hash_map.entry(value_hash(&key)).or_default().push(key);
343                }
344            }
345        }
346
347        let num_probe_fields = probe_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
348        let mut probe_types: Vec<PhysicalTypeID> = Vec::with_capacity(num_probe_fields);
349        if let Some(first) = probe_chunks.first() {
350            for col in 0..first.num_fields() {
351                probe_types.push(first.field_types[col]);
352            }
353        }
354
355        let total_probe_rows: usize = probe_chunks.iter().map(|c| c.size).sum();
356        let mut non_match_rows: Vec<(usize, usize)> = Vec::with_capacity(total_probe_rows);
357        for (ci, chunk) in probe_chunks.iter().enumerate() {
358            for row in 0..chunk.size {
359                if let Some(_field) = chunk.fields.get(probe_col) {
360                    let key = chunk.get_value(probe_col, row).unwrap_or(Value::Null);
361                    if matches!(key, Value::Null) {
362                        continue;
363                    }
364                    let matched = hash_map.get(&value_hash(&key)).map_or(false, |bucket| {
365                        bucket.iter().any(|k| *k == key)
366                    });
367                    if !matched {
368                        non_match_rows.push((ci, row));
369                    }
370                }
371            }
372        }
373
374        if non_match_rows.is_empty() {
375            return Ok(vec![]);
376        }
377
378        let num_left_cols = probe_types.len();
379        let mut output_fields: Vec<ValueVector> = probe_types
380            .iter()
381            .map(|t| ValueVector::new(*t, non_match_rows.len().max(1)))
382            .collect();
383
384        for (out_idx, (ci, row)) in non_match_rows.iter().enumerate() {
385            if let Some(chunk) = probe_chunks.get(*ci) {
386                for (col, out_field) in output_fields.iter_mut().enumerate().take(num_left_cols) {
387                    if let Some(_field) = chunk.fields.get(col) {
388                        let val = chunk.get_value(col, *row).unwrap_or(Value::Null);
389                        let _ = out_field.set_value(out_idx, &val);
390                    }
391                }
392            }
393        }
394        for field in &mut output_fields {
395            field.resize(non_match_rows.len());
396        }
397        let arrow_fields = output_fields
398            .iter()
399            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
400            .collect::<Vec<_>>();
401        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
402        Ok(vec![DataChunk {
403            fields: arrow_fields,
404            field_types: arrow_field_types,
405            size: non_match_rows.len(),
406            field_names: vec![],
407            sel_vector: None,
408        }])
409    }
410}
411
412// ==================== Intersect ====================
413
414/// Physical intersect operator.
415///
416/// For multi-pattern matching like `MATCH (a)-[:r1]->(b), (a)-[:r2]->(c)`:
417/// - Multiple build sides each produce a hash table keyed by the shared variable `a`
418/// - The probe side produces candidate values for `a`
419/// - For each probe key, all build hash tables are probed
420/// - The matching node ID lists are pairwise intersected (two-way sorted merge)
421/// - Only keys that appear in ALL build sides produce output
422///
423/// Implementation: a simplified version of the C++ `Intersect` (intersect.h).
424/// Builds hash tables from build chunks, probes with probe chunks, and does
425/// pairwise intersection using sorted node ID comparison.
426pub struct PhysicalIntersect {
427    /// Number of build hash tables (one per pattern).
428    pub num_build_sides: u32,
429    /// Column index of the key in the probe side.
430    pub probe_key_col: u32,
431    /// Column index of the key in each build side.
432    pub build_key_col: u32,
433}
434
435impl PhysicalIntersect {
436    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
437        let num_builds = self.num_build_sides.max(1) as usize;
438        if build_chunks.is_empty() || probe_chunks.is_empty() {
439            return Ok(vec![]);
440        }
441
442        // Partition the flat build chunk list into per-side groups. Each build
443        // side in the plan produces the same number of chunks, so we split evenly.
444        let chunk_group_size = (build_chunks.len() / num_builds).max(1);
445        let mut sides: Vec<Vec<DataChunk>> = Vec::with_capacity(num_builds);
446        for side in 0..num_builds {
447            let start = side * chunk_group_size;
448            let end = (start + chunk_group_size).min(build_chunks.len());
449            sides.push(build_chunks[start..end].to_vec());
450        }
451
452        self.execute_sides(&sides, probe_chunks)
453    }
454
455    /// Execute the intersect against independently-produced build sides.
456    ///
457    /// Each build side is hashed into its own table keyed on the shared node ID.
458    /// A probe row passes only when its key is present in EVERY build table, and
459    /// the output is the full cross product of the matching build rows (so a
460    /// shared node with `k` neighbors per side produces `k1 * k2 * ...` rows).
461    ///
462    /// Output column layout: `[probe columns] + [build side 1 columns] + ...`.
463    pub fn execute_sides(&self, build_sides: &[Vec<DataChunk>], probe_chunks: &[DataChunk]) -> OperatorResult {
464        let num_builds = build_sides.len().max(1);
465        if probe_chunks.is_empty() {
466            return Ok(vec![]);
467        }
468
469        let build_col = self.build_key_col as usize;
470        let probe_col = self.probe_key_col as usize;
471
472        // Build one hash table per side: key_hash → (key_value, Vec<(ci, row)>)
473        let mut build_tables: Vec<HashJoinTable> = Vec::with_capacity(num_builds);
474        let mut side_field_names: Vec<Vec<String>> = Vec::with_capacity(num_builds);
475        let mut side_field_counts: Vec<usize> = Vec::with_capacity(num_builds);
476
477        for side in build_sides {
478            let mut ht: HashJoinTable = HashMap::new();
479            let mut names: Vec<String> = Vec::new();
480            let mut count = 0usize;
481
482            for (ci, chunk) in side.iter().enumerate() {
483                if ci == 0 {
484                    names = chunk.field_names.clone();
485                    count = chunk.fields.len();
486                }
487                for row in 0..chunk.size {
488                    if chunk.fields.get(build_col).is_none() {
489                        continue;
490                    }
491                    let key = chunk.get_value(build_col, row).unwrap_or(Value::Null);
492                    if matches!(key, Value::Null) {
493                        continue;
494                    }
495                    let hash = value_hash(&key);
496                    ht.entry(hash).or_default().push((key, vec![(ci, row)]));
497                }
498            }
499
500            side_field_names.push(names);
501            side_field_counts.push(count);
502            build_tables.push(ht);
503        }
504
505        if build_tables.iter().any(|t| t.is_empty()) {
506            // A build side without data → no key can be in all sides → empty result
507            return Ok(vec![]);
508        }
509
510        let probe_field_names = probe_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default();
511        let probe_field_count = probe_chunks.first().map(|c| c.fields.len()).unwrap_or(0);
512        let mut output_rows: Vec<Vec<Value>> = Vec::new();
513
514        for (ci, chunk) in probe_chunks.iter().enumerate() {
515            let _ = ci;
516            for row in 0..chunk.size {
517                let probe_key = chunk.get_value(probe_col, row).unwrap_or(Value::Null);
518                if matches!(probe_key, Value::Null) {
519                    continue;
520                }
521                let probe_hash = value_hash(&probe_key);
522
523                // Collect matching (chunk_idx, row_idx) per build side.
524                let mut matches_per_side: Vec<Vec<(usize, usize)>> = Vec::with_capacity(num_builds);
525                let mut all_match = true;
526                for ht in &build_tables {
527                    let mut side_matches: Vec<(usize, usize)> = Vec::new();
528                    if let Some(bucket) = ht.get(&probe_hash) {
529                        for (stored_key, locations) in bucket {
530                            if stored_key == &probe_key {
531                                side_matches.extend(locations.iter().cloned());
532                            }
533                        }
534                    }
535                    if side_matches.is_empty() {
536                        all_match = false;
537                        break;
538                    }
539                    matches_per_side.push(side_matches);
540                }
541                if !all_match {
542                    continue;
543                }
544
545                // Cross product across build sides.
546                let mut combos: Vec<Vec<(usize, usize)>> = vec![vec![]];
547                for side_matches in &matches_per_side {
548                    let mut next = Vec::with_capacity(combos.len() * side_matches.len());
549                    for combo in &combos {
550                        for m in side_matches {
551                            let mut c = combo.clone();
552                            c.push(*m);
553                            next.push(c);
554                        }
555                    }
556                    combos = next;
557                }
558
559                let per_row_cols = probe_field_count + side_field_counts.iter().sum::<usize>();
560                for combo in combos {
561                    let mut row_values: Vec<Value> = Vec::with_capacity(per_row_cols);
562                    // Probe side values — all columns of the probe row.
563                    for col_in_probe in 0..probe_field_count {
564                        row_values.push(chunk.get_value(col_in_probe, row).unwrap_or(Value::Null));
565                    }
566                    // One build side's payload per combo entry.
567                    for (side_idx, &(b_ci, b_row)) in combo.iter().enumerate() {
568                        if let Some(side_chunk) = build_sides.get(side_idx).and_then(|s| s.get(b_ci)) {
569                            for col in 0..side_chunk.fields.len() {
570                                row_values.push(side_chunk.get_value(col, b_row).unwrap_or(Value::Null));
571                            }
572                        }
573                    }
574                    output_rows.push(row_values);
575                }
576            }
577        }
578
579        if output_rows.is_empty() {
580            return Ok(vec![]);
581        }
582
583        // Build output DataChunk (one row per field group)
584        let output_size = output_rows.len();
585        let mut output_fields: Vec<ValueVector> = Vec::with_capacity(output_rows.first().map(|r| r.len()).unwrap_or(0));
586
587        if let Some(first_row) = output_rows.first() {
588            for val in first_row {
589                let ptype = val.physical_type();
590                let mut vv = ValueVector::new(ptype, output_size);
591                vv.resize(output_size);
592                output_fields.push(vv);
593            }
594        }
595
596        for (out_idx, row_values) in output_rows.iter().enumerate() {
597            for (col, val) in row_values.iter().enumerate() {
598                if let Some(field) = output_fields.get_mut(col) {
599                    let _ = field.set_value(out_idx, val);
600                }
601            }
602        }
603
604        let arrow_fields = output_fields
605            .iter()
606            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
607            .collect::<Vec<_>>();
608        let arrow_field_types = output_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
609
610        let mut field_names: Vec<String> = probe_field_names;
611        for names in &side_field_names {
612            field_names.extend(names.iter().cloned());
613        }
614
615        Ok(vec![DataChunk {
616            fields: arrow_fields,
617            field_types: arrow_field_types,
618            field_names,
619            size: output_size,
620            sel_vector: None,
621        }])
622    }
623}
624
625// ==================== JoinHashTable ====================
626
627/// A hash table for hash join operations with parallel build support.
628///
629/// Optimized with:
630/// - `ahash` for fast integer hashing (3-5× faster than SipHash)
631/// - Flat bucket design: `HashMap<u64, Vec<(usize, usize)>>` — no Value cloning in buckets
632/// - Pre-sized hash map based on total build rows (zero reallocations)
633/// - Bulk output construction instead of per-row set_value
634pub struct JoinHashTable {
635    build_columns: Vec<u32>,
636    probe_columns: Vec<u32>,
637}
638
639impl JoinHashTable {
640    pub fn new(build_columns: Vec<u32>, probe_columns: Vec<u32>) -> Self {
641        Self {
642            build_columns,
643            probe_columns,
644        }
645    }
646
647    /// Build phase: create a flat hash table mapping key hashes to (chunk_idx, row_idx) pairs.
648    /// The hash table is pre-sized and uses ahash for fast integer hashing.
649    pub fn build(&self, build_chunks: &[DataChunk]) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
650        let total_rows: usize = build_chunks.iter().map(|c| c.size).sum();
651        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
652
653        if total_rows > 1000 {
654            self.build_parallel(build_chunks, build_col, total_rows)
655        } else {
656            self.build_sequential(build_chunks, build_col, total_rows)
657        }
658    }
659
660    fn build_sequential(
661        &self,
662        build_chunks: &[DataChunk],
663        build_col: usize,
664        total_rows: usize,
665    ) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
666        // Pre-size to ~75% load factor to avoid rehashing
667        let mut table: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
668            hashbrown::HashMap::with_capacity(total_rows * 4 / 3);
669
670        for (ci, chunk) in build_chunks.iter().enumerate() {
671            for row in 0..chunk.size {
672                let Some(hash) = hash_chunk_cell(chunk, build_col, row) else {
673                    continue;
674                };
675                table
676                    .entry(hash)
677                    .or_insert_with(|| Vec::with_capacity(4))
678                    .push((ci, row));
679            }
680        }
681        table
682    }
683
684    fn build_parallel(
685        &self,
686        build_chunks: &[DataChunk],
687        build_col: usize,
688        total_rows: usize,
689    ) -> hashbrown::HashMap<u64, Vec<(usize, usize)>> {
690        use rayon::prelude::*;
691
692        let tables: Vec<hashbrown::HashMap<u64, Vec<(usize, usize)>>> = build_chunks
693            .par_iter()
694            .enumerate()
695            .map(|(ci, chunk)| {
696                let mut local: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
697                    hashbrown::HashMap::with_capacity(chunk.size * 4 / 3);
698                for row in 0..chunk.size {
699                    let Some(hash) = hash_chunk_cell(chunk, build_col, row) else {
700                        continue;
701                    };
702                    local
703                        .entry(hash)
704                        .or_insert_with(|| Vec::with_capacity(4))
705                        .push((ci, row));
706                }
707                local
708            })
709            .collect();
710
711        // Merge: pre-size the final table
712        let mut merged: hashbrown::HashMap<u64, Vec<(usize, usize)>> =
713            hashbrown::HashMap::with_capacity(total_rows * 4 / 3);
714        for local in tables {
715            for (hash, locations) in local {
716                merged
717                    .entry(hash)
718                    .or_insert_with(|| Vec::with_capacity(locations.len()))
719                    .extend(locations);
720            }
721        }
722        merged
723    }
724
725    /// Probe phase: for each probe row, look up matching build rows by key hash,
726    /// then verify key equality. Outputs combined build+probe columns.
727    pub fn probe(
728        &self,
729        hash_table: &hashbrown::HashMap<u64, Vec<(usize, usize)>>,
730        build_chunks: &[DataChunk],
731        probe_chunks: &[DataChunk],
732    ) -> OperatorResult {
733        let probe_col = self.probe_columns.first().copied().unwrap_or(0) as usize;
734        let build_col = self.build_columns.first().copied().unwrap_or(0) as usize;
735
736        // Determine output schema from build + probe
737        let num_build_fields = build_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
738        let num_probe_fields = probe_chunks.first().map(|c| c.num_fields()).unwrap_or(0);
739        let total_cols = num_build_fields + num_probe_fields;
740
741        if total_cols == 0 {
742            return Ok(Vec::new());
743        }
744
745        // Collect output types
746        let mut output_types: Vec<PhysicalTypeID> = Vec::with_capacity(total_cols);
747        if let Some(bc) = build_chunks.first() {
748            for col in 0..bc.num_fields() {
749                output_types.push(bc.field_types[col]);
750            }
751        }
752        if let Some(pc) = probe_chunks.first() {
753            for col in 0..pc.num_fields() {
754                output_types.push(pc.field_types[col]);
755            }
756        }
757
758        let total_probe_rows: usize = probe_chunks.iter().map(|c| c.size).sum();
759        let mut matches: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(total_probe_rows);
760
761        for (pci, chunk) in probe_chunks.iter().enumerate() {
762            for row in 0..chunk.size {
763                let Some(probe_hash) = hash_chunk_cell(chunk, probe_col, row) else {
764                    continue;
765                };
766
767                if let Some(locations) = hash_table.get(&probe_hash) {
768                    // Verify key equality for each candidate
769                    for &(bci, brow) in locations {
770                        if chunk_cells_equal(&build_chunks[bci], build_col, brow, chunk, probe_col, row) {
771                            matches.push((bci, brow, pci, row));
772                        }
773                    }
774                }
775            }
776        }
777
778        if matches.is_empty() {
779            return Ok(Vec::new());
780        }
781
782        // Second pass: build output DataChunk from collected matches
783        let num_rows = matches.len();
784        let mut result_fields: Vec<ValueVector> = output_types
785            .iter()
786            .map(|t| {
787                let mut v = ValueVector::new(*t, num_rows);
788                v.resize(num_rows);
789                v
790            })
791            .collect();
792
793        for (out_row, &(bci, brow, pci, prow)) in matches.iter().enumerate() {
794            // Copy build-side columns
795            for col in 0..num_build_fields {
796                if let Some(_field) = build_chunks[bci].fields.get(col) {
797                    let val = build_chunks[bci].get_value(col, brow).unwrap_or(Value::Null);
798                    if matches!(val, Value::Null) {
799                        result_fields[col].set_null(out_row, true);
800                    } else {
801                        store_value_in_vector(&mut result_fields[col], out_row, &val)?;
802                    }
803                }
804            }
805            // Copy probe-side columns
806            for col in 0..num_probe_fields {
807                if let Some(_field) = probe_chunks[pci].fields.get(col) {
808                    let val = probe_chunks[pci].get_value(col, prow).unwrap_or(Value::Null);
809                    if matches!(val, Value::Null) {
810                        result_fields[num_build_fields + col].set_null(out_row, true);
811                    } else {
812                        store_value_in_vector(&mut result_fields[num_build_fields + col], out_row, &val)?;
813                    }
814                }
815            }
816        }
817
818        let arrow_fields = result_fields
819            .iter()
820            .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
821            .collect::<Vec<_>>();
822        let arrow_field_types = result_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
823        Ok(vec![DataChunk {
824            fields: arrow_fields,
825            field_types: arrow_field_types,
826            size: num_rows,
827            field_names: vec![],
828            sel_vector: None,
829        }])
830    }
831}
832
833// ==================== HashJoin ====================
834
835pub struct PhysicalHashJoin {
836    pub build_columns: Vec<u32>,
837    pub probe_columns: Vec<u32>,
838}
839
840impl PhysicalHashJoin {
841    pub fn new(build_columns: Vec<u32>, probe_columns: Vec<u32>) -> Self {
842        Self {
843            build_columns,
844            probe_columns,
845        }
846    }
847}
848
849impl PhysicalHashJoin {
850    pub fn execute_binary(&self, build_chunks: &[DataChunk], probe_chunks: &[DataChunk]) -> OperatorResult {
851        if build_chunks.is_empty() || probe_chunks.is_empty() {
852            return Ok(vec![]);
853        }
854
855        // Use JoinHashTable for parallel build
856        let join_table = JoinHashTable::new(self.build_columns.clone(), self.probe_columns.clone());
857        let hash_table = join_table.build(build_chunks);
858        let mut result = join_table.probe(&hash_table, build_chunks, probe_chunks)?;
859
860        // Propagate field names
861        if !result.is_empty() {
862            let mut output_names: Vec<String> = build_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default();
863            output_names.extend(probe_chunks.first().map(|c| c.field_names.clone()).unwrap_or_default());
864            result[0].field_names = output_names;
865        }
866
867        Ok(result)
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    fn make_i64_chunk(values: &[i64]) -> DataChunk {
876        let mut v = ValueVector::new(PhysicalTypeID::Int64, values.len().max(1));
877        for (i, val) in values.iter().enumerate() {
878            v.set_i64(i, *val);
879        }
880        v.resize(values.len());
881        let ptype = v.physical_type();
882        let fields = vec![akar_common::arrow_vector::ArrowVector::from_legacy(&v).array];
883        DataChunk::new(fields, vec![ptype])
884    }
885
886    fn make_u64_chunk(values: &[u64]) -> DataChunk {
887        let mut v = ValueVector::new(PhysicalTypeID::UInt64, values.len().max(1));
888        for (i, val) in values.iter().enumerate() {
889            let _ = v.set_value(i, &Value::UInt64(*val));
890        }
891        v.resize(values.len());
892        let ptype = v.physical_type();
893        let fields = vec![akar_common::arrow_vector::ArrowVector::from_legacy(&v).array];
894        DataChunk::new(fields, vec![ptype])
895    }
896
897    /// Two Int64 columns: column 0 is a non-key label, column 1 is the key.
898    fn make_two_col_chunk(rows: &[(i64, i64)]) -> DataChunk {
899        let mut label = ValueVector::new(PhysicalTypeID::Int64, rows.len().max(1));
900        let mut id = ValueVector::new(PhysicalTypeID::Int64, rows.len().max(1));
901        for (i, (l, v)) in rows.iter().enumerate() {
902            label.set_i64(i, *l);
903            id.set_i64(i, *v);
904        }
905        label.resize(rows.len());
906        id.resize(rows.len());
907        let ptype = label.physical_type();
908        let fields = vec![
909            akar_common::arrow_vector::ArrowVector::from_legacy(&label).array,
910            akar_common::arrow_vector::ArrowVector::from_legacy(&id).array,
911        ];
912        let mut chunk = DataChunk::new(fields, vec![ptype, ptype]);
913        chunk.field_names = vec!["label".into(), "id".into()];
914        chunk
915    }
916
917    #[test]
918    fn test_semi_join_hash_collision_no_false_match() {
919        // `Int64(7)` and `UInt64(7)` hash to the same bucket but are not equal.
920        // Hash-only matching would falsely emit the probe row.
921        let build = make_i64_chunk(&[7]);
922        let probe = make_u64_chunk(&[7]);
923        let semi = PhysicalSemiJoin {
924            build_columns: vec![0],
925            probe_columns: vec![0],
926        };
927        let result = semi.execute_binary(&[build], &[probe]).unwrap();
928        assert!(
929            result.is_empty(),
930            "hash collision must not produce a semi-join match, got {:?}",
931            result
932        );
933    }
934
935    #[test]
936    fn test_anti_join_hash_collision_keeps_probe() {
937        let build = make_i64_chunk(&[7]);
938        let probe = make_u64_chunk(&[7]);
939        let anti = PhysicalAntiJoin {
940            build_columns: vec![0],
941            probe_columns: vec![0],
942        };
943        let result = anti.execute_binary(&[build], &[probe]).unwrap();
944        assert_eq!(result[0].size, 1, "hash collision must not drop the probe row");
945    }
946
947    #[test]
948    fn test_semi_join_uses_key_column() {
949        // Key is column 1; column 0 must not be treated as the join key.
950        let build = make_two_col_chunk(&[(5, 10), (5, 20)]);
951        let probe = make_two_col_chunk(&[(1, 10), (2, 30), (3, 20)]);
952        let semi = PhysicalSemiJoin {
953            build_columns: vec![1],
954            probe_columns: vec![1],
955        };
956        let result = semi.execute_binary(&[build], &[probe]).unwrap();
957        assert_eq!(result[0].size, 2, "probe rows with id 10 and 20 should match");
958        let got = result[0].get_i64(1, 0).unwrap_or(0);
959        assert_eq!(got, 10, "first matched row id");
960        let got = result[0].get_i64(1, 1).unwrap_or(0);
961        assert_eq!(got, 20, "second matched row id");
962    }
963
964    #[test]
965    fn test_anti_join_uses_key_column() {
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 anti = PhysicalAntiJoin {
969            build_columns: vec![1],
970            probe_columns: vec![1],
971        };
972        let result = anti.execute_binary(&[build], &[probe]).unwrap();
973        assert_eq!(result[0].size, 1, "only probe row with id 30 should remain");
974        let got = result[0].get_i64(1, 0).unwrap_or(0);
975        assert_eq!(got, 30, "remaining row id");
976    }
977
978    #[test]
979    fn test_intersect_execute_sides_cross_product() {
980        let intersect = PhysicalIntersect {
981            num_build_sides: 2,
982            probe_key_col: 0,
983            build_key_col: 0,
984        };
985        let build1 = make_i64_chunk(&[1, 1, 5]);
986        let build2 = make_i64_chunk(&[1, 1, 1, 7]);
987        let probe = make_i64_chunk(&[1, 2]);
988        let sides = vec![vec![build1], vec![build2]];
989        let result = intersect.execute_sides(&sides, &[probe]).unwrap();
990        assert!(!result.is_empty(), "expected non-empty result");
991        assert_eq!(result[0].size, 6, "expected 2x3 cross product for probe key 1");
992        assert_eq!(result[0].fields.len(), 3, "probe + 2 build columns");
993    }
994
995    #[test]
996    fn test_intersect_execute_sides_key_resolution() {
997        let mut probe_v = ValueVector::new(PhysicalTypeID::Int64, 2);
998        probe_v.set_i64(0, 10);
999        probe_v.set_i64(1, 20);
1000        let mut probe_id = ValueVector::new(PhysicalTypeID::Int64, 2);
1001        probe_id.set_i64(0, 1);
1002        probe_id.set_i64(1, 2);
1003        let ptype = probe_v.physical_type();
1004        let probe_fields = vec![
1005            akar_common::arrow_vector::ArrowVector::from_legacy(&probe_v).array,
1006            akar_common::arrow_vector::ArrowVector::from_legacy(&probe_id).array,
1007        ];
1008        let mut probe = DataChunk::new(probe_fields, vec![ptype, ptype]);
1009        probe.field_names = vec!["a.other".into(), "a.id".into()];
1010
1011        let mut build_v = ValueVector::new(PhysicalTypeID::Int64, 2);
1012        build_v.set_i64(0, 30);
1013        build_v.set_i64(1, 40);
1014        let mut build_id = ValueVector::new(PhysicalTypeID::Int64, 2);
1015        build_id.set_i64(0, 1);
1016        build_id.set_i64(1, 1);
1017        let build_fields = vec![
1018            akar_common::arrow_vector::ArrowVector::from_legacy(&build_v).array,
1019            akar_common::arrow_vector::ArrowVector::from_legacy(&build_id).array,
1020        ];
1021        let mut build = DataChunk::new(build_fields, vec![ptype, ptype]);
1022        build.field_names = vec!["a.other".into(), "a.id".into()];
1023
1024        let intersect = PhysicalIntersect {
1025            num_build_sides: 1,
1026            probe_key_col: 1,
1027            build_key_col: 1,
1028        };
1029        let result = intersect.execute_sides(&[vec![build]], &[probe]).unwrap();
1030        assert!(!result.is_empty(), "expected non-empty result");
1031        assert_eq!(
1032            result[0].size, 2,
1033            "probe id 1 matches both build rows; id 2 matches nothing"
1034        );
1035        assert_eq!(
1036            result[0].field_names,
1037            vec![
1038                "a.other".to_string(),
1039                "a.id".to_string(),
1040                "a.other".to_string(),
1041                "a.id".to_string()
1042            ]
1043        );
1044    }
1045}