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