1use std::sync::Arc;
26
27use arrow::array::{
28 Array, ArrayRef, Decimal128Array, Float64Array, Int64Array, StringArray, UInt64Array,
29};
30use arrow::compute::kernels::aggregate as arrow_agg;
31use arrow::datatypes::{DataType, Field, Schema};
32use arrow::record_batch::RecordBatch;
33
34use crate::error::{QueryError, Result};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AggFn {
39 Count,
41 Sum,
43 Min,
45 Max,
47 Avg,
49 First,
65 Last,
81}
82
83pub const AVG_EXTRA_SCALE: u8 = 6;
94
95#[derive(Debug, Clone, PartialEq)]
106pub enum AggResult {
107 Null,
109 F64(f64),
111 Decimal {
113 mantissa: i128,
115 scale: u8,
117 },
118 Histogram(Box<chronix_core::histogram::Histogram>),
125}
126
127impl AggResult {
128 #[must_use]
134 #[allow(clippy::cast_precision_loss)]
135 pub fn as_f64(self) -> Option<f64> {
136 match self {
137 Self::Null => None,
138 Self::F64(v) => Some(v),
139 Self::Decimal { mantissa, scale } => {
140 Some(mantissa as f64 / chronix_core::pow10(u32::from(scale)).unwrap_or(1) as f64)
141 }
142 Self::Histogram(_) => None,
146 }
147 }
148}
149
150pub fn agg_result_column(name: &str, results: &[AggResult]) -> Result<(Field, ArrayRef)> {
161 if results.iter().any(|r| matches!(r, AggResult::Histogram(_))) {
165 let blobs: Vec<Option<Vec<u8>>> = results
166 .iter()
167 .map(|r| match r {
168 AggResult::Histogram(h) => postcard::to_allocvec(h.as_ref()).ok(),
169 _ => None,
170 })
171 .collect();
172 let refs: Vec<Option<&[u8]>> = blobs.iter().map(|b| b.as_deref()).collect();
173 return Ok((
174 Field::new(name, DataType::Binary, true),
175 Arc::new(arrow::array::BinaryArray::from(refs)),
176 ));
177 }
178 let scale = results.iter().find_map(|r| match r {
179 AggResult::Decimal { scale, .. } => Some(*scale),
180 _ => None,
181 });
182 match scale {
183 Some(scale) => {
184 let scale_i8 = i8::try_from(scale).map_err(|_| {
185 QueryError::Validation(format!("decimal scale {scale} is out of range"))
186 })?;
187 let mantissas: Vec<Option<i128>> = results
188 .iter()
189 .map(|r| match r {
190 AggResult::Decimal { mantissa, .. } => Some(*mantissa),
191 _ => None,
192 })
193 .collect();
194 let array = Decimal128Array::from(mantissas)
195 .with_precision_and_scale(chronix_core::DECIMAL_PRECISION, scale_i8)?;
196 let field = Field::new(
197 name,
198 DataType::Decimal128(chronix_core::DECIMAL_PRECISION, scale_i8),
199 true,
200 );
201 Ok((field, Arc::new(array)))
202 }
203 None => {
204 let values: Vec<Option<f64>> = results.iter().map(|r| r.clone().as_f64()).collect();
205 Ok((
206 Field::new(name, DataType::Float64, true),
207 Arc::new(Float64Array::from(values)),
208 ))
209 }
210 }
211}
212
213#[derive(Clone, Copy, Debug)]
215pub enum Num {
216 F64(f64),
218 Dec(i128, u8),
220}
221
222#[derive(Debug, Clone)]
230pub struct DecimalTrack {
231 pub scale: u8,
233 pub sum: i128,
235 pub min: i128,
237 pub max: i128,
239 pub first: i128,
241 pub last: i128,
243 pub overflow: bool,
247}
248
249impl DecimalTrack {
250 #[must_use]
252 pub fn new(scale: u8) -> Self {
253 Self {
254 scale,
255 sum: 0,
256 min: i128::MAX,
257 max: i128::MIN,
258 first: 0,
259 last: 0,
260 overflow: false,
261 }
262 }
263
264 pub fn widen_to(&mut self, scale: u8) {
266 let Some(factor) = scale
267 .checked_sub(self.scale)
268 .and_then(|d| chronix_core::pow10(u32::from(d)))
269 else {
270 self.overflow = true;
271 return;
272 };
273 let mut scaled = |v: i128| match v.checked_mul(factor) {
274 Some(x) => x,
275 None => {
276 self.overflow = true;
277 v
278 }
279 };
280 self.sum = scaled(self.sum);
281 if self.min != i128::MAX {
284 self.min = scaled(self.min);
285 }
286 if self.max != i128::MIN {
287 self.max = scaled(self.max);
288 }
289 self.first = scaled(self.first);
290 self.last = scaled(self.last);
291 self.scale = scale;
292 }
293
294 pub fn align(&mut self, mantissa: i128, scale: u8) -> Option<i128> {
300 match scale.cmp(&self.scale) {
301 std::cmp::Ordering::Equal => Some(mantissa),
302 std::cmp::Ordering::Greater => {
303 self.widen_to(scale);
304 Some(mantissa)
305 }
306 std::cmp::Ordering::Less => chronix_core::pow10(u32::from(self.scale - scale))
307 .and_then(|f| mantissa.checked_mul(f)),
308 }
309 }
310
311 #[must_use]
313 pub fn sum_result(&self) -> AggResult {
314 if self.overflow || chronix_core::Decimal::new(self.sum, self.scale).is_err() {
315 return AggResult::Null;
316 }
317 AggResult::Decimal {
318 mantissa: self.sum,
319 scale: self.scale,
320 }
321 }
322
323 #[must_use]
327 pub fn avg_result(&self, count: u64) -> AggResult {
328 if self.overflow || count == 0 {
329 return AggResult::Null;
330 }
331 let target = self
332 .scale
333 .saturating_add(AVG_EXTRA_SCALE)
334 .min(chronix_core::MAX_DECIMAL_SCALE);
335 let extra = target - self.scale;
336 let Some(numerator) =
337 chronix_core::pow10(u32::from(extra)).and_then(|factor| self.sum.checked_mul(factor))
338 else {
339 return AggResult::Null;
340 };
341 let divisor = i128::from(count);
342 let quotient = numerator / divisor;
343 let remainder = numerator % divisor;
344 let rounded = match remainder.unsigned_abs().checked_mul(2) {
346 Some(twice) if twice >= divisor.unsigned_abs() => {
347 quotient + if numerator < 0 { -1 } else { 1 }
348 }
349 _ => quotient,
350 };
351 if chronix_core::Decimal::new(rounded, target).is_err() {
352 return AggResult::Null;
353 }
354 AggResult::Decimal {
355 mantissa: rounded,
356 scale: target,
357 }
358 }
359
360 #[must_use]
362 pub fn at(&self, mantissa: i128) -> AggResult {
363 AggResult::Decimal {
364 mantissa,
365 scale: self.scale,
366 }
367 }
368}
369
370macro_rules! impl_aggregate {
376 ($name:ident, $arr_ty:ty, $conv:expr) => {
377 #[allow(clippy::cast_precision_loss)]
378 fn $name(values: &$arr_ty, function: AggFn) -> Option<f64> {
379 if values.is_empty() {
381 return if function == AggFn::Count {
382 Some(0.0)
383 } else {
384 None
385 };
386 }
387 match function {
388 AggFn::Count => Some((values.len() - values.null_count()) as f64),
391 AggFn::Sum => arrow_agg::sum(values).map($conv),
392 AggFn::Min => arrow_agg::min(values).map($conv),
393 AggFn::Max => arrow_agg::max(values).map($conv),
394 AggFn::Avg => {
395 let sum = arrow_agg::sum(values).map($conv)?;
396 let count = values.len() - values.null_count();
397 if count == 0 {
398 None
399 } else {
400 Some(sum / count as f64)
401 }
402 }
403 AggFn::First => values.iter().flatten().next().map($conv),
404 AggFn::Last => values.iter().flatten().last().map($conv),
405 }
406 }
407 };
408}
409
410impl_aggregate!(aggregate_f64, Float64Array, |v| v);
411impl_aggregate!(aggregate_i64, Int64Array, |v| v as f64);
412impl_aggregate!(aggregate_u64, UInt64Array, |v| v as f64);
413
414pub fn aggregate_batch(
422 batch: &RecordBatch,
423 functions: &[AggFn],
424 field_columns: &[&str],
425 memory_tracker: Option<&crate::memory::MemoryTracker>,
426) -> Result<RecordBatch> {
427 let mut result_fields = Vec::new();
428 let mut result_columns: Vec<ArrayRef> = Vec::new();
429
430 let ts_col = batch
432 .column_by_name(chronix_core::TIME_COLUMN)
433 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
434
435 for &field_name in field_columns {
436 let col = batch
437 .column_by_name(field_name)
438 .ok_or_else(|| QueryError::Validation(format!("column '{field_name}' not found")))?;
439
440 for func in functions {
441 let func_name = format!("{field_name}_{}", agg_fn_name(*func));
442 let value = aggregate_column_ts(col, *func, ts_col);
443 let (field, array) = agg_result_column(&func_name, &[value])?;
444 result_fields.push(field);
445 result_columns.push(array);
446 }
447 }
448
449 if result_fields.is_empty() {
450 return Err(QueryError::Validation("no columns to aggregate".into()));
451 }
452
453 let schema = Arc::new(Schema::new(result_fields));
454 let batch = RecordBatch::try_new(schema, result_columns)?;
455 if let Some(tracker) = memory_tracker {
456 tracker.try_allocate(batch.get_array_memory_size())?;
457 }
458 Ok(batch)
459}
460
461pub fn aggregate_grouped(
486 batch: &RecordBatch,
487 functions: &[AggFn],
488 field_columns: &[&str],
489 group_by: &[&str],
490 memory_tracker: Option<&crate::memory::MemoryTracker>,
491) -> Result<RecordBatch> {
492 if group_by.is_empty() {
493 return aggregate_batch(batch, functions, field_columns, memory_tracker);
494 }
495
496 let group_cols: Vec<&StringArray> = group_by
498 .iter()
499 .map(|name| {
500 batch
501 .column_by_name(name)
502 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
503 .ok_or_else(|| {
504 QueryError::Validation(format!(
505 "group-by column '{name}' not found or not string"
506 ))
507 })
508 })
509 .collect::<Result<Vec<_>>>()?;
510
511 let field_cols: Vec<&dyn Array> = field_columns
513 .iter()
514 .map(|name| {
515 batch
516 .column_by_name(name)
517 .map(std::convert::AsRef::as_ref)
518 .ok_or_else(|| QueryError::Validation(format!("column '{name}' not found")))
519 })
520 .collect::<Result<Vec<_>>>()?;
521
522 let typed_cols: Vec<TypedColumn<'_>> = field_cols
524 .iter()
525 .map(|c| TypedColumn::from_array(*c))
526 .collect();
527
528 let ts_col = batch
529 .column_by_name(chronix_core::TIME_COLUMN)
530 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
531
532 let num_fields = field_columns.len();
536
537 let mut groups: Vec<(Vec<Option<&str>>, Vec<IncrementalAccumulator>)> = Vec::new();
539 let mut group_index: std::collections::HashMap<u64, Vec<usize>> =
540 std::collections::HashMap::new();
541
542 for row in 0..batch.num_rows() {
543 let ts: Option<i64> = ts_col.and_then(|t| {
544 if t.is_null(row) {
545 None
546 } else {
547 Some(t.value(row))
548 }
549 });
550
551 let mut hasher = fnv::FnvHasher::default();
553 for col in &group_cols {
554 if col.is_null(row) {
555 std::hash::Hash::hash(&0u8, &mut hasher);
556 } else {
557 std::hash::Hash::hash(&1u8, &mut hasher);
558 std::hash::Hash::hash(col.value(row), &mut hasher);
559 }
560 }
561 let hash = std::hash::Hasher::finish(&hasher);
562
563 let key: Vec<Option<&str>> = group_cols
565 .iter()
566 .map(|col| {
567 if col.is_null(row) {
568 None
569 } else {
570 Some(col.value(row))
571 }
572 })
573 .collect();
574
575 let bucket = group_index.entry(hash).or_default();
577 if bucket.len() >= 1024 {
579 return Err(QueryError::Validation(
580 "group-by hash chain length exceeded limit (possible collision attack)".into(),
581 ));
582 }
583 let gidx = {
584 let mut found = None;
585 for &idx in bucket.iter() {
586 if groups[idx].0 == key {
587 found = Some(idx);
588 break;
589 }
590 }
591 match found {
592 Some(idx) => idx,
593 None => {
594 if let Some(tracker) = memory_tracker {
596 let estimated_group_bytes = 24 + group_by.len() * 16 + num_fields * std::mem::size_of::<IncrementalAccumulator>()
598 + 64; tracker.try_allocate(estimated_group_bytes)?;
600 }
601 let idx = groups.len();
602 let accs = (0..num_fields)
603 .map(|_| IncrementalAccumulator::new())
604 .collect();
605 groups.push((key, accs));
606 bucket.push(idx);
607 idx
608 }
609 }
610 };
611
612 for (fi, typed) in typed_cols.iter().enumerate() {
616 if let Some(v) = typed.value_num(row) {
617 groups[gidx].1[fi].update(v, ts);
618 }
619 }
620 }
621
622 let mut result_fields: Vec<Field> = group_by
624 .iter()
625 .map(|name| Field::new(*name, DataType::Utf8, true))
626 .collect();
627
628 let num_groups = groups.len();
630 let mut result_columns: Vec<ArrayRef> = Vec::new();
631
632 for col_idx in 0..group_by.len() {
634 let vals: Vec<Option<&str>> = groups.iter().map(|(key, _)| key[col_idx]).collect();
635 let arr: StringArray = vals.into_iter().collect();
636 result_columns.push(Arc::new(arr));
637 }
638
639 for (fi, &field_name) in field_columns.iter().enumerate() {
644 for func in functions {
645 let func_name = format!("{field_name}_{}", agg_fn_name(*func));
646 let vals: Vec<AggResult> = (0..num_groups)
647 .map(|gi| groups[gi].1[fi].finalize(*func))
648 .collect();
649 let (field, array) = agg_result_column(&func_name, &vals)?;
650 result_fields.push(field);
651 result_columns.push(array);
652 }
653 }
654 debug_assert_eq!(num_fields, field_columns.len());
655
656 let schema = Arc::new(Schema::new(result_fields));
657 let batch = RecordBatch::try_new(schema, result_columns)?;
658 if let Some(tracker) = memory_tracker {
659 tracker.try_allocate(batch.get_array_memory_size())?;
660 }
661 Ok(batch)
662}
663
664#[allow(clippy::cast_precision_loss)]
668fn aggregate_column_ts(col: &dyn Array, func: AggFn, timestamps: Option<&Int64Array>) -> AggResult {
669 if let Some(ts) = timestamps
672 && matches!(func, AggFn::First | AggFn::Last)
673 {
674 return first_last_by_ts(col, ts, func);
675 }
676
677 if let TypedColumn::Decimal(a, scale) = TypedColumn::from_array(col) {
680 if matches!(func, AggFn::First | AggFn::Last) {
685 let mut present = (0..a.len()).filter(|&i| !a.is_null(i));
686 let idx = if func == AggFn::First {
687 present.next()
688 } else {
689 present.next_back()
690 };
691 return idx.map_or(AggResult::Null, |i| AggResult::Decimal {
692 mantissa: a.value(i),
693 scale,
694 });
695 }
696 let mut acc = IncrementalAccumulator::new();
697 for i in 0..a.len() {
698 if !a.is_null(i) {
699 acc.update(Num::Dec(a.value(i), scale), None);
700 }
701 }
702 return acc.finalize(func);
703 }
704
705 let value = if let Some(f64_col) = col.as_any().downcast_ref::<Float64Array>() {
706 aggregate_f64(f64_col, func)
707 } else if let Some(i64_col) = col.as_any().downcast_ref::<Int64Array>() {
708 aggregate_i64(i64_col, func)
709 } else if let Some(u64_col) = col.as_any().downcast_ref::<UInt64Array>() {
710 aggregate_u64(u64_col, func)
711 } else {
712 match func {
713 AggFn::Count => Some((col.len() - col.null_count()) as f64),
714 _ => None,
715 }
716 };
717 value.map_or(AggResult::Null, AggResult::F64)
718}
719
720#[allow(clippy::cast_precision_loss)]
723fn first_last_by_ts(col: &dyn Array, ts: &Int64Array, func: AggFn) -> AggResult {
724 let find_idx = |cmp: fn(i64, i64) -> bool| -> Option<usize> {
725 let mut best_idx: Option<usize> = None;
726 let mut best_ts: i64 = 0;
727 for i in 0..ts.len() {
728 if ts.is_null(i) || col.is_null(i) {
729 continue;
730 }
731 let t = ts.value(i);
732 if best_idx.is_none() || cmp(t, best_ts) {
733 best_idx = Some(i);
734 best_ts = t;
735 }
736 }
737 best_idx
738 };
739
740 let idx = match func {
741 AggFn::First => find_idx(|t, best| t < best),
742 AggFn::Last => find_idx(|t, best| t >= best),
746 _ => return AggResult::Null,
747 };
748 let Some(idx) = idx else {
749 return AggResult::Null;
750 };
751
752 match TypedColumn::from_array(col).value_num(idx) {
754 Some(Num::F64(v)) => AggResult::F64(v),
755 Some(Num::Dec(mantissa, scale)) => AggResult::Decimal { mantissa, scale },
756 None => AggResult::Null,
757 }
758}
759
760#[must_use]
766pub fn agg_fn_name(func: AggFn) -> &'static str {
767 match func {
768 AggFn::Count => "count",
769 AggFn::Sum => "sum",
770 AggFn::Min => "min",
771 AggFn::Max => "max",
772 AggFn::Avg => "avg",
773 AggFn::First => "first",
774 AggFn::Last => "last",
775 }
776}
777
778#[derive(Clone, Copy)]
783enum TypedColumn<'a> {
784 Float64(&'a Float64Array),
785 Int64(&'a Int64Array),
786 UInt64(&'a UInt64Array),
787 Decimal(&'a Decimal128Array, u8),
789 Other,
790}
791
792impl<'a> TypedColumn<'a> {
793 fn from_array(arr: &'a dyn Array) -> Self {
794 if let Some(a) = arr.as_any().downcast_ref::<Float64Array>() {
795 Self::Float64(a)
796 } else if let Some(a) = arr.as_any().downcast_ref::<Int64Array>() {
797 Self::Int64(a)
798 } else if let Some(a) = arr.as_any().downcast_ref::<UInt64Array>() {
799 Self::UInt64(a)
800 } else if let Some(a) = arr.as_any().downcast_ref::<Decimal128Array>() {
801 Self::Decimal(a, u8::try_from(a.scale()).unwrap_or(0))
804 } else {
805 Self::Other
806 }
807 }
808
809 #[allow(clippy::cast_precision_loss)]
811 fn value_num(&self, row: usize) -> Option<Num> {
812 match self {
813 Self::Decimal(a, scale) => {
814 if a.is_null(row) {
815 None
816 } else {
817 Some(Num::Dec(a.value(row), *scale))
818 }
819 }
820 _ => self.value_f64(row).map(Num::F64),
821 }
822 }
823
824 #[allow(clippy::cast_precision_loss)]
825 fn value_f64(&self, row: usize) -> Option<f64> {
826 match self {
827 Self::Float64(a) => {
828 if a.is_null(row) {
829 None
830 } else {
831 Some(a.value(row))
832 }
833 }
834 Self::Int64(a) => {
835 if a.is_null(row) {
836 None
837 } else {
838 Some(a.value(row) as f64)
839 }
840 }
841 Self::UInt64(a) => {
842 if a.is_null(row) {
843 None
844 } else {
845 Some(a.value(row) as f64)
846 }
847 }
848 Self::Decimal(a, scale) => {
852 if a.is_null(row) {
853 None
854 } else {
855 Some(
856 a.value(row) as f64
857 / chronix_core::pow10(u32::from(*scale)).unwrap_or(1) as f64,
858 )
859 }
860 }
861 Self::Other => None,
862 }
863 }
864}
865
866#[derive(Debug, Clone)]
869struct IncrementalAccumulator {
870 count: u64,
871 sum: f64,
872 min: f64,
873 max: f64,
874 first_ts: i64,
876 first_val: f64,
877 first_set: bool,
878 last_ts: i64,
880 last_val: f64,
881 last_set: bool,
882 decimal: Option<Box<DecimalTrack>>,
884}
885
886impl IncrementalAccumulator {
887 fn new() -> Self {
888 Self {
889 count: 0,
890 sum: 0.0,
891 min: f64::INFINITY,
892 max: f64::NEG_INFINITY,
893 first_ts: i64::MAX,
894 first_val: 0.0,
895 first_set: false,
896 last_ts: i64::MIN,
897 last_val: 0.0,
898 last_set: false,
899 decimal: None,
900 }
901 }
902
903 #[allow(clippy::cast_precision_loss)]
904 fn update(&mut self, value: Num, timestamp: Option<i64>) {
905 self.count += 1;
906 match value {
907 Num::F64(v) => {
908 self.sum += v;
909 if v < self.min {
910 self.min = v;
911 }
912 if v > self.max {
913 self.max = v;
914 }
915 }
916 Num::Dec(mantissa, scale) => {
917 let track = self
918 .decimal
919 .get_or_insert_with(|| Box::new(DecimalTrack::new(scale)));
920 match track.align(mantissa, scale) {
921 Some(m) => {
922 match track.sum.checked_add(m) {
923 Some(sum) => track.sum = sum,
924 None => track.overflow = true,
925 }
926 if m < track.min {
927 track.min = m;
928 }
929 if m > track.max {
930 track.max = m;
931 }
932 }
933 None => track.overflow = true,
934 }
935 }
936 }
937 if let Some(ts) = timestamp {
941 let first = ts < self.first_ts || !self.first_set;
942 let last = ts >= self.last_ts || !self.last_set;
945 if first {
946 self.first_ts = ts;
947 self.first_set = true;
948 }
949 if last {
950 self.last_ts = ts;
951 self.last_set = true;
952 }
953 match value {
954 Num::F64(v) => {
955 if first {
956 self.first_val = v;
957 }
958 if last {
959 self.last_val = v;
960 }
961 }
962 Num::Dec(mantissa, scale) => {
963 if let Some(track) = self.decimal.as_deref_mut()
964 && let Some(m) = track.align(mantissa, scale)
965 {
966 if first {
967 track.first = m;
968 }
969 if last {
970 track.last = m;
971 }
972 }
973 }
974 }
975 }
976 }
977
978 #[allow(clippy::cast_precision_loss)]
979 fn finalize(&self, func: AggFn) -> AggResult {
980 if self.count == 0 {
981 return if func == AggFn::Count {
982 AggResult::F64(0.0)
983 } else {
984 AggResult::Null
985 };
986 }
987 if func == AggFn::Count {
991 return AggResult::F64(self.count as f64);
992 }
993 if let Some(track) = self.decimal.as_deref() {
994 return match func {
995 AggFn::Sum => track.sum_result(),
996 AggFn::Avg => track.avg_result(self.count),
997 AggFn::Min => track.at(track.min),
998 AggFn::Max => track.at(track.max),
999 AggFn::First => track.at(track.first),
1000 AggFn::Last => track.at(track.last),
1001 AggFn::Count => unreachable!("handled above"),
1002 };
1003 }
1004 match func {
1005 AggFn::Sum => AggResult::F64(self.sum),
1006 AggFn::Min => AggResult::F64(self.min),
1007 AggFn::Max => AggResult::F64(self.max),
1008 AggFn::Avg => AggResult::F64(self.sum / self.count as f64),
1009 AggFn::First => AggResult::F64(self.first_val),
1010 AggFn::Last => AggResult::F64(self.last_val),
1011 AggFn::Count => unreachable!("handled above"),
1012 }
1013 }
1014}
1015
1016#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028pub enum AggregationStrategy {
1029 Hash,
1031 Sort,
1033}
1034
1035const SORT_CARDINALITY_THRESHOLD: usize = 1024;
1040
1041#[must_use]
1047pub fn choose_strategy(estimated_cardinality: Option<usize>) -> AggregationStrategy {
1048 match estimated_cardinality {
1049 Some(c) if c <= SORT_CARDINALITY_THRESHOLD => AggregationStrategy::Sort,
1050 _ => AggregationStrategy::Hash,
1051 }
1052}
1053
1054pub fn aggregate_sorted(
1067 batch: &RecordBatch,
1068 functions: &[AggFn],
1069 field_columns: &[&str],
1070 group_by: &[&str],
1071 memory_tracker: Option<&crate::memory::MemoryTracker>,
1072) -> Result<RecordBatch> {
1073 use arrow::row::{RowConverter, SortField};
1074
1075 if group_by.is_empty() {
1076 return aggregate_batch(batch, functions, field_columns, memory_tracker);
1077 }
1078
1079 let group_arrays: Vec<ArrayRef> = group_by
1081 .iter()
1082 .map(|name| {
1083 batch.column_by_name(name).cloned().ok_or_else(|| {
1084 QueryError::Validation(format!("group-by column '{name}' not found"))
1085 })
1086 })
1087 .collect::<Result<Vec<_>>>()?;
1088
1089 let sort_fields: Vec<SortField> = group_arrays
1090 .iter()
1091 .map(|a| SortField::new(a.data_type().clone()))
1092 .collect();
1093
1094 let converter = RowConverter::new(sort_fields)?;
1095 let rows = converter.convert_columns(&group_arrays)?;
1096
1097 let mut indices: Vec<u32> = (0..batch.num_rows() as u32).collect();
1099 indices.sort_by(|&a, &b| rows.row(a as usize).cmp(&rows.row(b as usize)));
1100
1101 let field_cols: Vec<&dyn Array> = field_columns
1103 .iter()
1104 .map(|name| {
1105 batch
1106 .column_by_name(name)
1107 .map(std::convert::AsRef::as_ref)
1108 .ok_or_else(|| QueryError::Validation(format!("column '{name}' not found")))
1109 })
1110 .collect::<Result<Vec<_>>>()?;
1111 let typed_cols: Vec<TypedColumn<'_>> = field_cols
1112 .iter()
1113 .map(|c| TypedColumn::from_array(*c))
1114 .collect();
1115
1116 let ts_col = batch
1117 .column_by_name(chronix_core::TIME_COLUMN)
1118 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1119
1120 let group_cols: Vec<&StringArray> = group_by
1121 .iter()
1122 .map(|name| {
1123 batch
1124 .column_by_name(name)
1125 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1126 .ok_or_else(|| {
1127 QueryError::Validation(format!(
1128 "group-by column '{name}' not found or not string"
1129 ))
1130 })
1131 })
1132 .collect::<Result<Vec<_>>>()?;
1133
1134 let num_fields = field_columns.len();
1135 let mut groups: Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)> = Vec::new();
1136
1137 let mut run_start = 0usize;
1139 while run_start < indices.len() {
1140 let anchor_row = rows.row(indices[run_start] as usize);
1141 let mut run_end = run_start + 1;
1142 while run_end < indices.len() && rows.row(indices[run_end] as usize) == anchor_row {
1143 run_end += 1;
1144 }
1145
1146 let first_idx = indices[run_start] as usize;
1148 let key: Vec<Option<String>> = group_cols
1149 .iter()
1150 .map(|col| {
1151 if col.is_null(first_idx) {
1152 None
1153 } else {
1154 Some(col.value(first_idx).to_owned())
1155 }
1156 })
1157 .collect();
1158
1159 if let Some(tracker) = memory_tracker {
1160 let est = 24
1161 + group_by.len() * 48
1162 + num_fields * std::mem::size_of::<IncrementalAccumulator>()
1163 + 64;
1164 tracker.try_allocate(est)?;
1165 }
1166
1167 let mut accs: Vec<IncrementalAccumulator> = (0..num_fields)
1168 .map(|_| IncrementalAccumulator::new())
1169 .collect();
1170
1171 for &idx in &indices[run_start..run_end] {
1172 let row = idx as usize;
1173 let ts: Option<i64> = ts_col.and_then(|t| {
1174 if t.is_null(row) {
1175 None
1176 } else {
1177 Some(t.value(row))
1178 }
1179 });
1180 for (fi, typed) in typed_cols.iter().enumerate() {
1181 if let Some(v) = typed.value_num(row) {
1182 accs[fi].update(v, ts);
1183 }
1184 }
1185 }
1186
1187 groups.push((key, accs));
1188 run_start = run_end;
1189 }
1190
1191 let mut result_fields: Vec<Field> = group_by
1193 .iter()
1194 .map(|name| Field::new(*name, DataType::Utf8, true))
1195 .collect();
1196 let num_groups = groups.len();
1197 let mut result_columns: Vec<ArrayRef> = Vec::new();
1198
1199 for col_idx in 0..group_by.len() {
1200 let vals: Vec<Option<&str>> = groups
1201 .iter()
1202 .map(|(key, _)| key[col_idx].as_deref())
1203 .collect();
1204 let arr: StringArray = vals.into_iter().collect();
1205 result_columns.push(Arc::new(arr));
1206 }
1207
1208 for (fi, &field_name) in field_columns.iter().enumerate() {
1209 for func in functions {
1210 let func_name = format!("{field_name}_{}", agg_fn_name(*func));
1211 let vals: Vec<AggResult> = (0..num_groups)
1212 .map(|gi| groups[gi].1[fi].finalize(*func))
1213 .collect();
1214 let (field, array) = agg_result_column(&func_name, &vals)?;
1215 result_fields.push(field);
1216 result_columns.push(array);
1217 }
1218 }
1219 debug_assert_eq!(num_fields, field_columns.len());
1220
1221 let schema = Arc::new(Schema::new(result_fields));
1222 let batch = RecordBatch::try_new(schema, result_columns)?;
1223 if let Some(tracker) = memory_tracker {
1224 tracker.try_allocate(batch.get_array_memory_size())?;
1225 }
1226 Ok(batch)
1227}
1228
1229pub fn aggregate_grouped_streaming(
1240 batches: &[RecordBatch],
1241 functions: &[AggFn],
1242 field_columns: &[&str],
1243 group_by: &[&str],
1244 memory_tracker: Option<&crate::memory::MemoryTracker>,
1245) -> Result<RecordBatch> {
1246 if batches.is_empty() {
1247 return Err(QueryError::Validation("no batches to aggregate".into()));
1248 }
1249 let mut agg = StreamingAggregator::new(functions, field_columns, group_by);
1250 for batch in batches {
1251 agg.push(batch, memory_tracker)?;
1252 }
1253 agg.finish(memory_tracker)
1254}
1255
1256pub struct StreamingAggregator {
1268 functions: Vec<AggFn>,
1269 field_columns: Vec<String>,
1270 group_by: Vec<String>,
1271 state: AggState,
1272}
1273
1274enum AggState {
1275 Grouped {
1277 groups: Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)>,
1278 index: std::collections::HashMap<u64, Vec<usize>>,
1280 },
1281 Ungrouped(UngroupedState),
1283}
1284
1285struct UngroupedState {
1287 counts: Vec<u64>,
1288 sums: Vec<f64>,
1289 mins: Vec<f64>,
1290 maxs: Vec<f64>,
1291 first_ts: Vec<i64>,
1292 first_val: Vec<f64>,
1293 first_set: Vec<bool>,
1294 last_ts: Vec<i64>,
1295 last_val: Vec<f64>,
1296 last_set: Vec<bool>,
1297 needs_first_last: bool,
1298 decimals: Vec<Option<Box<DecimalTrack>>>,
1300}
1301
1302impl StreamingAggregator {
1303 #[must_use]
1306 pub fn new(functions: &[AggFn], field_columns: &[&str], group_by: &[&str]) -> Self {
1307 let num_fields = field_columns.len();
1308 let state = if group_by.is_empty() {
1309 AggState::Ungrouped(UngroupedState {
1310 counts: vec![0; num_fields],
1311 sums: vec![0.0; num_fields],
1312 mins: vec![f64::INFINITY; num_fields],
1313 maxs: vec![f64::NEG_INFINITY; num_fields],
1314 first_ts: vec![i64::MAX; num_fields],
1315 first_val: vec![0.0; num_fields],
1316 first_set: vec![false; num_fields],
1317 last_ts: vec![i64::MIN; num_fields],
1318 last_val: vec![0.0; num_fields],
1319 last_set: vec![false; num_fields],
1320 needs_first_last: functions
1321 .iter()
1322 .any(|f| matches!(f, AggFn::First | AggFn::Last)),
1323 decimals: (0..num_fields).map(|_| None).collect(),
1324 })
1325 } else {
1326 AggState::Grouped {
1327 groups: Vec::new(),
1328 index: std::collections::HashMap::new(),
1329 }
1330 };
1331 Self {
1332 functions: functions.to_vec(),
1333 field_columns: field_columns.iter().map(|s| (*s).to_string()).collect(),
1334 group_by: group_by.iter().map(|s| (*s).to_string()).collect(),
1335 state,
1336 }
1337 }
1338
1339 pub fn push(
1346 &mut self,
1347 batch: &RecordBatch,
1348 memory_tracker: Option<&crate::memory::MemoryTracker>,
1349 ) -> Result<()> {
1350 let field_columns: Vec<&str> = self.field_columns.iter().map(String::as_str).collect();
1351 match &mut self.state {
1352 AggState::Ungrouped(st) => {
1353 st.push(batch, &field_columns);
1354 Ok(())
1355 }
1356 AggState::Grouped { groups, index } => {
1357 let group_by: Vec<&str> = self.group_by.iter().map(String::as_str).collect();
1358 push_grouped(
1359 batch,
1360 &field_columns,
1361 &group_by,
1362 groups,
1363 index,
1364 memory_tracker,
1365 )
1366 }
1367 }
1368 }
1369
1370 pub fn finish(
1377 self,
1378 memory_tracker: Option<&crate::memory::MemoryTracker>,
1379 ) -> Result<RecordBatch> {
1380 let field_columns: Vec<&str> = self.field_columns.iter().map(String::as_str).collect();
1381 let batch = match self.state {
1382 AggState::Ungrouped(st) => st.finish(&self.functions, &field_columns)?,
1383 AggState::Grouped { groups, .. } => {
1384 let group_by: Vec<&str> = self.group_by.iter().map(String::as_str).collect();
1385 finish_grouped(&groups, &self.functions, &field_columns, &group_by)?
1386 }
1387 };
1388 if let Some(tracker) = memory_tracker {
1389 tracker.try_allocate(batch.get_array_memory_size())?;
1390 }
1391 Ok(batch)
1392 }
1393}
1394
1395fn push_grouped(
1397 batch: &RecordBatch,
1398 field_columns: &[&str],
1399 group_by: &[&str],
1400 groups: &mut Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)>,
1401 group_index: &mut std::collections::HashMap<u64, Vec<usize>>,
1402 memory_tracker: Option<&crate::memory::MemoryTracker>,
1403) -> Result<()> {
1404 let num_fields = field_columns.len();
1405
1406 let group_cols: Vec<Option<&StringArray>> = group_by
1407 .iter()
1408 .map(|name| {
1409 batch
1410 .column_by_name(name)
1411 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1412 })
1413 .collect();
1414
1415 let ts_col = batch
1416 .column_by_name(chronix_core::TIME_COLUMN)
1417 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1418
1419 let typed_cols: Vec<Option<TypedColumn<'_>>> = field_columns
1421 .iter()
1422 .map(|name| {
1423 batch
1424 .column_by_name(name)
1425 .map(|c| TypedColumn::from_array(c.as_ref()))
1426 })
1427 .collect();
1428
1429 for row in 0..batch.num_rows() {
1430 let ts: Option<i64> = ts_col.and_then(|t| {
1431 if t.is_null(row) {
1432 None
1433 } else {
1434 Some(t.value(row))
1435 }
1436 });
1437
1438 let mut hasher = fnv::FnvHasher::default();
1440 for col in &group_cols {
1441 match col {
1442 Some(c) if !c.is_null(row) => {
1443 std::hash::Hash::hash(&1u8, &mut hasher);
1444 std::hash::Hash::hash(c.value(row), &mut hasher);
1445 }
1446 _ => std::hash::Hash::hash(&0u8, &mut hasher),
1447 }
1448 }
1449 let hash = std::hash::Hasher::finish(&hasher);
1450
1451 let key: Vec<Option<&str>> = group_cols
1452 .iter()
1453 .map(|col| match col {
1454 Some(c) if !c.is_null(row) => Some(c.value(row)),
1455 _ => None,
1456 })
1457 .collect();
1458
1459 let bucket = group_index.entry(hash).or_default();
1462 let gidx = {
1463 let mut found = None;
1464 for &idx in bucket.iter() {
1465 let existing: Vec<Option<&str>> =
1466 groups[idx].0.iter().map(|o| o.as_deref()).collect();
1467 if existing == key {
1468 found = Some(idx);
1469 break;
1470 }
1471 }
1472 match found {
1473 Some(idx) => idx,
1474 None => {
1475 if let Some(tracker) = memory_tracker {
1477 let estimated_bytes = group_by.len() * 48 + num_fields * std::mem::size_of::<IncrementalAccumulator>()
1479 + 48; tracker.try_allocate(estimated_bytes)?;
1481 }
1482 let idx = groups.len();
1483 let owned_key: Vec<Option<String>> =
1484 key.iter().map(|o| o.map(String::from)).collect();
1485 let accs = (0..num_fields)
1486 .map(|_| IncrementalAccumulator::new())
1487 .collect();
1488 groups.push((owned_key, accs));
1489 bucket.push(idx);
1490 idx
1491 }
1492 }
1493 };
1494
1495 for (fi, typed) in typed_cols.iter().enumerate() {
1498 if let Some(v) = typed.and_then(|tc| tc.value_num(row)) {
1499 groups[gidx].1[fi].update(v, ts);
1500 }
1501 }
1502 }
1503
1504 Ok(())
1505}
1506
1507fn finish_grouped(
1509 groups: &[(Vec<Option<String>>, Vec<IncrementalAccumulator>)],
1510 functions: &[AggFn],
1511 field_columns: &[&str],
1512 group_by: &[&str],
1513) -> Result<RecordBatch> {
1514 let num_groups = groups.len();
1515 let mut result_fields: Vec<Field> = group_by
1516 .iter()
1517 .map(|name| Field::new(*name, DataType::Utf8, true))
1518 .collect();
1519 let mut result_columns: Vec<ArrayRef> = Vec::new();
1520
1521 for col_idx in 0..group_by.len() {
1523 let vals: Vec<Option<&str>> = groups
1524 .iter()
1525 .map(|(key, _)| key[col_idx].as_deref())
1526 .collect();
1527 let arr: StringArray = vals.into_iter().collect();
1528 result_columns.push(Arc::new(arr));
1529 }
1530
1531 for (fi, &field_name) in field_columns.iter().enumerate() {
1533 for func in functions {
1534 let name = format!("{field_name}_{}", agg_fn_name(*func));
1535 let vals: Vec<AggResult> = (0..num_groups)
1536 .map(|gi| groups[gi].1[fi].finalize(*func))
1537 .collect();
1538 let (field, array) = agg_result_column(&name, &vals)?;
1539 result_fields.push(field);
1540 result_columns.push(array);
1541 }
1542 }
1543
1544 let schema = Arc::new(Schema::new(result_fields));
1545 RecordBatch::try_new(schema, result_columns).map_err(Into::into)
1546}
1547
1548impl UngroupedState {
1549 #[allow(clippy::cast_precision_loss)]
1553 fn push(&mut self, batch: &RecordBatch, field_columns: &[&str]) {
1554 let ts_col = batch
1555 .column_by_name(chronix_core::TIME_COLUMN)
1556 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1557
1558 for (fi, &field_name) in field_columns.iter().enumerate() {
1559 let Some(col) = batch.column_by_name(field_name) else {
1560 continue;
1561 };
1562
1563 self.counts[fi] += (col.len() - col.null_count()) as u64;
1565
1566 let typed = TypedColumn::from_array(col.as_ref());
1568 match typed {
1569 TypedColumn::Float64(a) => {
1570 if let Some(s) = arrow_agg::sum(a) {
1571 self.sums[fi] += s;
1572 }
1573 if let Some(m) = arrow_agg::min(a)
1574 && m < self.mins[fi]
1575 {
1576 self.mins[fi] = m;
1577 }
1578 if let Some(m) = arrow_agg::max(a)
1579 && m > self.maxs[fi]
1580 {
1581 self.maxs[fi] = m;
1582 }
1583 }
1584 TypedColumn::Int64(a) => {
1585 if let Some(s) = arrow_agg::sum(a) {
1586 self.sums[fi] += s as f64;
1587 }
1588 if let Some(m) = arrow_agg::min(a)
1589 && (m as f64) < self.mins[fi]
1590 {
1591 self.mins[fi] = m as f64;
1592 }
1593 if let Some(m) = arrow_agg::max(a)
1594 && (m as f64) > self.maxs[fi]
1595 {
1596 self.maxs[fi] = m as f64;
1597 }
1598 }
1599 TypedColumn::UInt64(a) => {
1600 if let Some(s) = arrow_agg::sum(a) {
1601 self.sums[fi] += s as f64;
1602 }
1603 if let Some(m) = arrow_agg::min(a)
1604 && (m as f64) < self.mins[fi]
1605 {
1606 self.mins[fi] = m as f64;
1607 }
1608 if let Some(m) = arrow_agg::max(a)
1609 && (m as f64) > self.maxs[fi]
1610 {
1611 self.maxs[fi] = m as f64;
1612 }
1613 }
1614 TypedColumn::Decimal(a, scale) => {
1615 let track =
1619 self.decimals[fi].get_or_insert_with(|| Box::new(DecimalTrack::new(scale)));
1620 for i in 0..a.len() {
1621 if a.is_null(i) {
1622 continue;
1623 }
1624 match track.align(a.value(i), scale) {
1625 Some(m) => {
1626 match track.sum.checked_add(m) {
1627 Some(sum) => track.sum = sum,
1628 None => track.overflow = true,
1629 }
1630 if m < track.min {
1631 track.min = m;
1632 }
1633 if m > track.max {
1634 track.max = m;
1635 }
1636 }
1637 None => track.overflow = true,
1638 }
1639 }
1640 }
1641 TypedColumn::Other => {}
1642 }
1643
1644 if self.needs_first_last
1648 && let Some(ts) = ts_col
1649 {
1650 let n = ts.len().min(col.len());
1651 for i in 0..n {
1652 if ts.is_null(i) || col.is_null(i) {
1653 continue;
1654 }
1655 let t = ts.value(i);
1656 if let Some(v) = typed.value_num(i) {
1657 let first = t < self.first_ts[fi] || !self.first_set[fi];
1658 let last = t >= self.last_ts[fi] || !self.last_set[fi];
1660 if first {
1661 self.first_ts[fi] = t;
1662 self.first_set[fi] = true;
1663 }
1664 if last {
1665 self.last_ts[fi] = t;
1666 self.last_set[fi] = true;
1667 }
1668 match v {
1669 Num::F64(x) => {
1670 if first {
1671 self.first_val[fi] = x;
1672 }
1673 if last {
1674 self.last_val[fi] = x;
1675 }
1676 }
1677 Num::Dec(mantissa, scale) => {
1678 let track = self.decimals[fi]
1679 .get_or_insert_with(|| Box::new(DecimalTrack::new(scale)));
1680 if let Some(m) = track.align(mantissa, scale) {
1681 if first {
1682 track.first = m;
1683 }
1684 if last {
1685 track.last = m;
1686 }
1687 }
1688 }
1689 }
1690 }
1691 }
1692 }
1693 }
1694 }
1695
1696 #[allow(clippy::cast_precision_loss)]
1698 fn finalize(&self, fi: usize, func: AggFn) -> AggResult {
1699 if self.counts[fi] == 0 {
1700 return if func == AggFn::Count {
1701 AggResult::F64(0.0)
1702 } else {
1703 AggResult::Null
1704 };
1705 }
1706 if func == AggFn::Count {
1708 return AggResult::F64(self.counts[fi] as f64);
1709 }
1710 if let Some(track) = self.decimals[fi].as_deref() {
1711 return match func {
1712 AggFn::Sum => track.sum_result(),
1713 AggFn::Avg => track.avg_result(self.counts[fi]),
1714 AggFn::Min => track.at(track.min),
1715 AggFn::Max => track.at(track.max),
1716 AggFn::First => {
1717 if self.first_set[fi] {
1718 track.at(track.first)
1719 } else {
1720 AggResult::Null
1721 }
1722 }
1723 AggFn::Last => {
1724 if self.last_set[fi] {
1725 track.at(track.last)
1726 } else {
1727 AggResult::Null
1728 }
1729 }
1730 AggFn::Count => unreachable!("handled above"),
1731 };
1732 }
1733 match func {
1734 AggFn::Sum => AggResult::F64(self.sums[fi]),
1735 AggFn::Min => AggResult::F64(self.mins[fi]),
1736 AggFn::Max => AggResult::F64(self.maxs[fi]),
1737 AggFn::Avg => AggResult::F64(self.sums[fi] / self.counts[fi] as f64),
1738 AggFn::First => {
1739 if self.first_set[fi] {
1740 AggResult::F64(self.first_val[fi])
1741 } else {
1742 AggResult::Null
1743 }
1744 }
1745 AggFn::Last => {
1746 if self.last_set[fi] {
1747 AggResult::F64(self.last_val[fi])
1748 } else {
1749 AggResult::Null
1750 }
1751 }
1752 AggFn::Count => unreachable!("handled above"),
1753 }
1754 }
1755
1756 #[allow(clippy::cast_precision_loss)]
1757 fn finish(&self, functions: &[AggFn], field_columns: &[&str]) -> Result<RecordBatch> {
1758 let mut result_fields = Vec::new();
1759 let mut result_columns: Vec<ArrayRef> = Vec::new();
1760 for (fi, &field_name) in field_columns.iter().enumerate() {
1761 for func in functions {
1762 let name = format!("{field_name}_{}", agg_fn_name(*func));
1763 let value = self.finalize(fi, *func);
1764 let (field, array) = agg_result_column(&name, &[value])?;
1765 result_fields.push(field);
1766 result_columns.push(array);
1767 }
1768 }
1769
1770 let schema = Arc::new(Schema::new(result_fields));
1771 RecordBatch::try_new(schema, result_columns).map_err(Into::into)
1772 }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777 use super::*;
1778
1779 #[test]
1782 fn first_and_last_over_a_decimal_column_without_timestamps() {
1783 let col: ArrayRef = Arc::new(
1788 Decimal128Array::from(vec![Some(111_i128), None, Some(333)])
1789 .with_precision_and_scale(38, 2)
1790 .unwrap(),
1791 );
1792 assert_eq!(
1793 aggregate_column_ts(col.as_ref(), AggFn::First, None),
1794 AggResult::Decimal {
1795 mantissa: 111,
1796 scale: 2
1797 }
1798 );
1799 assert_eq!(
1800 aggregate_column_ts(col.as_ref(), AggFn::Last, None),
1801 AggResult::Decimal {
1802 mantissa: 333,
1803 scale: 2
1804 }
1805 );
1806 let empty: ArrayRef = Arc::new(
1808 Decimal128Array::from(vec![None::<i128>, None])
1809 .with_precision_and_scale(38, 2)
1810 .unwrap(),
1811 );
1812 assert_eq!(
1813 aggregate_column_ts(empty.as_ref(), AggFn::First, None),
1814 AggResult::Null
1815 );
1816 }
1817
1818 #[test]
1819 fn a_decimal_sum_that_overflows_38_digits_is_null_not_wrapped() {
1820 let widest = 99_999_999_999_999_999_999_999_999_999_999_999_999_i128;
1822 let col: ArrayRef = Arc::new(
1823 Decimal128Array::from(vec![Some(widest), Some(widest)])
1824 .with_precision_and_scale(38, 0)
1825 .unwrap(),
1826 );
1827 assert_eq!(
1828 aggregate_column_ts(col.as_ref(), AggFn::Sum, None),
1829 AggResult::Null
1830 );
1831 assert_eq!(
1833 aggregate_column_ts(col.as_ref(), AggFn::Max, None),
1834 AggResult::Decimal {
1835 mantissa: widest,
1836 scale: 0
1837 }
1838 );
1839 }
1840
1841 #[test]
1842 fn a_decimal_column_at_two_scales_folds_at_the_wider_one() {
1843 let mut acc = IncrementalAccumulator::new();
1847 acc.update(Num::Dec(150, 2), Some(1)); acc.update(Num::Dec(15_000, 4), Some(2)); assert_eq!(
1850 acc.finalize(AggFn::Sum),
1851 AggResult::Decimal {
1852 mantissa: 30_000,
1853 scale: 4
1854 }
1855 );
1856 let mut acc = IncrementalAccumulator::new();
1858 acc.update(Num::Dec(15_000, 4), Some(1));
1859 acc.update(Num::Dec(150, 2), Some(2));
1860 assert_eq!(
1861 acc.finalize(AggFn::Sum),
1862 AggResult::Decimal {
1863 mantissa: 30_000,
1864 scale: 4
1865 }
1866 );
1867 }
1868
1869 #[test]
1870 fn the_average_of_decimals_rounds_half_away_from_zero() {
1871 let mut acc = IncrementalAccumulator::new();
1872 for m in [100_i128, 100, 200] {
1873 acc.update(Num::Dec(m, 2), Some(1));
1874 }
1875 assert_eq!(
1877 acc.finalize(AggFn::Avg),
1878 AggResult::Decimal {
1879 mantissa: 133_333_333,
1880 scale: 8
1881 }
1882 );
1883 let mut acc = IncrementalAccumulator::new();
1885 for m in [-100_i128, -100, -200] {
1886 acc.update(Num::Dec(m, 2), Some(1));
1887 }
1888 assert_eq!(
1889 acc.finalize(AggFn::Avg),
1890 AggResult::Decimal {
1891 mantissa: -133_333_333,
1892 scale: 8
1893 }
1894 );
1895 }
1896
1897 #[test]
1898 fn a_count_of_decimals_is_a_float_column_not_a_decimal_one() {
1899 let results = [AggResult::F64(3.0)];
1900 let (field, _) = agg_result_column("v_count", &results).unwrap();
1901 assert_eq!(field.data_type(), &DataType::Float64);
1902 let results = [AggResult::Decimal {
1904 mantissa: 30,
1905 scale: 2,
1906 }];
1907 let (field, array) = agg_result_column("v_sum", &results).unwrap();
1908 assert_eq!(field.data_type(), &DataType::Decimal128(38, 2));
1909 assert_eq!(array.len(), 1);
1910 }
1911
1912 fn test_batch() -> RecordBatch {
1913 let schema = Arc::new(Schema::new(vec![
1914 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1915 Field::new("host", DataType::Utf8, false),
1916 Field::new("value", DataType::Float64, false),
1917 ]));
1918
1919 let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
1920 let hosts = Arc::new(StringArray::from(vec!["a", "a", "b", "b", "a"]));
1921 let values = Arc::new(Float64Array::from(vec![10.0, 20.0, 30.0, 40.0, 50.0]));
1922
1923 RecordBatch::try_new(schema, vec![timestamps, hosts, values]).unwrap()
1924 }
1925
1926 #[test]
1927 fn aggregate_count() {
1928 let batch = test_batch();
1929 let result = aggregate_batch(&batch, &[AggFn::Count], &["value"], None).unwrap();
1930 assert_eq!(result.num_rows(), 1);
1931 let col = result
1932 .column(0)
1933 .as_any()
1934 .downcast_ref::<Float64Array>()
1935 .unwrap();
1936 assert!((col.value(0) - 5.0).abs() < f64::EPSILON);
1937 }
1938
1939 #[test]
1940 fn aggregate_sum() {
1941 let batch = test_batch();
1942 let result = aggregate_batch(&batch, &[AggFn::Sum], &["value"], None).unwrap();
1943 let col = result
1944 .column(0)
1945 .as_any()
1946 .downcast_ref::<Float64Array>()
1947 .unwrap();
1948 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
1949 }
1950
1951 #[test]
1952 fn aggregate_min_max() {
1953 let batch = test_batch();
1954 let result = aggregate_batch(&batch, &[AggFn::Min, AggFn::Max], &["value"], None).unwrap();
1955 let min_col = result
1956 .column(0)
1957 .as_any()
1958 .downcast_ref::<Float64Array>()
1959 .unwrap();
1960 let max_col = result
1961 .column(1)
1962 .as_any()
1963 .downcast_ref::<Float64Array>()
1964 .unwrap();
1965 assert!((min_col.value(0) - 10.0).abs() < f64::EPSILON);
1966 assert!((max_col.value(0) - 50.0).abs() < f64::EPSILON);
1967 }
1968
1969 #[test]
1970 fn aggregate_avg() {
1971 let batch = test_batch();
1972 let result = aggregate_batch(&batch, &[AggFn::Avg], &["value"], None).unwrap();
1973 let col = result
1974 .column(0)
1975 .as_any()
1976 .downcast_ref::<Float64Array>()
1977 .unwrap();
1978 assert!((col.value(0) - 30.0).abs() < f64::EPSILON);
1979 }
1980
1981 #[test]
1982 fn aggregate_first_last() {
1983 let batch = test_batch();
1984 let result =
1985 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
1986 let first = result
1987 .column(0)
1988 .as_any()
1989 .downcast_ref::<Float64Array>()
1990 .unwrap();
1991 let last = result
1992 .column(1)
1993 .as_any()
1994 .downcast_ref::<Float64Array>()
1995 .unwrap();
1996 assert!((first.value(0) - 10.0).abs() < f64::EPSILON);
1997 assert!((last.value(0) - 50.0).abs() < f64::EPSILON);
1998 }
1999
2000 #[test]
2001 fn aggregate_grouped_by_host() {
2002 let batch = test_batch();
2003 let result = aggregate_grouped(
2004 &batch,
2005 &[AggFn::Sum, AggFn::Count],
2006 &["value"],
2007 &["host"],
2008 None,
2009 )
2010 .unwrap();
2011
2012 assert_eq!(result.num_rows(), 2); let hosts = result
2015 .column(0)
2016 .as_any()
2017 .downcast_ref::<StringArray>()
2018 .unwrap();
2019 let sums = result
2020 .column(1)
2021 .as_any()
2022 .downcast_ref::<Float64Array>()
2023 .unwrap();
2024 let counts = result
2025 .column(2)
2026 .as_any()
2027 .downcast_ref::<Float64Array>()
2028 .unwrap();
2029
2030 assert_eq!(hosts.value(0), "a");
2032 assert!((sums.value(0) - 80.0).abs() < f64::EPSILON); assert!((counts.value(0) - 3.0).abs() < f64::EPSILON);
2034
2035 assert_eq!(hosts.value(1), "b");
2036 assert!((sums.value(1) - 70.0).abs() < f64::EPSILON); assert!((counts.value(1) - 2.0).abs() < f64::EPSILON);
2038 }
2039
2040 #[test]
2041 fn all_functions_on_known_data() {
2042 let batch = test_batch();
2043 let result = aggregate_batch(
2044 &batch,
2045 &[
2046 AggFn::Count,
2047 AggFn::Sum,
2048 AggFn::Min,
2049 AggFn::Max,
2050 AggFn::Avg,
2051 AggFn::First,
2052 AggFn::Last,
2053 ],
2054 &["value"],
2055 None,
2056 )
2057 .unwrap();
2058
2059 assert_eq!(result.num_columns(), 7);
2060 let cols: Vec<f64> = (0..7)
2061 .map(|i| {
2062 result
2063 .column(i)
2064 .as_any()
2065 .downcast_ref::<Float64Array>()
2066 .unwrap()
2067 .value(0)
2068 })
2069 .collect();
2070
2071 assert!((cols[0] - 5.0).abs() < f64::EPSILON); assert!((cols[1] - 150.0).abs() < f64::EPSILON); assert!((cols[2] - 10.0).abs() < f64::EPSILON); assert!((cols[3] - 50.0).abs() < f64::EPSILON); assert!((cols[4] - 30.0).abs() < f64::EPSILON); assert!((cols[5] - 10.0).abs() < f64::EPSILON); assert!((cols[6] - 50.0).abs() < f64::EPSILON); }
2079
2080 fn u64_batch() -> RecordBatch {
2083 let schema = Arc::new(Schema::new(vec![
2084 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2085 Field::new("count", DataType::UInt64, false),
2086 ]));
2087 let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
2088 let counts = Arc::new(UInt64Array::from(vec![10u64, 20, 30, 40, 50]));
2089 RecordBatch::try_new(schema, vec![timestamps, counts]).unwrap()
2090 }
2091
2092 #[test]
2093 fn aggregate_u64_sum() {
2094 let batch = u64_batch();
2095 let result = aggregate_batch(&batch, &[AggFn::Sum], &["count"], None).unwrap();
2096 let col = result
2097 .column(0)
2098 .as_any()
2099 .downcast_ref::<Float64Array>()
2100 .unwrap();
2101 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
2102 }
2103
2104 #[test]
2105 fn aggregate_u64_min_max() {
2106 let batch = u64_batch();
2107 let result = aggregate_batch(&batch, &[AggFn::Min, AggFn::Max], &["count"], None).unwrap();
2108 let min_col = result
2109 .column(0)
2110 .as_any()
2111 .downcast_ref::<Float64Array>()
2112 .unwrap();
2113 let max_col = result
2114 .column(1)
2115 .as_any()
2116 .downcast_ref::<Float64Array>()
2117 .unwrap();
2118 assert!((min_col.value(0) - 10.0).abs() < f64::EPSILON);
2119 assert!((max_col.value(0) - 50.0).abs() < f64::EPSILON);
2120 }
2121
2122 #[test]
2123 fn aggregate_u64_avg() {
2124 let batch = u64_batch();
2125 let result = aggregate_batch(&batch, &[AggFn::Avg], &["count"], None).unwrap();
2126 let col = result
2127 .column(0)
2128 .as_any()
2129 .downcast_ref::<Float64Array>()
2130 .unwrap();
2131 assert!((col.value(0) - 30.0).abs() < f64::EPSILON);
2132 }
2133
2134 #[test]
2135 fn aggregate_u64_first_last() {
2136 let batch = u64_batch();
2137 let result =
2138 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["count"], None).unwrap();
2139 let first = result
2140 .column(0)
2141 .as_any()
2142 .downcast_ref::<Float64Array>()
2143 .unwrap();
2144 let last = result
2145 .column(1)
2146 .as_any()
2147 .downcast_ref::<Float64Array>()
2148 .unwrap();
2149 assert!((first.value(0) - 10.0).abs() < f64::EPSILON);
2150 assert!((last.value(0) - 50.0).abs() < f64::EPSILON);
2151 }
2152
2153 #[test]
2154 fn aggregate_u64_count() {
2155 let batch = u64_batch();
2156 let result = aggregate_batch(&batch, &[AggFn::Count], &["count"], None).unwrap();
2157 let col = result
2158 .column(0)
2159 .as_any()
2160 .downcast_ref::<Float64Array>()
2161 .unwrap();
2162 assert!((col.value(0) - 5.0).abs() < f64::EPSILON);
2163 }
2164
2165 #[test]
2166 fn aggregate_respects_memory_tracker() {
2167 let batch = test_batch();
2168 let tracker = crate::memory::MemoryTracker::new(1_000_000);
2169 let result = aggregate_batch(&batch, &[AggFn::Sum], &["value"], Some(&tracker)).unwrap();
2170 assert!(tracker.allocated() > 0);
2171 let col = result
2172 .column(0)
2173 .as_any()
2174 .downcast_ref::<Float64Array>()
2175 .unwrap();
2176 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
2177 }
2178
2179 #[test]
2180 fn aggregate_exceeds_memory_budget() {
2181 let batch = test_batch();
2182 let tracker = crate::memory::MemoryTracker::new(1); let err = aggregate_batch(&batch, &[AggFn::Sum], &["value"], Some(&tracker)).unwrap_err();
2184 assert!(matches!(
2185 err,
2186 crate::error::QueryError::QueryMemoryExceeded { .. }
2187 ));
2188 }
2189
2190 #[test]
2191 fn grouped_aggregate_respects_memory_tracker() {
2192 let batch = test_batch();
2193 let tracker = crate::memory::MemoryTracker::new(1_000_000);
2194 let result =
2195 aggregate_grouped(&batch, &[AggFn::Sum], &["value"], &["host"], Some(&tracker))
2196 .unwrap();
2197 assert!(tracker.allocated() > 0);
2198 assert_eq!(result.num_rows(), 2);
2199 }
2200
2201 #[test]
2204 fn first_last_out_of_order_timestamps() {
2205 let schema = Arc::new(Schema::new(vec![
2206 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2207 Field::new("host", DataType::Utf8, false),
2208 Field::new("value", DataType::Float64, false),
2209 ]));
2210 let ts = Int64Array::from(vec![300, 100, 500, 200, 400]);
2211 let host = StringArray::from(vec!["a", "a", "a", "a", "a"]);
2212 let value = Float64Array::from(vec![3.0, 1.0, 5.0, 2.0, 4.0]);
2213 let batch =
2214 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2215 .unwrap();
2216
2217 let result =
2218 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
2219 let first = result
2220 .column(0)
2221 .as_any()
2222 .downcast_ref::<Float64Array>()
2223 .unwrap();
2224 let last = result
2225 .column(1)
2226 .as_any()
2227 .downcast_ref::<Float64Array>()
2228 .unwrap();
2229 assert!(
2231 (first.value(0) - 1.0).abs() < f64::EPSILON,
2232 "First should be 1.0, got {}",
2233 first.value(0)
2234 );
2235 assert!(
2237 (last.value(0) - 5.0).abs() < f64::EPSILON,
2238 "Last should be 5.0, got {}",
2239 last.value(0)
2240 );
2241 }
2242
2243 #[test]
2245 fn first_last_timestamp_ties() {
2246 let schema = Arc::new(Schema::new(vec![
2247 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2248 Field::new("host", DataType::Utf8, false),
2249 Field::new("value", DataType::Float64, false),
2250 ]));
2251 let ts = Int64Array::from(vec![100, 200, 100, 200]);
2253 let host = StringArray::from(vec!["a", "a", "a", "a"]);
2254 let value = Float64Array::from(vec![1.0, 2.0, 3.0, 4.0]);
2255 let batch =
2256 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2257 .unwrap();
2258
2259 let result =
2260 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
2261 let first = result
2262 .column(0)
2263 .as_any()
2264 .downcast_ref::<Float64Array>()
2265 .unwrap();
2266 let last = result
2267 .column(1)
2268 .as_any()
2269 .downcast_ref::<Float64Array>()
2270 .unwrap();
2271 assert!((first.value(0) - 1.0).abs() < f64::EPSILON);
2273 assert!((last.value(0) - 4.0).abs() < f64::EPSILON);
2275 }
2276
2277 #[test]
2279 fn grouped_first_last_by_timestamp() {
2280 let schema = Arc::new(Schema::new(vec![
2281 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2282 Field::new("host", DataType::Utf8, false),
2283 Field::new("value", DataType::Float64, false),
2284 ]));
2285 let ts = Int64Array::from(vec![200, 100, 300, 100, 200]);
2288 let host = StringArray::from(vec!["a", "a", "b", "b", "b"]);
2289 let value = Float64Array::from(vec![20.0, 10.0, 30.0, 10.0, 20.0]);
2290 let batch =
2291 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2292 .unwrap();
2293
2294 let result = aggregate_grouped(
2295 &batch,
2296 &[AggFn::First, AggFn::Last],
2297 &["value"],
2298 &["host"],
2299 None,
2300 )
2301 .unwrap();
2302
2303 let host_col = result.column_by_name("host").unwrap();
2305 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
2306 let first_col = result.column_by_name("value_first").unwrap();
2307 let first_arr = first_col.as_any().downcast_ref::<Float64Array>().unwrap();
2308 let last_col = result.column_by_name("value_last").unwrap();
2309 let last_arr = last_col.as_any().downcast_ref::<Float64Array>().unwrap();
2310
2311 for i in 0..result.num_rows() {
2312 match host_arr.value(i) {
2313 "a" => {
2314 assert!(
2315 (first_arr.value(i) - 10.0).abs() < f64::EPSILON,
2316 "host=a first"
2317 );
2318 assert!(
2319 (last_arr.value(i) - 20.0).abs() < f64::EPSILON,
2320 "host=a last"
2321 );
2322 }
2323 "b" => {
2324 assert!(
2325 (first_arr.value(i) - 10.0).abs() < f64::EPSILON,
2326 "host=b first"
2327 );
2328 assert!(
2329 (last_arr.value(i) - 30.0).abs() < f64::EPSILON,
2330 "host=b last"
2331 );
2332 }
2333 other => panic!("unexpected host: {other}"),
2334 }
2335 }
2336 }
2337
2338 #[test]
2340 fn grouped_last_timestamp_tie_breaking() {
2341 let schema = Arc::new(Schema::new(vec![
2342 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2343 Field::new("host", DataType::Utf8, false),
2344 Field::new("value", DataType::Float64, false),
2345 ]));
2346 let ts = Int64Array::from(vec![100, 100]);
2348 let host = StringArray::from(vec!["a", "a"]);
2349 let value = Float64Array::from(vec![1.0, 2.0]);
2350 let batch =
2351 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2352 .unwrap();
2353
2354 let result =
2355 aggregate_grouped(&batch, &[AggFn::Last], &["value"], &["host"], None).unwrap();
2356
2357 let last_col = result.column_by_name("value_last").unwrap();
2358 let last_arr = last_col.as_any().downcast_ref::<Float64Array>().unwrap();
2359 assert!(
2360 (last_arr.value(0) - 2.0).abs() < f64::EPSILON,
2361 "Last with tie should keep last-encountered (2.0), got {}",
2362 last_arr.value(0)
2363 );
2364 }
2365
2366 #[test]
2368 fn first_last_single_row() {
2369 let schema = Arc::new(Schema::new(vec![
2370 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2371 Field::new("host", DataType::Utf8, false),
2372 Field::new("value", DataType::Float64, false),
2373 ]));
2374 let ts = Int64Array::from(vec![100]);
2375 let host = StringArray::from(vec!["a"]);
2376 let value = Float64Array::from(vec![42.0]);
2377 let batch =
2378 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2379 .unwrap();
2380
2381 let result =
2382 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
2383 let first = result
2384 .column(0)
2385 .as_any()
2386 .downcast_ref::<Float64Array>()
2387 .unwrap();
2388 let last = result
2389 .column(1)
2390 .as_any()
2391 .downcast_ref::<Float64Array>()
2392 .unwrap();
2393 assert!((first.value(0) - 42.0).abs() < f64::EPSILON);
2394 assert!((last.value(0) - 42.0).abs() < f64::EPSILON);
2395 }
2396
2397 #[test]
2400 fn sort_aggregate_matches_hash() {
2401 let batch = test_batch();
2402 let hash_result = aggregate_grouped(
2403 &batch,
2404 &[AggFn::Sum, AggFn::Count, AggFn::Min, AggFn::Max, AggFn::Avg],
2405 &["value"],
2406 &["host"],
2407 None,
2408 )
2409 .unwrap();
2410 let sort_result = aggregate_sorted(
2411 &batch,
2412 &[AggFn::Sum, AggFn::Count, AggFn::Min, AggFn::Max, AggFn::Avg],
2413 &["value"],
2414 &["host"],
2415 None,
2416 )
2417 .unwrap();
2418
2419 assert_eq!(hash_result.num_rows(), sort_result.num_rows());
2421
2422 let hash_hosts = hash_result
2424 .column_by_name("host")
2425 .unwrap()
2426 .as_any()
2427 .downcast_ref::<StringArray>()
2428 .unwrap();
2429 let sort_hosts = sort_result
2430 .column_by_name("host")
2431 .unwrap()
2432 .as_any()
2433 .downcast_ref::<StringArray>()
2434 .unwrap();
2435
2436 for host in &["a", "b"] {
2437 let hi = (0..hash_result.num_rows())
2438 .find(|&i| hash_hosts.value(i) == *host)
2439 .unwrap();
2440 let si = (0..sort_result.num_rows())
2441 .find(|&i| sort_hosts.value(i) == *host)
2442 .unwrap();
2443
2444 for col_name in &[
2445 "value_sum",
2446 "value_count",
2447 "value_min",
2448 "value_max",
2449 "value_avg",
2450 ] {
2451 let hv = hash_result
2452 .column_by_name(col_name)
2453 .unwrap()
2454 .as_any()
2455 .downcast_ref::<Float64Array>()
2456 .unwrap()
2457 .value(hi);
2458 let sv = sort_result
2459 .column_by_name(col_name)
2460 .unwrap()
2461 .as_any()
2462 .downcast_ref::<Float64Array>()
2463 .unwrap()
2464 .value(si);
2465 assert!(
2466 (hv - sv).abs() < f64::EPSILON,
2467 "mismatch for host={host} col={col_name}: hash={hv}, sort={sv}"
2468 );
2469 }
2470 }
2471 }
2472
2473 #[test]
2474 fn sort_aggregate_first_last() {
2475 let batch = test_batch();
2476 let result = aggregate_sorted(
2477 &batch,
2478 &[AggFn::First, AggFn::Last],
2479 &["value"],
2480 &["host"],
2481 None,
2482 )
2483 .unwrap();
2484
2485 let host_col = result
2486 .column_by_name("host")
2487 .unwrap()
2488 .as_any()
2489 .downcast_ref::<StringArray>()
2490 .unwrap();
2491 let first_col = result
2492 .column_by_name("value_first")
2493 .unwrap()
2494 .as_any()
2495 .downcast_ref::<Float64Array>()
2496 .unwrap();
2497 let last_col = result
2498 .column_by_name("value_last")
2499 .unwrap()
2500 .as_any()
2501 .downcast_ref::<Float64Array>()
2502 .unwrap();
2503
2504 for i in 0..result.num_rows() {
2505 match host_col.value(i) {
2506 "a" => {
2507 assert!((first_col.value(i) - 10.0).abs() < f64::EPSILON);
2509 assert!((last_col.value(i) - 50.0).abs() < f64::EPSILON);
2510 }
2511 "b" => {
2512 assert!((first_col.value(i) - 30.0).abs() < f64::EPSILON);
2514 assert!((last_col.value(i) - 40.0).abs() < f64::EPSILON);
2515 }
2516 other => panic!("unexpected host: {other}"),
2517 }
2518 }
2519 }
2520
2521 #[test]
2522 fn sort_aggregate_produces_sorted_output() {
2523 let schema = Arc::new(Schema::new(vec![
2524 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2525 Field::new("region", DataType::Utf8, false),
2526 Field::new("value", DataType::Float64, false),
2527 ]));
2528 let ts = Int64Array::from(vec![100, 200, 300]);
2529 let regions = StringArray::from(vec!["eu", "us", "ap"]);
2530 let values = Float64Array::from(vec![1.0, 2.0, 3.0]);
2531 let batch = RecordBatch::try_new(
2532 schema,
2533 vec![Arc::new(ts), Arc::new(regions), Arc::new(values)],
2534 )
2535 .unwrap();
2536
2537 let result =
2538 aggregate_sorted(&batch, &[AggFn::Sum], &["value"], &["region"], None).unwrap();
2539
2540 let region_col = result
2541 .column_by_name("region")
2542 .unwrap()
2543 .as_any()
2544 .downcast_ref::<StringArray>()
2545 .unwrap();
2546 let regions: Vec<&str> = (0..result.num_rows())
2548 .map(|i| region_col.value(i))
2549 .collect();
2550 assert_eq!(
2551 regions,
2552 vec!["ap", "eu", "us"],
2553 "output should be sorted by group key"
2554 );
2555 }
2556
2557 #[test]
2558 fn choose_strategy_defaults_to_hash() {
2559 assert_eq!(choose_strategy(None), AggregationStrategy::Hash);
2560 assert_eq!(choose_strategy(Some(2000)), AggregationStrategy::Hash);
2561 }
2562
2563 #[test]
2564 fn choose_strategy_low_cardinality_uses_sort() {
2565 assert_eq!(choose_strategy(Some(5)), AggregationStrategy::Sort);
2566 assert_eq!(choose_strategy(Some(1024)), AggregationStrategy::Sort);
2567 }
2568
2569 #[test]
2570 fn sort_aggregate_no_group_by_delegates() {
2571 let batch = test_batch();
2572 let result = aggregate_sorted(&batch, &[AggFn::Sum], &["value"], &[], None).unwrap();
2573 let col = result
2574 .column(0)
2575 .as_any()
2576 .downcast_ref::<Float64Array>()
2577 .unwrap();
2578 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
2579 }
2580}