Skip to main content

vortex_array/scalar_fn/fns/
list_sum.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use vortex_buffer::BitBuffer;
5use vortex_error::VortexResult;
6use vortex_error::vortex_bail;
7use vortex_error::vortex_err;
8use vortex_mask::AllOr;
9use vortex_session::VortexSession;
10use vortex_session::registry::CachedId;
11
12use crate::ArrayRef;
13use crate::Canonical;
14use crate::Columnar;
15use crate::ExecutionCtx;
16use crate::IntoArray;
17use crate::aggregate_fn::AggregateFnVTable;
18use crate::aggregate_fn::DynGroupedAccumulator;
19use crate::aggregate_fn::GroupRanges;
20use crate::aggregate_fn::GroupedAccumulator;
21use crate::aggregate_fn::GroupedArray;
22use crate::aggregate_fn::NumericalAggregateOpts;
23use crate::aggregate_fn::fns::sum::Sum;
24use crate::arrays::BoolArray;
25use crate::arrays::ConstantArray;
26use crate::arrays::FixedSizeList;
27use crate::arrays::ListView;
28use crate::builtins::ArrayBuiltins;
29use crate::dtype::DType;
30use crate::scalar_fn::Arity;
31use crate::scalar_fn::ChildName;
32use crate::scalar_fn::ExecutionArgs;
33use crate::scalar_fn::ScalarFnId;
34use crate::scalar_fn::ScalarFnVTable;
35use crate::validity::Validity;
36
37/// Sum of the elements in each list of a `List` or `FixedSizeList` typed array.
38///
39/// Follows SQL `SUM` semantics per list, matching DuckDB's `list_sum`: null lists, empty
40/// lists, and lists whose elements are all null yield a null sum; null elements are skipped.
41/// Integer and decimal overflow yields a null sum value, matching [`Sum`]. The result dtype
42/// follows [`Sum`]'s widening rules and is always nullable.
43///
44/// NaN handling for float elements is controlled by [`NumericalAggregateOpts`]: with
45/// `skip_nans` (the default) NaN values contribute nothing, otherwise any NaN poisons the
46/// list's sum to NaN.
47#[derive(Clone)]
48pub struct ListSum;
49
50impl ScalarFnVTable for ListSum {
51    type Options = NumericalAggregateOpts;
52
53    fn id(&self) -> ScalarFnId {
54        static ID: CachedId = CachedId::new("vortex.list.sum");
55        *ID
56    }
57
58    fn serialize(&self, options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
59        Ok(Some(options.serialize()))
60    }
61
62    fn deserialize(
63        &self,
64        metadata: &[u8],
65        _session: &VortexSession,
66    ) -> VortexResult<Self::Options> {
67        NumericalAggregateOpts::deserialize(metadata)
68    }
69
70    fn arity(&self, _options: &Self::Options) -> Arity {
71        Arity::Exact(1)
72    }
73
74    fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName {
75        match child_idx {
76            0 => ChildName::from("input"),
77            _ => unreachable!("Invalid child index {child_idx} for list_sum()"),
78        }
79    }
80
81    fn return_dtype(&self, options: &Self::Options, arg_dtypes: &[DType]) -> VortexResult<DType> {
82        let elem_dtype = match &arg_dtypes[0] {
83            DType::List(elem, _) | DType::FixedSizeList(elem, ..) => elem.as_ref(),
84            other => vortex_bail!("list_sum() requires List or FixedSizeList, got {other}"),
85        };
86        Sum.return_dtype(options, elem_dtype)
87            .ok_or_else(|| vortex_err!("list_sum() cannot sum elements of type {elem_dtype}"))
88    }
89
90    fn execute(
91        &self,
92        options: &Self::Options,
93        args: &dyn ExecutionArgs,
94        ctx: &mut ExecutionCtx,
95    ) -> VortexResult<ArrayRef> {
96        let input = args.get(0)?;
97
98        let elem_dtype = match input.dtype() {
99            DType::List(elem, _) | DType::FixedSizeList(elem, ..) => elem.as_ref().clone(),
100            other => vortex_bail!("list_sum() requires List or FixedSizeList, got {other}"),
101        };
102
103        // `mask_empty_lists` needs access to list elements validity and sizes
104        let columnar = input.execute::<Columnar>(ctx)?;
105
106        match columnar {
107            Columnar::Constant(constant) => {
108                // Canonicalize one row of the constant and broadcast its sum.
109                let one_row = ConstantArray::new(constant.scalar().clone(), 1)
110                    .into_array()
111                    .execute::<Canonical>(ctx)?
112                    .into_array();
113                let sum =
114                    list_sum_impl(one_row, elem_dtype, options, ctx)?.execute_scalar(0, ctx)?;
115                Ok(ConstantArray::new(sum, constant.len()).into_array())
116            }
117            Columnar::Canonical(canonical) => {
118                list_sum_impl(canonical.into_array(), elem_dtype, options, ctx)
119            }
120        }
121    }
122
123    fn is_strict(&self, _options: &Self::Options) -> bool {
124        // A null list sums to null, which is all that strictness requires. Element nulls are part
125        // of the list value rather than row-level nulls, and a valid list may still sum to null
126        // (empty, all-null, or overflow) since strictness is only one-directional.
127        true
128    }
129
130    fn is_fallible(&self, _options: &Self::Options) -> bool {
131        false
132    }
133}
134
135/// Sum each list of a canonical `array` into one value per list.
136///
137/// Note that we need to nullify sums produced by empty or all-null lists,
138/// since grouped sum kernels default to 0 for these.
139fn list_sum_impl(
140    canonical: ArrayRef,
141    elem_dtype: DType,
142    options: &NumericalAggregateOpts,
143    ctx: &mut ExecutionCtx,
144) -> VortexResult<ArrayRef> {
145    let mut acc = GroupedAccumulator::try_new(Sum, *options, elem_dtype)?;
146    acc.accumulate_list(&canonical, ctx)?;
147    let sums = acc.finish()?;
148
149    let grouped: GroupedArray = if let Some(fsl) = canonical.as_opt::<FixedSizeList>() {
150        fsl.into_owned().into()
151    } else if let Some(lv) = canonical.as_opt::<ListView>() {
152        lv.into_owned().into()
153    } else {
154        let dtype = canonical.dtype();
155        vortex_bail!("list_sum() requires List or FixedSizeList but got {dtype}")
156    };
157
158    mask_empty_lists(grouped, sums, ctx)
159}
160
161/// Applies a mask to `sums` that nullifies entries produced by lists without at least
162/// one valid element. This is necessary because the grouped `Sum` aggregate only produces
163/// nulls for null lists and sums that overflow.
164fn mask_empty_lists(
165    grouped: GroupedArray,
166    sums: ArrayRef,
167    ctx: &mut ExecutionCtx,
168) -> VortexResult<ArrayRef> {
169    let elements = grouped.elements();
170    let elem_mask = elements.validity()?.execute_mask(elements.len(), ctx)?;
171    let ranges = grouped.group_ranges(ctx)?;
172
173    let has_valid_element: BitBuffer = match elem_mask.bit_buffer() {
174        AllOr::All => match &ranges {
175            // fixed-size lists of non-zero width cannot have empty lists.
176            GroupRanges::FixedSizeList { size, .. } if *size > 0 => return Ok(sums),
177            GroupRanges::FixedSizeList { len, .. } => BitBuffer::full(false, *len),
178            GroupRanges::ListView { ranges } => ranges.iter().map(|&(_, size)| size > 0).collect(),
179        },
180        AllOr::None => BitBuffer::full(false, ranges.len()),
181        AllOr::Some(bits) => ranges
182            .iter()
183            .map(|(offset, size)| size > 0 && bits.count_range(offset, offset + size) > 0)
184            .collect(),
185    };
186    if has_valid_element.true_count() == has_valid_element.len() {
187        return Ok(sums);
188    }
189
190    sums.mask(BoolArray::new(has_valid_element, Validity::NonNullable).into_array())
191}
192
193#[cfg(test)]
194mod tests {
195    use std::sync::Arc;
196
197    use prost::Message;
198    use rstest::rstest;
199    use vortex_buffer::buffer;
200    use vortex_error::VortexResult;
201    use vortex_proto::expr as pb;
202
203    use crate::ArrayRef;
204    use crate::IntoArray;
205    use crate::VortexSessionExecute;
206    use crate::aggregate_fn::NumericalAggregateOpts;
207    use crate::array_session;
208    use crate::arrays::BoolArray;
209    use crate::arrays::ConstantArray;
210    use crate::arrays::FixedSizeListArray;
211    use crate::arrays::ListArray;
212    use crate::arrays::ListViewArray;
213    use crate::arrays::PrimitiveArray;
214    use crate::assert_arrays_eq;
215    use crate::dtype::DType;
216    use crate::dtype::Nullability;
217    use crate::dtype::PType;
218    use crate::expr::Expression;
219    use crate::expr::list_sum;
220    use crate::expr::list_sum_opts;
221    use crate::expr::proto::ExprSerializeProtoExt;
222    use crate::expr::root;
223    use crate::scalar::Scalar;
224    use crate::scalar_fn::ScalarFnVTable;
225    use crate::scalar_fn::fns::list_sum::ListSum;
226    use crate::validity::Validity;
227
228    fn create_list_elements() -> ArrayRef {
229        PrimitiveArray::from_option_iter::<i32, _>([
230            Some(1),
231            Some(2),
232            Some(3),
233            Some(4),
234            Some(5),
235            Some(6),
236            None,
237        ])
238        .into_array()
239    }
240
241    #[rstest]
242    #[case(buffer![0u32, 2, 5, 5, 7].into_array())]
243    #[case(buffer![0u64, 2, 5, 5, 7].into_array())]
244    fn test_list_sum(#[case] offsets: ArrayRef) -> VortexResult<()> {
245        let elements = create_list_elements();
246        let list = ListArray::try_new(elements, offsets, Validity::NonNullable)?.into_array();
247        let result = list.apply(&list_sum(root()))?;
248        let mut ctx = array_session().create_execution_ctx();
249        // [1, 2] = 3; [3, 4, 5] = 12; [] = null; [6, null] = 6 (nulls skipped).
250        let expected =
251            PrimitiveArray::from_option_iter::<i64, _>([Some(3), Some(12), None, Some(6)]);
252        assert_arrays_eq!(result, expected, &mut ctx);
253        Ok(())
254    }
255
256    #[test]
257    fn test_nullable_list_sum() -> VortexResult<()> {
258        let elements = create_list_elements();
259        let list = ListArray::try_new(
260            elements,
261            buffer![0u32, 2, 5, 5, 7].into_array(),
262            Validity::Array(BoolArray::from_iter([true, false, true, false]).into_array()),
263        )?
264        .into_array();
265        let result = list.apply(&list_sum(root()))?;
266
267        let mut ctx = array_session().create_execution_ctx();
268        // Row 1 and 3 are null lists; row 2 is a valid but empty list.
269        let expected = PrimitiveArray::from_option_iter::<i64, _>([Some(3), None, None, None]);
270        assert_arrays_eq!(result, expected, &mut ctx);
271        Ok(())
272    }
273
274    #[test]
275    fn test_all_null_elements_sum_to_null() -> VortexResult<()> {
276        let elements = PrimitiveArray::from_option_iter::<i32, _>([None, None, Some(1)]);
277        let list = ListArray::try_new(
278            elements.into_array(),
279            buffer![0u32, 2, 3].into_array(),
280            Validity::NonNullable,
281        )?
282        .into_array();
283        let result = list.apply(&list_sum(root()))?;
284
285        let mut ctx = array_session().create_execution_ctx();
286        let expected = PrimitiveArray::from_option_iter::<i64, _>([None, Some(1)]);
287        assert_arrays_eq!(result, expected, &mut ctx);
288        Ok(())
289    }
290
291    #[test]
292    fn test_overflow_sums_to_null() -> VortexResult<()> {
293        let elements = PrimitiveArray::from_iter([i64::MAX, 1, 1, 2]);
294        let list = ListArray::try_new(
295            elements.into_array(),
296            buffer![0u32, 2, 4].into_array(),
297            Validity::NonNullable,
298        )?
299        .into_array();
300        let result = list.apply(&list_sum(root()))?;
301
302        let mut ctx = array_session().create_execution_ctx();
303        let expected = PrimitiveArray::from_option_iter::<i64, _>([None, Some(3)]);
304        assert_arrays_eq!(result, expected, &mut ctx);
305        Ok(())
306    }
307
308    #[test]
309    fn test_unsigned_widening() -> VortexResult<()> {
310        let elements = PrimitiveArray::from_iter([1u8, 2, 3]);
311        let list = ListArray::try_new(
312            elements.into_array(),
313            buffer![0u32, 3].into_array(),
314            Validity::NonNullable,
315        )?
316        .into_array();
317        let result = list.apply(&list_sum(root()))?;
318
319        let mut ctx = array_session().create_execution_ctx();
320        let expected = PrimitiveArray::from_option_iter::<u64, _>([Some(6)]);
321        assert_arrays_eq!(result, expected, &mut ctx);
322        Ok(())
323    }
324
325    #[test]
326    fn test_bool_elements() -> VortexResult<()> {
327        let elements = BoolArray::from_iter([true, true, false, true]);
328        let list = ListArray::try_new(
329            elements.into_array(),
330            buffer![0u32, 3, 4].into_array(),
331            Validity::NonNullable,
332        )?
333        .into_array();
334        let result = list.apply(&list_sum(root()))?;
335
336        let mut ctx = array_session().create_execution_ctx();
337        let expected = PrimitiveArray::from_option_iter::<u64, _>([Some(2), Some(1)]);
338        assert_arrays_eq!(result, expected, &mut ctx);
339        Ok(())
340    }
341
342    #[test]
343    fn test_nan_skipped_by_default() -> VortexResult<()> {
344        let elements = PrimitiveArray::from_iter([1.0f64, f64::NAN, 2.0]);
345        let list = ListArray::try_new(
346            elements.into_array(),
347            buffer![0u32, 3].into_array(),
348            Validity::NonNullable,
349        )?
350        .into_array();
351        let result = list.apply(&list_sum(root()))?;
352
353        let mut ctx = array_session().create_execution_ctx();
354        let expected = PrimitiveArray::from_option_iter::<f64, _>([Some(3.0)]);
355        assert_arrays_eq!(result, expected, &mut ctx);
356        Ok(())
357    }
358
359    #[test]
360    fn test_all_nan_list_sums_to_zero() -> VortexResult<()> {
361        // NaN elements are *valid*: with the default `skip_nans` they contribute nothing, but
362        // the list still has valid elements, so the sum is `0.0` rather than null (unlike an
363        // all-null list).
364        let elements = PrimitiveArray::from_iter([f64::NAN, f64::NAN]);
365        let list = ListArray::try_new(
366            elements.into_array(),
367            buffer![0u32, 2].into_array(),
368            Validity::NonNullable,
369        )?
370        .into_array();
371        let result = list.apply(&list_sum(root()))?;
372
373        let mut ctx = array_session().create_execution_ctx();
374        let expected = PrimitiveArray::from_option_iter::<f64, _>([Some(0.0)]);
375        assert_arrays_eq!(result, expected, &mut ctx);
376        Ok(())
377    }
378
379    #[test]
380    fn test_nan_poisons_with_include_nans() -> VortexResult<()> {
381        let elements = PrimitiveArray::from_iter([1.0f64, f64::NAN, 2.0]);
382        let list = ListArray::try_new(
383            elements.into_array(),
384            buffer![0u32, 3].into_array(),
385            Validity::NonNullable,
386        )?
387        .into_array();
388        let result = list.apply(&list_sum_opts(
389            root(),
390            NumericalAggregateOpts::include_nans(),
391        ))?;
392
393        let mut ctx = array_session().create_execution_ctx();
394        assert!(result.is_valid(0, &mut ctx)?);
395        let result = result.execute::<PrimitiveArray>(&mut ctx)?;
396        assert!(result.as_slice::<f64>()[0].is_nan());
397        Ok(())
398    }
399
400    #[test]
401    fn test_listview_sum() -> VortexResult<()> {
402        let elements = create_list_elements();
403        // Overlapping and out-of-order views over the shared elements.
404        let lv = ListViewArray::new(
405            elements,
406            buffer![5u32, 0, 4, 1].into_array(),
407            buffer![2u32, 3, 0, 2].into_array(),
408            Validity::NonNullable,
409        )
410        .into_array();
411        let result = lv.apply(&list_sum(root()))?;
412
413        let mut ctx = array_session().create_execution_ctx();
414        // [6, null] = 6; [1, 2, 3] = 6; [] = null; [2, 3] = 5.
415        let expected =
416            PrimitiveArray::from_option_iter::<i64, _>([Some(6), Some(6), None, Some(5)]);
417        assert_arrays_eq!(result, expected, &mut ctx);
418        Ok(())
419    }
420
421    fn create_fixed_size_list(validity: Validity) -> ArrayRef {
422        // 4 lists of size 2 over 8 primitive elements.
423        let elements = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6, 7, 8]).into_array();
424        FixedSizeListArray::new(elements, 2, validity, 4).into_array()
425    }
426
427    #[test]
428    fn test_fixed_size_list_sum() -> VortexResult<()> {
429        let fsl = create_fixed_size_list(Validity::NonNullable);
430        let result = fsl.apply(&list_sum(root()))?;
431
432        let mut ctx = array_session().create_execution_ctx();
433        let expected =
434            PrimitiveArray::from_option_iter::<i64, _>([Some(3), Some(7), Some(11), Some(15)]);
435        assert_arrays_eq!(result, expected, &mut ctx);
436        Ok(())
437    }
438
439    #[test]
440    fn test_fixed_size_list_sum_nullable() -> VortexResult<()> {
441        let fsl = create_fixed_size_list(Validity::Array(
442            BoolArray::from_iter([true, false, true, false]).into_array(),
443        ));
444        let result = fsl.apply(&list_sum(root()))?;
445
446        let mut ctx = array_session().create_execution_ctx();
447        let expected = PrimitiveArray::from_option_iter::<i64, _>([Some(3), None, Some(11), None]);
448        assert_arrays_eq!(result, expected, &mut ctx);
449        Ok(())
450    }
451
452    #[test]
453    fn test_list_sum_take() -> VortexResult<()> {
454        let elements = create_list_elements();
455        let list = ListArray::try_new(
456            elements,
457            buffer![0u32, 2, 5, 5, 7].into_array(),
458            Validity::NonNullable,
459        )?
460        .into_array();
461        let taken = list.take(buffer![3u64, 0, 2].into_array())?;
462
463        let result = taken.apply(&list_sum(root()))?;
464        let mut ctx = array_session().create_execution_ctx();
465        let expected = PrimitiveArray::from_option_iter::<i64, _>([Some(6), Some(3), None]);
466        assert_arrays_eq!(result, expected, &mut ctx);
467        Ok(())
468    }
469
470    #[test]
471    fn test_list_sum_slice() -> VortexResult<()> {
472        let elements = create_list_elements();
473        let list = ListArray::try_new(
474            elements,
475            buffer![0u32, 2, 5, 5, 7].into_array(),
476            Validity::NonNullable,
477        )?
478        .into_array();
479        let sliced = list.slice(1..4)?;
480
481        let result = sliced.apply(&list_sum(root()))?;
482        let mut ctx = array_session().create_execution_ctx();
483        let expected = PrimitiveArray::from_option_iter::<i64, _>([Some(12), None, Some(6)]);
484        assert_arrays_eq!(result, expected, &mut ctx);
485        Ok(())
486    }
487
488    #[test]
489    fn test_empty_array() -> VortexResult<()> {
490        let elements = PrimitiveArray::from_iter([0i32; 0]);
491        let list = ListArray::try_new(
492            elements.into_array(),
493            buffer![0u32].into_array(),
494            Validity::NonNullable,
495        )?
496        .into_array();
497        let result = list.apply(&list_sum(root()))?;
498
499        let mut ctx = array_session().create_execution_ctx();
500        let result = result.execute::<PrimitiveArray>(&mut ctx)?;
501        assert_eq!(result.len(), 0);
502        Ok(())
503    }
504
505    #[test]
506    fn test_constant_list_sum() -> VortexResult<()> {
507        let elements = create_list_elements();
508        let list = ListArray::try_new(
509            elements,
510            buffer![0u32, 2, 5, 5, 7].into_array(),
511            Validity::NonNullable,
512        )?
513        .into_array();
514        let mut ctx = array_session().create_execution_ctx();
515        let scalar = list.execute_scalar(1, &mut ctx)?;
516
517        let constant = ConstantArray::new(scalar, 3).into_array();
518        let result = constant.apply(&list_sum(root()))?;
519        let expected = PrimitiveArray::from_option_iter::<i64, _>([Some(12), Some(12), Some(12)]);
520        assert_arrays_eq!(result, expected, &mut ctx);
521        Ok(())
522    }
523
524    #[test]
525    fn test_null_scalar_list_sum() -> VortexResult<()> {
526        let null_scalar = Scalar::null(DType::List(
527            Arc::new(DType::Primitive(PType::I32, Nullability::NonNullable)),
528            Nullability::Nullable,
529        ));
530        let array = ConstantArray::new(null_scalar, 2).into_array();
531        let result = array.apply(&list_sum(root()))?;
532
533        let mut ctx = array_session().create_execution_ctx();
534        assert!(!result.is_valid(0, &mut ctx)?);
535        assert!(!result.is_valid(1, &mut ctx)?);
536        Ok(())
537    }
538
539    #[test]
540    fn test_unsupported_dtypes() {
541        let opts = NumericalAggregateOpts::default();
542        // Non-list inputs are rejected.
543        assert!(
544            ListSum
545                .return_dtype(
546                    &opts,
547                    &[DType::Primitive(PType::I32, Nullability::NonNullable)]
548                )
549                .is_err()
550        );
551        // Non-numeric element types are rejected.
552        assert!(
553            ListSum
554                .return_dtype(
555                    &opts,
556                    &[DType::List(
557                        Arc::new(DType::Utf8(Nullability::NonNullable)),
558                        Nullability::NonNullable,
559                    )],
560                )
561                .is_err()
562        );
563    }
564
565    #[test]
566    fn test_display() {
567        assert_eq!(list_sum(root()).to_string(), "vortex.list.sum($)");
568        assert_eq!(
569            list_sum_opts(root(), NumericalAggregateOpts::include_nans()).to_string(),
570            "vortex.list.sum($, opts=skip_nans=false)"
571        );
572    }
573
574    #[test]
575    fn test_proto_round_trip() -> VortexResult<()> {
576        for expr in [
577            list_sum(root()),
578            list_sum_opts(root(), NumericalAggregateOpts::include_nans()),
579        ] {
580            let proto = expr.serialize_proto()?;
581            let buf = proto.encode_to_vec();
582            let decoded = pb::Expr::decode(buf.as_slice())?;
583            let deser = Expression::from_proto(&decoded, &array_session())?;
584            assert_eq!(expr, deser);
585        }
586        Ok(())
587    }
588}