Skip to main content

vortex_array/arrays/list/
array.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::fmt::Display;
5use std::fmt::Formatter;
6use std::sync::Arc;
7
8use num_traits::AsPrimitive;
9use vortex_error::VortexExpect;
10use vortex_error::VortexResult;
11use vortex_error::vortex_bail;
12use vortex_error::vortex_ensure;
13use vortex_error::vortex_panic;
14
15use crate::ArrayRef;
16use crate::ArraySlots;
17use crate::Canonical;
18use crate::ExecutionCtx;
19use crate::IntoArray;
20use crate::VortexSessionExecute;
21use crate::aggregate_fn::NumericalAggregateOpts;
22use crate::aggregate_fn::fns::min_max::min_max;
23use crate::array::Array;
24use crate::array::ArrayParts;
25use crate::array::TypedArrayRef;
26use crate::array::child_to_validity;
27use crate::array::validity_to_child;
28use crate::array_slots;
29use crate::arrays::ConstantArray;
30use crate::arrays::List;
31use crate::arrays::ListArray;
32use crate::arrays::Primitive;
33use crate::builtins::ArrayBuiltins;
34use crate::dtype::DType;
35use crate::dtype::NativePType;
36use crate::legacy_session;
37use crate::match_each_integer_ptype;
38use crate::match_each_native_ptype;
39use crate::scalar_fn::fns::operators::Operator;
40use crate::validity::Validity;
41
42#[array_slots(List)]
43pub struct ListSlots {
44    /// The elements data array containing all list elements concatenated together.
45    pub elements: ArrayRef,
46    /// The offsets array defining the start/end of each list within the elements array.
47    pub offsets: ArrayRef,
48    /// The validity bitmap indicating which list elements are non-null.
49    pub validity: Option<ArrayRef>,
50}
51
52/// A list array that stores variable-length lists of elements, similar to `Vec<Vec<T>>`.
53///
54/// This mirrors the Apache Arrow List array encoding and provides efficient storage
55/// for nested data where each row contains a list of elements of the same type.
56///
57/// ## Data Layout
58///
59/// The list array uses an offset-based encoding:
60/// - **Elements array**: A flat array containing all list elements concatenated together
61/// - **Offsets array**: Integer array where `offsets[i]` is an (inclusive) start index into
62///   the **elements** and `offsets[i+1]` is the (exclusive) stop index for the `i`th list.
63/// - **Validity**: Optional mask indicating which lists are null
64///
65/// This allows for excellent cascading compression of the elements and offsets, as similar values
66/// are clustered together and the offsets have a predictable pattern and small deltas between
67/// consecutive elements.
68///
69/// ## Offset Semantics
70///
71/// - Offsets must be non-nullable integers (i32, i64, etc.)
72/// - Offsets array has length `n+1` where `n` is the number of lists
73/// - List `i` contains elements from `elements[offsets[i]..offsets[i+1]]`
74/// - Offsets must be monotonically increasing
75///
76/// # Examples
77///
78/// ```
79/// use vortex_array::arrays::{ListArray, PrimitiveArray};
80/// use vortex_array::arrays::list::ListArrayExt;
81/// use vortex_array::validity::Validity;
82/// use vortex_array::IntoArray;
83/// use vortex_buffer::buffer;
84/// use std::sync::Arc;
85///
86/// // Create a list array representing [[1, 2], [3, 4, 5], []]
87/// let elements = buffer![1i32, 2, 3, 4, 5].into_array();
88/// let offsets = buffer![0u32, 2, 5, 5].into_array(); // 3 lists
89///
90/// let list_array = ListArray::try_new(
91///     elements.into_array(),
92///     offsets.into_array(),
93///     Validity::NonNullable,
94/// ).unwrap();
95///
96/// assert_eq!(list_array.len(), 3);
97///
98/// // Access individual lists
99/// let first_list = list_array.list_elements_at(0).unwrap();
100/// assert_eq!(first_list.len(), 2); // [1, 2]
101///
102/// let third_list = list_array.list_elements_at(2).unwrap();
103/// assert!(third_list.is_empty()); // []
104/// ```
105#[derive(Clone, Debug, Default)]
106pub struct ListData;
107
108impl Display for ListData {
109    fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result {
110        Ok(())
111    }
112}
113
114pub struct ListDataParts {
115    pub elements: ArrayRef,
116    pub offsets: ArrayRef,
117    pub validity: Validity,
118    pub dtype: DType,
119}
120
121impl ListData {
122    pub(crate) fn make_slots(
123        elements: &ArrayRef,
124        offsets: &ArrayRef,
125        validity: &Validity,
126        len: usize,
127    ) -> ArraySlots {
128        ListSlots {
129            elements: elements.clone(),
130            offsets: offsets.clone(),
131            validity: validity_to_child(validity, len),
132        }
133        .into_slots()
134    }
135
136    /// Creates a new `ListArray`.
137    ///
138    /// # Panics
139    ///
140    /// Panics if the provided components do not satisfy the invariants documented
141    /// in `ListArray::new_unchecked`.
142    pub fn build(elements: ArrayRef, offsets: ArrayRef, validity: Validity) -> Self {
143        Self::try_build(elements, offsets, validity).vortex_expect("ListArray new")
144    }
145
146    /// Constructs a new `ListArray`.
147    ///
148    /// See `ListArray::new_unchecked` for more information.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the provided components do not satisfy the invariants documented in
153    /// `ListArray::new_unchecked`.
154    pub(crate) fn try_build(
155        elements: ArrayRef,
156        offsets: ArrayRef,
157        validity: Validity,
158    ) -> VortexResult<Self> {
159        Self::validate(&elements, &offsets, &validity)?;
160
161        // SAFETY: validate ensures all invariants are met.
162        Ok(unsafe { Self::new_unchecked() })
163    }
164
165    /// Creates a new `ListArray` without validation from these components:
166    ///
167    /// * `elements` is a flat array containing all list elements concatenated.
168    /// * `offsets` is an integer array where `offsets[i]` is the start index for list `i`.
169    /// * `validity` holds the null values.
170    ///
171    /// # Safety
172    ///
173    /// The caller must ensure all of the following invariants are satisfied:
174    ///
175    /// - Offsets must be a non-nullable integer array.
176    /// - Offsets must have at least one element (even for empty lists, it should contain \[0\]).
177    /// - Offsets must be sorted (monotonically increasing).
178    /// - All offset values must be non-negative.
179    /// - The maximum offset must not exceed `elements.len()`.
180    /// - If validity is an array, its length must equal `offsets.len() - 1`.
181    pub unsafe fn new_unchecked() -> Self {
182        Self
183    }
184
185    /// Validates the components that would be used to create a `ListArray`.
186    ///
187    /// This function checks all the invariants required by `ListArray::new_unchecked`.
188    #[allow(clippy::disallowed_methods)]
189    pub fn validate(
190        elements: &ArrayRef,
191        offsets: &ArrayRef,
192        validity: &Validity,
193    ) -> VortexResult<()> {
194        // Offsets must have at least one element
195        vortex_ensure!(
196            !offsets.is_empty(),
197            InvalidArgument: "Offsets must have at least one element, [0] for an empty list"
198        );
199
200        // Offsets must be of integer type, and cannot go lower than 0.
201        vortex_ensure!(
202            offsets.dtype().is_int() && !offsets.dtype().is_nullable(),
203            InvalidArgument: "offsets have invalid type {}",
204            offsets.dtype()
205        );
206
207        // We can safely unwrap the DType as primitive now
208        let offsets_ptype = offsets.dtype().as_ptype();
209        let mut ctx = legacy_session().create_execution_ctx();
210
211        // Offsets must be sorted (but not strictly sorted, zero-length lists are allowed)
212        if let Some(is_sorted) = offsets.statistics().compute_is_sorted(&mut ctx) {
213            vortex_ensure!(is_sorted, InvalidArgument: "offsets must be sorted");
214        } else {
215            vortex_bail!(InvalidArgument: "offsets must report is_sorted statistic");
216        }
217
218        // Validate that offsets min is non-negative, and max does not exceed the length of
219        // the elements array.
220        if let Some(min_max) = min_max(offsets, &mut ctx, NumericalAggregateOpts::default())? {
221            match_each_integer_ptype!(offsets_ptype, |P| {
222                #[allow(clippy::absurd_extreme_comparisons, unused_comparisons)]
223                {
224                    let max = min_max
225                        .max
226                        .as_primitive()
227                        .as_::<P>()
228                        .vortex_expect("offsets type must fit offsets values");
229                    let min = min_max
230                        .min
231                        .as_primitive()
232                        .as_::<P>()
233                        .vortex_expect("offsets type must fit offsets values");
234
235                    vortex_ensure!(
236                        min >= 0,
237                        InvalidArgument: "offsets minimum {min} outside valid range [0, {max}]"
238                    );
239
240                    vortex_ensure!(
241                        max <= P::try_from(elements.len()).unwrap_or_else(|_| vortex_panic!(
242                            "Offsets type {} must be able to fit elements length {}",
243                            <P as NativePType>::PTYPE,
244                            elements.len()
245                        )),
246                        InvalidArgument: "Max offset {max} is beyond the length of the elements array {}",
247                        elements.len()
248                    );
249                }
250            })
251        } else {
252            // TODO(aduffy): fallback to slower validation pathway?
253            vortex_bail!(
254                InvalidArgument: "offsets array with encoding {} must support min_max compute function",
255                offsets.encoding_id()
256            );
257        };
258
259        // If a validity array is present, it must be the same length as the ListArray
260        if let Some(validity_len) = validity.maybe_len() {
261            vortex_ensure!(
262                validity_len == offsets.len() - 1,
263                InvalidArgument: "validity with size {validity_len} does not match array size {}",
264                offsets.len() - 1
265            );
266        }
267
268        Ok(())
269    }
270    // TODO(connor)[ListView]: Create 2 functions `reset_offsets` and `recursive_reset_offsets`,
271    // where `reset_offsets` is infallible.
272    // Also, `reset_offsets` can be made more efficient by replacing `sub_scalar` with a match on
273    // the offset type and manual subtraction and fast path where `offsets[0] == 0`.
274}
275
276pub trait ListArrayExt: ListArraySlotsExt {
277    fn nullability(&self) -> crate::dtype::Nullability {
278        match self.as_ref().dtype() {
279            DType::List(_, nullability) => *nullability,
280            _ => unreachable!("ListArrayExt requires a list dtype"),
281        }
282    }
283
284    fn list_validity(&self) -> Validity {
285        child_to_validity(
286            self.as_ref().slots()[ListSlots::VALIDITY].as_ref(),
287            self.nullability(),
288        )
289    }
290
291    #[allow(clippy::disallowed_methods)]
292    fn offset_at(&self, index: usize) -> VortexResult<usize> {
293        vortex_ensure!(
294            index <= self.as_ref().len(),
295            "Index {index} out of bounds 0..={}",
296            self.as_ref().len()
297        );
298
299        if let Some(p) = self.offsets().as_opt::<Primitive>() {
300            Ok(match_each_native_ptype!(p.ptype(), |P| {
301                p.as_slice::<P>()[index].as_()
302            }))
303        } else {
304            self.offsets()
305                .execute_scalar(index, &mut legacy_session().create_execution_ctx())?
306                .as_primitive()
307                .as_::<usize>()
308                .ok_or_else(|| vortex_error::vortex_err!("offset value does not fit in usize"))
309        }
310    }
311
312    fn list_elements_at(&self, index: usize) -> VortexResult<ArrayRef> {
313        let start = self.offset_at(index)?;
314        let end = self.offset_at(index + 1)?;
315        self.elements().slice(start..end)
316    }
317
318    fn sliced_elements(&self) -> VortexResult<ArrayRef> {
319        let start = self.offset_at(0)?;
320        let end = self.offset_at(self.as_ref().len())?;
321        self.elements().slice(start..end)
322    }
323
324    fn element_dtype(&self) -> &DType {
325        self.elements().dtype()
326    }
327
328    fn reset_offsets(&self, recurse: bool, ctx: &mut ExecutionCtx) -> VortexResult<Array<List>> {
329        let mut elements = self.sliced_elements()?;
330        if recurse && elements.is_canonical() {
331            let compacted = elements
332                .execute::<Canonical>(ctx)?
333                .compact(ctx)?
334                .into_array();
335            elements = compacted;
336        } else if recurse && let Some(child_list_array) = elements.as_opt::<List>() {
337            elements = child_list_array
338                .into_owned()
339                .reset_offsets(recurse, ctx)?
340                .into_array();
341        }
342
343        let offsets = self.offsets();
344        let first_offset = offsets.execute_scalar(0, ctx)?;
345        let adjusted_offsets = offsets.clone().binary(
346            ConstantArray::new(first_offset, offsets.len()).into_array(),
347            Operator::Sub,
348        )?;
349
350        // SAFETY: By resetting the offsets we simply "shift" everything left and discard trailing garbage, so all invariants remain the same.
351        Ok(unsafe { ListArray::new_unchecked(elements, adjusted_offsets, self.list_validity()) })
352    }
353}
354impl<T: TypedArrayRef<List>> ListArrayExt for T {}
355
356impl Array<List> {
357    /// Creates a new `ListArray`.
358    pub fn new(elements: ArrayRef, offsets: ArrayRef, validity: Validity) -> Self {
359        let dtype = DType::List(Arc::new(elements.dtype().clone()), validity.nullability());
360        let len = offsets.len().saturating_sub(1);
361        let slots = ListData::make_slots(&elements, &offsets, &validity, len);
362        let data = ListData::build(elements, offsets, validity);
363        unsafe {
364            Array::from_parts_unchecked(ArrayParts::new(List, dtype, len, data).with_slots(slots))
365        }
366    }
367
368    /// Constructs a new `ListArray`.
369    pub fn try_new(
370        elements: ArrayRef,
371        offsets: ArrayRef,
372        validity: Validity,
373    ) -> VortexResult<Self> {
374        let dtype = DType::List(Arc::new(elements.dtype().clone()), validity.nullability());
375        let len = offsets.len().saturating_sub(1);
376        let slots = ListData::make_slots(&elements, &offsets, &validity, len);
377        let data = ListData::try_build(elements, offsets, validity)?;
378        Ok(unsafe {
379            Array::from_parts_unchecked(ArrayParts::new(List, dtype, len, data).with_slots(slots))
380        })
381    }
382
383    /// Creates a new `ListArray` without validation.
384    ///
385    /// # Safety
386    ///
387    /// See [`ListData::new_unchecked`].
388    pub unsafe fn new_unchecked(elements: ArrayRef, offsets: ArrayRef, validity: Validity) -> Self {
389        let dtype = DType::List(Arc::new(elements.dtype().clone()), validity.nullability());
390        let len = offsets.len().saturating_sub(1);
391        let slots = ListData::make_slots(&elements, &offsets, &validity, len);
392        let data = unsafe { ListData::new_unchecked() };
393        unsafe {
394            Array::from_parts_unchecked(ArrayParts::new(List, dtype, len, data).with_slots(slots))
395        }
396    }
397
398    pub fn into_data_parts(self) -> ListDataParts {
399        let dtype = self.dtype().clone();
400        let elements = self.slots()[ListSlots::ELEMENTS]
401            .clone()
402            .vortex_expect("ListArray elements slot");
403        let offsets = self.slots()[ListSlots::OFFSETS]
404            .clone()
405            .vortex_expect("ListArray offsets slot");
406        let validity = self.list_validity();
407        ListDataParts {
408            elements,
409            offsets,
410            validity,
411            dtype,
412        }
413    }
414}