Skip to main content

akar_processor/physical/order_aggregate/
aggregatehashtable.rs

1//! Auto-extracted from physical_operator.rs
2use crate::physical::common::{hash_value_into, store_value_in_vector};
3use crate::physical::types::OperatorResult;
4use akar_common::types::{PhysicalTypeID, Value};
5use akar_common::vector::{DataChunk, ValueVector};
6use akar_function::AggregateFunction;
7use akar_function::aggregate::AggValueState;
8use akar_parser::ast::Expression;
9use arrow::compute;
10use arrow::datatypes::{Float64Type, Int32Type, Int64Type};
11
12// ==================== AggregateHashTable ====================
13
14/// A parallel hash table for aggregation with GROUP BY keys.
15///
16/// Uses `rayon` thread-local aggregation: each thread builds its own
17/// local hash table, then all local tables are merged.
18pub struct AggregateHashTable {
19    /// Pre-parsed aggregate functions.
20    funcs: Vec<AggregateFunction>,
21    /// Group-by key column indices.
22    group_by_cols: Vec<u32>,
23    /// Aggregate function argument expressions (for resolving column indices).
24    agg_expressions: Vec<Vec<Expression>>,
25}
26
27impl AggregateHashTable {
28    pub fn new(funcs: Vec<AggregateFunction>, group_by_cols: Vec<u32>, agg_expressions: Vec<Vec<Expression>>) -> Self {
29        Self {
30            funcs,
31            group_by_cols,
32            agg_expressions,
33        }
34    }
35
36    /// Aggregate all input chunks, optionally in parallel.
37    pub fn aggregate(&self, chunks: &[DataChunk]) -> OperatorResult {
38        let total_rows: usize = chunks.iter().map(|c| c.size).sum();
39
40        // Resolve aggregate expression args to column indices
41        let mut col_indices = resolve_agg_col_indices(
42            &self.agg_expressions,
43            chunks.first().map(|c| c.field_names.as_slice()).unwrap_or(&[]),
44        );
45
46        // PhysicalAggregate (simple path) carries no agg expressions, so each
47        // function operates on the first input column. Guard against the empty
48        // vec to keep the fast paths below from indexing out of bounds.
49        if col_indices.is_empty() && !self.funcs.is_empty() {
50            col_indices = vec![Some(0); self.funcs.len()];
51        }
52
53        if total_rows == 0 && self.group_by_cols.is_empty() {
54            // Scalar aggregates on empty input: produce one row with default values
55            // (COUNT=0, SUM=Null, etc.)
56            let mut fields = Vec::new();
57            for func in &self.funcs {
58                let state = AggValueState::new(func);
59                let result = state.finalize();
60                let phys_type = result.physical_type();
61                let mut v = ValueVector::new(phys_type, 1);
62                v.resize(1);
63                store_value_in_vector(&mut v, 0, &result)?;
64                fields.push(v);
65            }
66            return Ok(vec![{
67                let arrow_fields = fields
68                    .iter()
69                    .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
70                    .collect::<Vec<_>>();
71                let arrow_field_types = fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
72                DataChunk::new(arrow_fields, arrow_field_types)
73            }]);
74        }
75
76        if total_rows == 0 {
77            return self.empty_result();
78        }
79
80        // Fast path for scalar COUNT aggregates (no GROUP BY)
81        if self.group_by_cols.is_empty() {
82            let all_count = self
83                .funcs
84                .iter()
85                .all(|f| matches!(f, AggregateFunction::Count | AggregateFunction::CountStar));
86            if all_count {
87                let mut fields = Vec::new();
88                for (i, func) in self.funcs.iter().enumerate() {
89                    let mut total = 0u64;
90                    for chunk in chunks {
91                        match func {
92                            AggregateFunction::CountStar => {
93                                total += chunk.active_rows() as u64;
94                            }
95                            AggregateFunction::Count => {
96                                if let Some(col_idx) = col_indices[i] {
97                                    if let Some(field) = chunk.fields.get(col_idx) {
98                                        if chunk.sel_vector.is_some() {
99                                            for row in chunk.iter_rows() {
100                                                if !field.is_null(row) {
101                                                    total += 1;
102                                                }
103                                            }
104                                        } else {
105                                            total += (field.len() - field.null_count()) as u64;
106                                        }
107                                    }
108                                } else {
109                                    total += chunk.active_rows() as u64;
110                                }
111                            }
112                            _ => {}
113                        }
114                    }
115                    let state = AggValueState::Count(total);
116                    let result = state.finalize();
117                    let phys_type = result.physical_type();
118                    let mut v = ValueVector::new(phys_type, 1);
119                    v.resize(1);
120                    store_value_in_vector(&mut v, 0, &result)?;
121                    fields.push(v);
122                }
123                return Ok(vec![{
124                    let arrow_fields = fields
125                        .iter()
126                        .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
127                        .collect::<Vec<_>>();
128                    let arrow_field_types = fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
129                    DataChunk::new(arrow_fields, arrow_field_types)
130                }]);
131            }
132        }
133
134        // Fast path for scalar Sum/Min/Max/Avg aggregates (no GROUP BY).
135        // Uses Arrow compute kernels directly on ArrayRef — avoids per-row Value dispatch.
136        if self.group_by_cols.is_empty() && chunks.iter().all(|c| c.sel_vector.is_none()) {
137            let all_scalar_agg = self.funcs.iter().all(|f| {
138                matches!(
139                    f,
140                    AggregateFunction::Sum | AggregateFunction::Min | AggregateFunction::Max | AggregateFunction::Avg
141                )
142            });
143            if all_scalar_agg {
144                let mut fields = Vec::new();
145                for (i, func) in self.funcs.iter().enumerate() {
146                    if let Some(col_idx) = col_indices[i] {
147                        let result_val = arrow_scalar_agg(func, chunks, col_idx);
148                        let phys_type = result_val.physical_type();
149                        let mut v = ValueVector::new(phys_type, 1);
150                        v.resize(1);
151                        store_value_in_vector(&mut v, 0, &result_val)?;
152                        fields.push(v);
153                    } else {
154                        let mut v = ValueVector::new(PhysicalTypeID::Int64, 1);
155                        v.resize(1);
156                        v.set_null(0, true);
157                        fields.push(v);
158                    }
159                }
160                return Ok(vec![DataChunk::new(
161                    fields
162                        .iter()
163                        .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
164                        .collect(),
165                    fields.iter().map(|v| v.physical_type()).collect(),
166                )]);
167            }
168        }
169
170        // Use rayon parallel aggregation for large inputs
171        if total_rows > 1000 {
172            self.aggregate_parallel(chunks, &col_indices)
173        } else {
174            self.aggregate_sequential(chunks, &col_indices)
175        }
176    }
177
178    /// Parallel aggregation: split chunks across threads, aggregate locally, merge.
179    fn aggregate_parallel(&self, chunks: &[DataChunk], col_indices: &[Option<usize>]) -> OperatorResult {
180        use rayon::prelude::*;
181        let total_rows: usize = chunks.iter().map(|c| c.size).sum();
182        type LocalTable = hashbrown::HashMap<u64, Vec<(Value, Vec<AggValueState>)>>;
183
184        // Each thread aggregates its portion
185        let group_cols = &self.group_by_cols;
186        let funcs = &self.funcs;
187
188        let results: Vec<LocalTable> = chunks
189            .par_iter()
190            .map(|chunk| {
191                let mut local: LocalTable = hashbrown::HashMap::with_capacity(chunk.size.max(16));
192                for row in chunk.iter_rows() {
193                    let hash = hash_group_key(chunk, group_cols, row);
194                    let bucket = local.entry(hash).or_default();
195                    let entry = bucket.iter_mut().find(|(k, _)| keys_equal(k, chunk, group_cols, row));
196                    if let Some((_, states)) = entry {
197                        update_states_row(states, chunk, funcs, col_indices, row);
198                    } else {
199                        let key = build_group_key(chunk, group_cols, row);
200                        let mut states = funcs.iter().map(AggValueState::new).collect::<Vec<_>>();
201                        update_states_row(&mut states, chunk, funcs, col_indices, row);
202                        bucket.push((key, states));
203                    }
204                }
205                local
206            })
207            .collect();
208
209        // Merge all local tables
210        let mut merged: LocalTable = hashbrown::HashMap::with_capacity(total_rows.max(16));
211        for local in results {
212            for (hash, bucket) in local {
213                let mbucket = merged.entry(hash).or_default();
214                for (key, states) in bucket {
215                    let entry = mbucket.iter_mut().find(|(k, _)| *k == key);
216                    if let Some((_, existing)) = entry {
217                        for (i, s) in states.iter().enumerate() {
218                            existing[i].merge(s);
219                        }
220                    } else {
221                        mbucket.push((key, states));
222                    }
223                }
224            }
225        }
226        self.build_output(&merged)
227    }
228
229    /// Sequential aggregation (small inputs).
230    fn aggregate_sequential(&self, chunks: &[DataChunk], col_indices: &[Option<usize>]) -> OperatorResult {
231        let total_rows: usize = chunks.iter().map(|c| c.size).sum();
232        let mut groups: hashbrown::HashMap<u64, Vec<(Value, Vec<AggValueState>)>> =
233            hashbrown::HashMap::with_capacity(total_rows.max(16));
234        let group_cols = &self.group_by_cols;
235        let funcs = &self.funcs;
236
237        for chunk in chunks {
238            for row in chunk.iter_rows() {
239                let hash = hash_group_key(chunk, group_cols, row);
240                let bucket = groups.entry(hash).or_default();
241                let entry = bucket.iter_mut().find(|(k, _)| keys_equal(k, chunk, group_cols, row));
242                if let Some((_, states)) = entry {
243                    update_states_row(states, chunk, funcs, col_indices, row);
244                } else {
245                    let key = build_group_key(chunk, group_cols, row);
246                    let mut states = funcs.iter().map(AggValueState::new).collect::<Vec<_>>();
247                    update_states_row(&mut states, chunk, funcs, col_indices, row);
248                    bucket.push((key, states));
249                }
250            }
251        }
252        self.build_output(&groups)
253    }
254
255    fn empty_result(&self) -> OperatorResult {
256        let num_cols = self.group_by_cols.len() + self.funcs.len();
257        Ok(vec![DataChunk::new(
258            Vec::with_capacity(num_cols),
259            Vec::with_capacity(num_cols),
260        )])
261    }
262
263    /// Scalar aggregate (no GROUP BY) over empty input: emit one row where each
264    /// aggregate column holds its initial state's final value (COUNT=0, SUM=NULL,
265    /// MIN/MAX/AVG=NULL, COLLECT=[], ...).
266    fn empty_scalar_result(&self) -> OperatorResult {
267        let mut fields = Vec::new();
268        for func in &self.funcs {
269            let state = AggValueState::new(func);
270            let result = state.finalize();
271            let phys_type = result.physical_type();
272            let mut v = ValueVector::new(phys_type, 1);
273            v.resize(1);
274            store_value_in_vector(&mut v, 0, &result)?;
275            fields.push(v);
276        }
277        Ok(vec![{
278            let arrow_fields = fields
279                .iter()
280                .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
281                .collect::<Vec<_>>();
282            let arrow_field_types = fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
283            DataChunk::new(arrow_fields, arrow_field_types)
284        }])
285    }
286
287    pub fn build_output(&self, groups: &hashbrown::HashMap<u64, Vec<(Value, Vec<AggValueState>)>>) -> OperatorResult {
288        if groups.is_empty() {
289            // Scalar aggregate (no GROUP BY) over empty input must still emit
290            // exactly one row with default values (COUNT=0, SUM/MIN/MAX/AVG=NULL).
291            if self.group_by_cols.is_empty() && !self.funcs.is_empty() {
292                return self.empty_scalar_result();
293            }
294            return self.empty_result();
295        }
296
297        // Flatten buckets into parallel arrays
298        let mut group_keys: Vec<Value> = Vec::new();
299        let mut agg_results: Vec<Vec<Value>> = (0..self.funcs.len()).map(|_| Vec::new()).collect();
300
301        for bucket in groups.values() {
302            for (key, states) in bucket {
303                group_keys.push(key.clone());
304                for (i, state) in states.iter().enumerate() {
305                    agg_results[i].push(state.finalize());
306                }
307            }
308        }
309
310        let num_group_cols = self.group_by_cols.len();
311        let num_rows = group_keys.len();
312        let mut output = Vec::with_capacity(num_group_cols + self.funcs.len());
313
314        // Group key columns
315        if num_group_cols == 1 {
316            let first_val = &group_keys[0];
317            let phys_type = first_val.physical_type();
318            let mut v = ValueVector::new(phys_type, num_rows);
319            v.resize(num_rows);
320            for (row, key) in group_keys.iter().enumerate() {
321                if matches!(key, Value::Null) {
322                    v.set_null(row, true);
323                } else {
324                    store_value_in_vector(&mut v, row, key)?;
325                }
326            }
327            output.push(v);
328        } else {
329            for gc_idx in 0..num_group_cols {
330                let first_key = &group_keys[0];
331                let inner_val = match first_key {
332                    Value::List(vals) => vals.get(gc_idx).cloned().unwrap_or(Value::Null),
333                    _ => Value::Null,
334                };
335                let phys_type = inner_val.physical_type();
336                let mut v = ValueVector::new(phys_type, num_rows);
337                v.resize(num_rows);
338                for (row, key) in group_keys.iter().enumerate() {
339                    let val = match key {
340                        Value::List(vals) => vals.get(gc_idx).cloned().unwrap_or(Value::Null),
341                        _ => Value::Null,
342                    };
343                    if matches!(val, Value::Null) {
344                        v.set_null(row, true);
345                    } else {
346                        store_value_in_vector(&mut v, row, &val)?;
347                    }
348                }
349                output.push(v);
350            }
351        }
352
353        // Aggregate result columns
354        for i in 0..self.funcs.len() {
355            let first_val = &agg_results[i][0];
356            let phys_type = first_val.physical_type();
357            let mut v = ValueVector::new(phys_type, num_rows);
358            v.resize(num_rows);
359            for (row, val) in agg_results[i].iter().enumerate() {
360                store_value_in_vector(&mut v, row, val)?;
361            }
362            output.push(v);
363        }
364
365        // Split output into chunks of 2048
366        const CHUNK_SIZE: usize = 2048;
367        let mut chunks = Vec::new();
368
369        for chunk_start in (0..num_rows).step_by(CHUNK_SIZE) {
370            let chunk_end = (chunk_start + CHUNK_SIZE).min(num_rows);
371            let chunk_len = chunk_end - chunk_start;
372
373            let mut chunk_fields = Vec::with_capacity(output.len());
374            for field in &output {
375                let mut new_v = ValueVector::new(field.physical_type(), chunk_len);
376                new_v.resize(chunk_len);
377                for i in 0..chunk_len {
378                    if field.is_null(chunk_start + i) {
379                        new_v.set_null(i, true);
380                    } else if let Some(val) = field.get_value(chunk_start + i) {
381                        store_value_in_vector(&mut new_v, i, &val)?;
382                    }
383                }
384                chunk_fields.push(new_v);
385            }
386            chunks.push({
387                let arrow_fields = chunk_fields
388                    .iter()
389                    .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
390                    .collect::<Vec<_>>();
391                let arrow_field_types = chunk_fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
392                DataChunk::new(arrow_fields, arrow_field_types)
393            });
394        }
395
396        Ok(chunks)
397    }
398}
399
400/// Hash group key columns directly from Arrow arrays without creating intermediate `Value` objects.
401/// For strings, avoids the `to_string()` allocation that `get_value()` would incur.
402/// For primitives, avoids `Value` enum dispatch overhead.
403pub fn hash_group_key(chunk: &DataChunk, group_cols: &[u32], row: usize) -> u64 {
404    use std::hash::Hash;
405    use std::hash::Hasher;
406    let mut hasher = ahash::AHasher::default();
407    for &gc in group_cols {
408        let col = gc as usize;
409        if col >= chunk.fields.len() {
410            0u8.hash(&mut hasher);
411            continue;
412        }
413        if chunk.is_null(col, row) {
414            0u8.hash(&mut hasher);
415            continue;
416        }
417        match chunk.field_types[col] {
418            PhysicalTypeID::Int64 => {
419                let v = chunk.get_i64(col, row).unwrap_or(0);
420                v.hash(&mut hasher);
421            }
422            PhysicalTypeID::Int32 => {
423                let v = chunk.get_i32(col, row).unwrap_or(0);
424                v.hash(&mut hasher);
425            }
426            PhysicalTypeID::Double => {
427                let v = chunk.get_f64(col, row).unwrap_or(0.0);
428                v.to_bits().hash(&mut hasher);
429            }
430            PhysicalTypeID::Bool => {
431                let v = chunk.get_bool(col, row).unwrap_or(false);
432                v.hash(&mut hasher);
433            }
434            PhysicalTypeID::String => {
435                if let Some(s) = chunk.get_string(col, row) {
436                    s.hash(&mut hasher);
437                }
438            }
439            _ => {
440                // Fallback: create Value for types we don't handle directly
441                if let Some(val) = chunk.get_value(col, row) {
442                    hash_value_into(&val, &mut hasher);
443                }
444            }
445        }
446    }
447    hasher.finish()
448}
449
450/// Check if a stored group key matches the current row's group column values.
451/// Avoids creating Value::List or intermediate Value objects for comparison.
452pub fn keys_equal(stored: &Value, chunk: &DataChunk, group_cols: &[u32], row: usize) -> bool {
453    if group_cols.is_empty() {
454        // Scalar aggregate without GROUP BY: every row belongs to the single
455        // global group, so the stored `Value::Null` key always matches (P52.8).
456        // Without this, each row became its own bucket entry with an identical
457        // Null key, causing O(n^2) bucket scans and N duplicate output groups.
458        return true;
459    }
460    if group_cols.len() == 1 {
461        let col = group_cols[0] as usize;
462        if col >= chunk.fields.len() {
463            return *stored == Value::Null;
464        }
465        if chunk.is_null(col, row) {
466            return *stored == Value::Null;
467        }
468        return match stored {
469            Value::Int64(v) => chunk.get_i64(col, row).is_some_and(|x| x == *v),
470            Value::Int32(v) => chunk.get_i32(col, row).is_some_and(|x| x == *v),
471            Value::Double(v) => chunk.get_f64(col, row).is_some_and(|x| x == *v),
472            Value::Bool(v) => chunk.get_bool(col, row).is_some_and(|x| x == *v),
473            Value::String(v) => chunk.get_string(col, row).is_some_and(|x| x == *v),
474            _ => {
475                let val = chunk.get_value(col, row).unwrap_or(Value::Null);
476                *stored == val
477            }
478        };
479    }
480    match stored {
481        Value::List(vals) if vals.len() == group_cols.len() => vals.iter().enumerate().all(|(i, v)| {
482            let col = group_cols[i] as usize;
483            if col >= chunk.fields.len() {
484                return *v == Value::Null;
485            }
486            if chunk.is_null(col, row) {
487                return *v == Value::Null;
488            }
489            match v {
490                Value::Int64(expected) => chunk.get_i64(col, row).is_some_and(|x| x == *expected),
491                Value::Int32(expected) => chunk.get_i32(col, row).is_some_and(|x| x == *expected),
492                Value::Double(expected) => chunk.get_f64(col, row).is_some_and(|x| x == *expected),
493                Value::Bool(expected) => chunk.get_bool(col, row).is_some_and(|x| x == *expected),
494                Value::String(expected) => chunk.get_string(col, row).is_some_and(|x| x == *expected),
495                _ => {
496                    let val = chunk.get_value(col, row).unwrap_or(Value::Null);
497                    *v == val
498                }
499            }
500        }),
501        _ => false,
502    }
503}
504
505/// Build a composite group key from chunk columns.
506pub fn build_group_key(chunk: &DataChunk, group_cols: &[u32], row: usize) -> Value {
507    if group_cols.is_empty() {
508        return Value::Null;
509    }
510    if group_cols.len() == 1 {
511        chunk
512            .fields
513            .get(group_cols[0] as usize)
514            .map(|_| chunk.get_value(group_cols[0] as usize, row))
515            .unwrap_or(Some(Value::Null))
516            .unwrap_or(Value::Null)
517    } else {
518        let vals: Vec<Value> = group_cols
519            .iter()
520            .map(|&gc| {
521                chunk
522                    .fields
523                    .get(gc as usize)
524                    .map(|_| chunk.get_value(gc as usize, row))
525                    .unwrap_or(Some(Value::Null))
526                    .unwrap_or(Value::Null)
527            })
528            .collect();
529        Value::List(vals)
530    }
531}
532
533/// Resolve an expression name to a column index using flexible matching.
534/// Tries: exact match, numeric parse, ends_with(".name").
535fn resolve_name_to_col(name: &str, field_names: &[String]) -> Option<usize> {
536    if let Some(idx) = field_names.iter().position(|n| n == name) {
537        return Some(idx);
538    }
539    if let Ok(idx) = name.parse::<usize>() {
540        if idx < field_names.len() {
541            return Some(idx);
542        }
543    }
544    let dot_name = format!(".{}", name);
545    if let Some(idx) = field_names.iter().position(|n| n.ends_with(&dot_name)) {
546        return Some(idx);
547    }
548    None
549}
550
551/// Resolve aggregate function argument expressions to column indices.
552/// Returns one Option per function: None means no column needed (e.g., COUNT(*)).
553pub fn resolve_agg_col_indices(agg_expressions: &[Vec<Expression>], field_names: &[String]) -> Vec<Option<usize>> {
554    agg_expressions
555        .iter()
556        .map(|args| {
557            for expr in args {
558                match expr {
559                    Expression::Variable(name) => {
560                        if let Some(idx) = resolve_name_to_col(name, field_names) {
561                            return Some(idx);
562                        }
563                    }
564                    Expression::PropertyAccess(base, prop) => {
565                        if let Expression::Variable(prefix) = base.as_ref() {
566                            // Try "prefix.prop" (exact match with Cypher variable name)
567                            let qualified = format!("{}.{}", prefix, prop);
568                            if let Some(idx) = field_names.iter().position(|n| n == &qualified) {
569                                return Some(idx);
570                            }
571                            // Try ".prop" suffix match (handles table-prefixed names like "Person.age")
572                            let dot_prop = format!(".{}", prop);
573                            if let Some(idx) = field_names.iter().position(|n| n.ends_with(&dot_prop)) {
574                                return Some(idx);
575                            }
576                        }
577                    }
578                    Expression::Star => return None,
579                    _ => {}
580                }
581            }
582            None
583        })
584        .collect()
585}
586
587/// Resolve GROUP BY expressions to actual column indices using field_names.
588pub fn resolve_group_by_indices(group_by: &[Expression], field_names: &[String]) -> Vec<u32> {
589    group_by
590        .iter()
591        .map(|expr| match expr {
592            Expression::Variable(name) => resolve_name_to_col(name, field_names).unwrap_or(0) as u32,
593            Expression::PropertyAccess(base, prop) => {
594                if let Expression::Variable(prefix) = base.as_ref() {
595                    let qualified = format!("{}.{}", prefix, prop);
596                    if let Some(idx) = field_names.iter().position(|n| n == &qualified) {
597                        return idx as u32;
598                    }
599                    let dot_prop = format!(".{}", prop);
600                    if let Some(idx) = field_names.iter().position(|n| n.ends_with(&dot_prop)) {
601                        return idx as u32;
602                    }
603                }
604                0
605            }
606            _ => 0,
607        })
608        .collect()
609}
610
611/// Update aggregate states for a single row.
612pub fn update_states_row(
613    states: &mut [AggValueState],
614    chunk: &DataChunk,
615    funcs: &[AggregateFunction],
616    col_indices: &[Option<usize>],
617    row: usize,
618) {
619    for (i, state) in states.iter_mut().enumerate() {
620        if matches!(funcs[i], AggregateFunction::CountStar) {
621            if let AggValueState::Count(n) = state {
622                *n += 1;
623            }
624            continue;
625        }
626        if col_indices.get(i).copied().flatten().is_none() && matches!(funcs[i], AggregateFunction::Count) {
627            if let AggValueState::Count(n) = state {
628                *n += 1;
629            }
630            continue;
631        }
632        let col_idx = col_indices
633            .get(i)
634            .copied()
635            .flatten()
636            .unwrap_or_else(|| i.min(chunk.fields.len().saturating_sub(1)));
637        let val = chunk
638            .fields
639            .get(col_idx)
640            .map(|_| chunk.get_value(col_idx, row))
641            .unwrap_or(Some(Value::Null))
642            .unwrap_or(Value::Null);
643        state.update(&val);
644    }
645}
646
647/// Compute a scalar aggregate (Sum/Min/Max/Avg) over chunks using Arrow compute kernels.
648/// Avoids per-row Value dispatch entirely.
649fn arrow_scalar_agg(func: &AggregateFunction, chunks: &[DataChunk], col_idx: usize) -> Value {
650    // Collect non-null numeric values from all chunks into a single Arrow array
651    let all_values: Vec<Value> = chunks
652        .iter()
653        .flat_map(|c| {
654            if col_idx >= c.fields.len() {
655                return Vec::new();
656            }
657            let field = &c.fields[col_idx];
658            let rows = if c.sel_vector.is_some() {
659                c.iter_rows().collect::<Vec<_>>()
660            } else {
661                (0..c.size).collect::<Vec<_>>()
662            };
663            rows.into_iter()
664                .filter_map(|row| {
665                    if field.is_null(row) {
666                        None
667                    } else {
668                        c.get_value(col_idx, row)
669                    }
670                })
671                .collect::<Vec<_>>()
672        })
673        .collect();
674
675    if all_values.is_empty() {
676        return match func {
677            AggregateFunction::Sum | AggregateFunction::Avg => Value::Null,
678            AggregateFunction::Min | AggregateFunction::Max => Value::Null,
679            _ => Value::Null,
680        };
681    }
682
683    // Try to use Arrow compute kernels on primitive arrays
684    if let Some(arr) = values_to_prim_array(&all_values) {
685        return match func {
686            AggregateFunction::Sum => compute_sum(&arr),
687            AggregateFunction::Min => compute_min(&arr),
688            AggregateFunction::Max => compute_max(&arr),
689            AggregateFunction::Avg => {
690                let sum_val = compute_sum_f64(&arr);
691                let count = non_null_count(chunks, col_idx);
692                if count == 0 {
693                    Value::Null
694                } else {
695                    Value::Double(sum_val / count as f64)
696                }
697            }
698            _ => Value::Null,
699        };
700    }
701
702    // Fallback: per-row Value dispatch (for non-numeric types)
703    let mut state = AggValueState::new(func);
704    for val in &all_values {
705        state.update(val);
706    }
707    state.finalize()
708}
709
710enum PrimArray {
711    I64(arrow::array::PrimitiveArray<Int64Type>),
712    I32(arrow::array::PrimitiveArray<Int32Type>),
713    F64(arrow::array::PrimitiveArray<Float64Type>),
714}
715
716fn values_to_prim_array(vals: &[Value]) -> Option<PrimArray> {
717    match &vals[0] {
718        Value::Int64(_) => {
719            let mut b = arrow::array::Int64Builder::with_capacity(vals.len());
720            for v in vals {
721                match v {
722                    Value::Int64(n) => b.append_value(*n),
723                    Value::Null => b.append_null(),
724                    _ => return None,
725                }
726            }
727            Some(PrimArray::I64(b.finish()))
728        }
729        Value::Int32(_) => {
730            let mut b = arrow::array::Int32Builder::with_capacity(vals.len());
731            for v in vals {
732                match v {
733                    Value::Int32(n) => b.append_value(*n),
734                    Value::Null => b.append_null(),
735                    _ => return None,
736                }
737            }
738            Some(PrimArray::I32(b.finish()))
739        }
740        Value::Double(_) => {
741            let mut b = arrow::array::Float64Builder::with_capacity(vals.len());
742            for v in vals {
743                match v {
744                    Value::Double(n) => b.append_value(*n),
745                    Value::Null => b.append_null(),
746                    _ => return None,
747                }
748            }
749            Some(PrimArray::F64(b.finish()))
750        }
751        _ => None,
752    }
753}
754
755fn compute_sum(arr: &PrimArray) -> Value {
756    match arr {
757        PrimArray::I64(a) => compute::sum(a).map(Value::Int64).unwrap_or(Value::Null),
758        PrimArray::I32(a) => compute::sum(a).map(|v| Value::Int64(v as i64)).unwrap_or(Value::Null),
759        PrimArray::F64(a) => compute::sum(a).map(Value::Double).unwrap_or(Value::Null),
760    }
761}
762
763fn compute_sum_f64(arr: &PrimArray) -> f64 {
764    match arr {
765        PrimArray::I64(a) => compute::sum::<Int64Type>(a).unwrap_or(0) as f64,
766        PrimArray::I32(a) => compute::sum::<Int32Type>(a).unwrap_or(0) as f64,
767        PrimArray::F64(a) => compute::sum::<Float64Type>(a).unwrap_or(0.0),
768    }
769}
770
771fn compute_min(arr: &PrimArray) -> Value {
772    match arr {
773        PrimArray::I64(a) => compute::min(a).map(Value::Int64).unwrap_or(Value::Null),
774        PrimArray::I32(a) => compute::min(a).map(|v| Value::Int64(v as i64)).unwrap_or(Value::Null),
775        PrimArray::F64(a) => compute::min(a).map(Value::Double).unwrap_or(Value::Null),
776    }
777}
778
779fn compute_max(arr: &PrimArray) -> Value {
780    match arr {
781        PrimArray::I64(a) => compute::max(a).map(Value::Int64).unwrap_or(Value::Null),
782        PrimArray::I32(a) => compute::max(a).map(|v| Value::Int64(v as i64)).unwrap_or(Value::Null),
783        PrimArray::F64(a) => compute::max(a).map(Value::Double).unwrap_or(Value::Null),
784    }
785}
786
787fn non_null_count(chunks: &[DataChunk], col_idx: usize) -> u64 {
788    let mut count = 0u64;
789    for c in chunks {
790        if col_idx >= c.fields.len() {
791            continue;
792        }
793        let field = &c.fields[col_idx];
794        if c.sel_vector.is_some() {
795            for row in c.iter_rows() {
796                if !field.is_null(row) {
797                    count += 1;
798                }
799            }
800        } else {
801            count += (field.len() - field.null_count()) as u64;
802        }
803    }
804    count
805}