Skip to main content

sqlly_datatable/pivot/
aggregation.rs

1//! Aggregation functions applied at every pivot intersection.
2//!
3//! GPUI-free on purpose: the same [`AggregationFn`] / [`Accumulator`] pair is
4//! usable from export pipelines and tests. All five functions are streaming —
5//! the engine feeds source cells one at a time and never materializes the
6//! per-group value lists.
7
8use crate::data::{compare_cells, CellValue};
9use std::cmp::Ordering;
10
11/// How the value field is combined at each pivot intersection.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
13pub enum AggregationFn {
14    /// Number of non-null values.
15    Count,
16    /// Numeric sum. Integers stay integers until a decimal (or `i64`
17    /// overflow) forces promotion to `f64`.
18    #[default]
19    Sum,
20    /// Numeric mean; always produces [`CellValue::Decimal`].
21    Avg,
22    /// Smallest value under [`compare_cells`]; preserves the source kind.
23    Min,
24    /// Largest value under [`compare_cells`]; preserves the source kind.
25    Max,
26}
27
28impl AggregationFn {
29    /// All functions, in the order pickers should present them.
30    #[must_use]
31    pub fn all() -> [AggregationFn; 5] {
32        [Self::Count, Self::Sum, Self::Avg, Self::Min, Self::Max]
33    }
34
35    /// Short label ("Count", "Sum", …).
36    #[must_use]
37    pub fn label(&self) -> &'static str {
38        match self {
39            Self::Count => "Count",
40            Self::Sum => "Sum",
41            Self::Avg => "Avg",
42            Self::Min => "Min",
43            Self::Max => "Max",
44        }
45    }
46
47    /// Header caption for the value area, e.g. `"Sum of Amount"`.
48    #[must_use]
49    pub fn caption(&self, field_name: &str) -> String {
50        format!("{} of {}", self.label(), field_name)
51    }
52}
53
54/// Streaming accumulator for one pivot intersection. Feed cells with
55/// [`Accumulator::ingest`], read the result with [`Accumulator::finish`].
56#[derive(Clone, Debug)]
57pub struct Accumulator {
58    func: AggregationFn,
59    count: u64,
60    int_sum: i64,
61    float_sum: f64,
62    /// Set once an ingested value was `Decimal`, or the integer sum
63    /// overflowed and had to be promoted.
64    promoted: bool,
65    extreme: Option<CellValue>,
66}
67
68impl Accumulator {
69    /// A fresh accumulator for `func` with no values ingested.
70    #[must_use]
71    pub fn new(func: AggregationFn) -> Self {
72        Self {
73            func,
74            count: 0,
75            int_sum: 0,
76            float_sum: 0.0,
77            promoted: false,
78            extreme: None,
79        }
80    }
81
82    /// Feed one source cell. `CellValue::None` is skipped by every function
83    /// (Excel semantics: blanks do not count and do not sum). Non-numeric
84    /// values contribute to `Count`/`Min`/`Max` but are ignored by
85    /// `Sum`/`Avg`.
86    pub fn ingest(&mut self, value: &CellValue) {
87        if matches!(value, CellValue::None) {
88            return;
89        }
90        self.count += 1;
91        match self.func {
92            AggregationFn::Count => {}
93            AggregationFn::Sum | AggregationFn::Avg => match value {
94                CellValue::Integer(v) => {
95                    if self.promoted {
96                        self.float_sum += *v as f64;
97                    } else {
98                        match self.int_sum.checked_add(*v) {
99                            Some(next) => self.int_sum = next,
100                            None => {
101                                self.promote();
102                                self.float_sum += *v as f64;
103                            }
104                        }
105                    }
106                }
107                CellValue::Decimal(v) => {
108                    if !self.promoted {
109                        self.promote();
110                    }
111                    self.float_sum += *v;
112                }
113                // Text/Date/Boolean cells do not participate in numeric sums;
114                // they still count toward the row having values so an
115                // Avg over a purely non-numeric group finishes as None below.
116                _ => self.count -= 1,
117            },
118            AggregationFn::Min => {
119                let replace = match &self.extreme {
120                    Some(current) => compare_cells(value, current) == Ordering::Less,
121                    None => true,
122                };
123                if replace {
124                    self.extreme = Some(value.clone());
125                }
126            }
127            AggregationFn::Max => {
128                let replace = match &self.extreme {
129                    Some(current) => compare_cells(value, current) == Ordering::Greater,
130                    None => true,
131                };
132                if replace {
133                    self.extreme = Some(value.clone());
134                }
135            }
136        }
137    }
138
139    fn promote(&mut self) {
140        self.float_sum += self.int_sum as f64;
141        self.int_sum = 0;
142        self.promoted = true;
143    }
144
145    /// Final aggregated cell. Empty input finishes as [`CellValue::None`]
146    /// (the paint layer renders those as blank, not `0`).
147    #[must_use]
148    pub fn finish(&self) -> CellValue {
149        if self.count == 0 && !matches!(self.func, AggregationFn::Count) {
150            return CellValue::None;
151        }
152        match self.func {
153            AggregationFn::Count => CellValue::Integer(self.count as i64),
154            AggregationFn::Sum => {
155                if self.promoted {
156                    CellValue::Decimal(self.float_sum)
157                } else {
158                    CellValue::Integer(self.int_sum)
159                }
160            }
161            AggregationFn::Avg => {
162                let total = if self.promoted {
163                    self.float_sum
164                } else {
165                    self.int_sum as f64
166                };
167                CellValue::Decimal(total / self.count as f64)
168            }
169            AggregationFn::Min | AggregationFn::Max => {
170                self.extreme.clone().unwrap_or(CellValue::None)
171            }
172        }
173    }
174}
175
176/// One-shot convenience over [`Accumulator`] for slices; used by tests and
177/// small export paths.
178#[must_use]
179pub fn aggregate(values: &[CellValue], func: AggregationFn) -> CellValue {
180    let mut acc = Accumulator::new(func);
181    for v in values {
182        acc.ingest(v);
183    }
184    acc.finish()
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use CellValue::{Boolean, Decimal, Integer, None as Null, Text};
191
192    #[test]
193    fn count_skips_nulls_and_counts_everything_else() {
194        let vals = vec![
195            Integer(1),
196            Null,
197            Text("x".into()),
198            Boolean(true),
199            Decimal(2.5),
200            Null,
201        ];
202        assert_eq!(aggregate(&vals, AggregationFn::Count), Integer(4));
203    }
204
205    #[test]
206    fn count_of_empty_is_zero() {
207        assert_eq!(aggregate(&[], AggregationFn::Count), Integer(0));
208        assert_eq!(aggregate(&[Null, Null], AggregationFn::Count), Integer(0));
209    }
210
211    #[test]
212    fn sum_of_integers_stays_integer() {
213        let vals = vec![Integer(1), Integer(2), Integer(3)];
214        assert_eq!(aggregate(&vals, AggregationFn::Sum), Integer(6));
215    }
216
217    #[test]
218    fn sum_promotes_on_mixed_numeric() {
219        let vals = vec![Integer(1), Decimal(0.5)];
220        assert_eq!(aggregate(&vals, AggregationFn::Sum), Decimal(1.5));
221    }
222
223    #[test]
224    fn sum_promotes_on_i64_overflow() {
225        let vals = vec![Integer(i64::MAX), Integer(1)];
226        match aggregate(&vals, AggregationFn::Sum) {
227            Decimal(v) => {
228                let expected = i64::MAX as f64 + 1.0;
229                assert!((v - expected).abs() < 1e3, "got {v}");
230            }
231            other => panic!("expected Decimal, got {other:?}"),
232        }
233    }
234
235    #[test]
236    fn sum_ignores_non_numeric_and_null() {
237        let vals = vec![Integer(4), Text("x".into()), Null, Boolean(true)];
238        assert_eq!(aggregate(&vals, AggregationFn::Sum), Integer(4));
239    }
240
241    #[test]
242    fn sum_of_empty_is_none() {
243        assert_eq!(aggregate(&[], AggregationFn::Sum), Null);
244        assert_eq!(aggregate(&[Null], AggregationFn::Sum), Null);
245    }
246
247    #[test]
248    fn avg_is_always_decimal() {
249        let vals = vec![Integer(1), Integer(2)];
250        assert_eq!(aggregate(&vals, AggregationFn::Avg), Decimal(1.5));
251        let vals = vec![Integer(2), Integer(2)];
252        assert_eq!(aggregate(&vals, AggregationFn::Avg), Decimal(2.0));
253    }
254
255    #[test]
256    fn avg_of_empty_is_none() {
257        assert_eq!(aggregate(&[], AggregationFn::Avg), Null);
258    }
259
260    #[test]
261    fn min_max_preserve_kind_and_skip_null() {
262        let vals = vec![Null, Integer(5), Integer(2), Integer(9)];
263        assert_eq!(aggregate(&vals, AggregationFn::Min), Integer(2));
264        assert_eq!(aggregate(&vals, AggregationFn::Max), Integer(9));
265        let dates = vec![CellValue::Date(200), CellValue::Date(100)];
266        assert_eq!(aggregate(&dates, AggregationFn::Min), CellValue::Date(100));
267        let texts = vec![Text("beta".into()), Text("alpha".into())];
268        assert_eq!(aggregate(&texts, AggregationFn::Max), Text("beta".into()));
269    }
270
271    #[test]
272    fn min_max_of_empty_is_none() {
273        assert_eq!(aggregate(&[], AggregationFn::Min), Null);
274        assert_eq!(aggregate(&[Null], AggregationFn::Max), Null);
275    }
276
277    #[test]
278    fn caption_formats_label_and_field() {
279        assert_eq!(AggregationFn::Sum.caption("Amount"), "Sum of Amount");
280        assert_eq!(AggregationFn::Count.caption("Id"), "Count of Id");
281    }
282
283    #[test]
284    fn all_lists_five_distinct_functions() {
285        let all = AggregationFn::all();
286        assert_eq!(all.len(), 5);
287        for (i, a) in all.iter().enumerate() {
288            for b in &all[i + 1..] {
289                assert_ne!(a, b);
290            }
291        }
292    }
293}