Skip to main content

mz_repr/adt/
array.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! A multi-dimensional array data type.
11
12use std::cmp::Ordering;
13use std::error::Error;
14use std::{fmt, mem};
15
16use mz_ore::cast::CastFrom;
17use mz_persist_types::columnar::FixedSizeCodec;
18use mz_proto::{RustType, TryFromProtoError};
19#[cfg(any(test, feature = "proptest"))]
20use proptest_derive::Arbitrary;
21use serde::{Deserialize, Serialize};
22
23use crate::Datum;
24use crate::row::DatumList;
25use crate::scalar::SqlScalarType;
26
27include!(concat!(env!("OUT_DIR"), "/mz_repr.adt.array.rs"));
28
29/// The maximum number of dimensions permitted in an array.
30pub const MAX_ARRAY_DIMENSIONS: u8 = 6;
31
32/// A variable-length multi-dimensional array.
33///
34/// The type parameter `T` represents the element type of the array. It is a
35/// phantom parameter propagated through the inner [`DatumList`] — the actual
36/// elements are stored as serialized bytes and `T` is not enforced at
37/// runtime. It is up to the caller to ensure `T` matches the actual element
38/// type. The default `T = Datum<'a>` means existing code that writes
39/// `Array<'a>` continues to work unchanged.
40#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
41pub struct Array<'a, T = Datum<'a>> {
42    // NOTE: Declared before `dims` so that the derived `Ord` compares the elements first, as
43    // PostgreSQL does.
44    /// The elements in the array.
45    pub(crate) elements: DatumList<'a, T>,
46    /// The dimensions of the array.
47    pub(crate) dims: ArrayDimensions<'a>,
48}
49
50impl<'a, T> Array<'a, T> {
51    /// Returns the dimensions of the array.
52    pub fn dims(&self) -> ArrayDimensions<'a> {
53        self.dims
54    }
55
56    /// Returns the elements of the array.
57    pub fn elements(&self) -> DatumList<'a, T> {
58        self.elements
59    }
60
61    /// Returns true if this array's dimensions are valid for the Int2Vector type.
62    /// Int2Vector is 1-D; empty arrays use 0 dimensions (PostgreSQL convention).
63    pub fn has_int2vector_dims(&self) -> bool {
64        self.dims().len() == 1
65            || (self.dims().len() == 0 && self.elements().iter().next().is_none())
66    }
67}
68
69impl<'a> crate::scalar::SqlContainerType for Array<'a, Datum<'a>> {
70    fn unwrap_element_type(container: &SqlScalarType) -> &SqlScalarType {
71        container.unwrap_array_element_type()
72    }
73    fn wrap_element_type(element: SqlScalarType) -> SqlScalarType {
74        SqlScalarType::Array(Box::new(element))
75    }
76}
77
78/// The dimensions of an [`Array`].
79#[derive(Clone, Copy, Eq, PartialEq, Hash)]
80pub struct ArrayDimensions<'a> {
81    pub(crate) data: &'a [u8],
82}
83
84impl Default for ArrayDimensions<'static> {
85    fn default() -> Self {
86        Self { data: &[] }
87    }
88}
89
90impl ArrayDimensions<'_> {
91    /// Returns the number of dimensions in the array as a [`u8`].
92    pub fn ndims(&self) -> u8 {
93        let ndims = self.data.len() / (mem::size_of::<usize>() * 2);
94        ndims.try_into().expect("ndims is known to fit in a u8")
95    }
96
97    /// Returns the number of the dimensions in the array as a [`usize`].
98    pub fn len(&self) -> usize {
99        self.ndims().into()
100    }
101
102    /// Reports whether the number of dimensions in the array is zero.
103    pub fn is_empty(&self) -> bool {
104        self.len() == 0
105    }
106}
107
108/// Orders like PostgreSQL's `array_cmp` orders arrays with equal elements: by the number of
109/// dimensions, then by the length of each dimension, then by the lower bound of each dimension.
110/// Only equal dimensions compare as `Ordering::Equal`, which keeps `Ord` consistent with `Eq`.
111impl Ord for ArrayDimensions<'_> {
112    fn cmp(&self, other: &Self) -> Ordering {
113        self.ndims()
114            .cmp(&other.ndims())
115            .then_with(|| {
116                self.into_iter()
117                    .map(|d| d.length)
118                    .cmp(other.into_iter().map(|d| d.length))
119            })
120            .then_with(|| {
121                self.into_iter()
122                    .map(|d| d.lower_bound)
123                    .cmp(other.into_iter().map(|d| d.lower_bound))
124            })
125    }
126}
127
128impl PartialOrd for ArrayDimensions<'_> {
129    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
130        Some(self.cmp(other))
131    }
132}
133
134impl fmt::Debug for ArrayDimensions<'_> {
135    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
136        f.debug_list().entries(*self).finish()
137    }
138}
139
140impl<'a> IntoIterator for ArrayDimensions<'a> {
141    type Item = ArrayDimension;
142    type IntoIter = ArrayDimensionsIter<'a>;
143
144    fn into_iter(self) -> ArrayDimensionsIter<'a> {
145        ArrayDimensionsIter { data: self.data }
146    }
147}
148
149/// An iterator over the dimensions in an [`ArrayDimensions`].
150#[derive(Debug)]
151pub struct ArrayDimensionsIter<'a> {
152    data: &'a [u8],
153}
154
155impl Iterator for ArrayDimensionsIter<'_> {
156    type Item = ArrayDimension;
157
158    fn next(&mut self) -> Option<ArrayDimension> {
159        if self.data.is_empty() {
160            None
161        } else {
162            let sz = mem::size_of::<usize>();
163            let lower_bound = isize::from_ne_bytes(self.data[..sz].try_into().unwrap());
164            self.data = &self.data[sz..];
165            let length = usize::from_ne_bytes(self.data[..sz].try_into().unwrap());
166            self.data = &self.data[sz..];
167            Some(ArrayDimension {
168                lower_bound,
169                length,
170            })
171        }
172    }
173}
174
175/// The specification of one dimension of an [`Array`].
176#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
177pub struct ArrayDimension {
178    /// The "logical" index at which this dimension begins. This value has no bearing on the
179    /// physical layout of the data, only how users want to use its indices (which may be negative).
180    pub lower_bound: isize,
181    /// The number of elements in this array.
182    pub length: usize,
183}
184
185impl ArrayDimension {
186    /// Presents the "logical indices" of the array, i.e. those that are revealed to the user.
187    ///
188    /// # Panics
189    /// - If the array contain more than [`isize::MAX`] elements (i.e. more than 9EB of data).
190    pub fn dimension_bounds(&self) -> (isize, isize) {
191        (
192            self.lower_bound,
193            self.lower_bound
194                + isize::try_from(self.length).expect("fewer than isize::MAX elements")
195                - 1,
196        )
197    }
198}
199
200/// An error that can occur when constructing an array.
201#[derive(
202    Clone,
203    Copy,
204    Debug,
205    Eq,
206    PartialEq,
207    Hash,
208    Ord,
209    PartialOrd,
210    Serialize,
211    Deserialize
212)]
213#[cfg_attr(any(test, feature = "proptest"), derive(Arbitrary))]
214pub enum InvalidArrayError {
215    /// The number of dimensions in the array exceeds [`MAX_ARRAY_DIMENSIONS]`.
216    TooManyDimensions(usize),
217    /// The number of array elements does not match the cardinality derived from
218    /// its dimensions.
219    WrongCardinality { actual: usize, expected: usize },
220}
221
222impl fmt::Display for InvalidArrayError {
223    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
224        match self {
225            InvalidArrayError::TooManyDimensions(n) => write!(
226                f,
227                "number of array dimensions ({}) exceeds the maximum allowed ({})",
228                n, MAX_ARRAY_DIMENSIONS
229            ),
230            InvalidArrayError::WrongCardinality { actual, expected } => write!(
231                f,
232                "number of array elements ({}) does not match declared cardinality ({})",
233                actual, expected
234            ),
235        }
236    }
237}
238
239impl Error for InvalidArrayError {
240    fn source(&self) -> Option<&(dyn Error + 'static)> {
241        None
242    }
243}
244
245impl RustType<ProtoInvalidArrayError> for InvalidArrayError {
246    fn into_proto(&self) -> ProtoInvalidArrayError {
247        use Kind::*;
248        use proto_invalid_array_error::*;
249        let kind = match self {
250            InvalidArrayError::TooManyDimensions(dims) => TooManyDimensions(dims.into_proto()),
251            InvalidArrayError::WrongCardinality { actual, expected } => {
252                WrongCardinality(ProtoWrongCardinality {
253                    actual: actual.into_proto(),
254                    expected: expected.into_proto(),
255                })
256            }
257        };
258        ProtoInvalidArrayError { kind: Some(kind) }
259    }
260
261    fn from_proto(proto: ProtoInvalidArrayError) -> Result<Self, TryFromProtoError> {
262        use proto_invalid_array_error::Kind::*;
263        match proto.kind {
264            Some(kind) => match kind {
265                TooManyDimensions(dims) => Ok(InvalidArrayError::TooManyDimensions(
266                    usize::from_proto(dims)?,
267                )),
268                WrongCardinality(v) => Ok(InvalidArrayError::WrongCardinality {
269                    actual: usize::from_proto(v.actual)?,
270                    expected: usize::from_proto(v.expected)?,
271                }),
272            },
273            None => Err(TryFromProtoError::missing_field(
274                "`ProtoInvalidArrayError::kind`",
275            )),
276        }
277    }
278}
279
280/// An encoded packed variant of [`ArrayDimension`].
281///
282/// We uphold the variant that [`PackedArrayDimension`] sorts the same as
283/// [`ArrayDimension`].
284#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
285pub struct PackedArrayDimension([u8; Self::SIZE]);
286
287// `as` conversions are okay here because we're doing bit level logic to make
288// sure the sort order of the packed binary is correct. This is implementation
289// is proptest-ed below.
290#[allow(clippy::as_conversions)]
291impl FixedSizeCodec<ArrayDimension> for PackedArrayDimension {
292    const SIZE: usize = 16;
293
294    fn as_bytes(&self) -> &[u8] {
295        &self.0
296    }
297
298    fn from_bytes(slice: &[u8]) -> Result<Self, String> {
299        let buf: [u8; Self::SIZE] = slice.try_into().map_err(|_| {
300            format!(
301                "size for PackedArrayDimension is {} bytes, got {}",
302                Self::SIZE,
303                slice.len()
304            )
305        })?;
306        Ok(PackedArrayDimension(buf))
307    }
308
309    fn from_value(value: ArrayDimension) -> Self {
310        let mut buf = [0; 16];
311
312        let lower_bound = (i64::cast_from(value.lower_bound) as u64) ^ (0x8000_0000_0000_0000u64);
313        buf[..8].copy_from_slice(&lower_bound.to_be_bytes());
314        let length = u64::cast_from(value.length);
315        buf[8..].copy_from_slice(&length.to_be_bytes());
316
317        PackedArrayDimension(buf)
318    }
319
320    fn into_value(self) -> ArrayDimension {
321        let mut lower_bound: [u8; 8] = self.0[..8].try_into().unwrap();
322        lower_bound.copy_from_slice(&self.0[..8]);
323        let lower_bound = u64::from_be_bytes(lower_bound) ^ 0x8000_0000_0000_0000u64;
324
325        let mut length: [u8; 8] = self.0[8..].try_into().unwrap();
326        length.copy_from_slice(&self.0[8..]);
327        let length = u64::from_be_bytes(length);
328
329        ArrayDimension {
330            lower_bound: isize::cast_from(lower_bound as i64),
331            length: usize::cast_from(length),
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use std::iter::empty;
339
340    use mz_ore::assert_ok;
341    use mz_proto::protobuf_roundtrip;
342    use proptest::prelude::*;
343
344    use crate::Datum;
345    use crate::row::Row;
346
347    use super::*;
348
349    #[mz_ore::test]
350    fn test_has_int2vector_dims() {
351        // 1-D array with elements: valid for int2vector
352        let mut row = Row::default();
353        row.packer()
354            .try_push_array(
355                &[ArrayDimension {
356                    lower_bound: 1,
357                    length: 2,
358                }],
359                [Datum::Int16(1), Datum::Int16(2)],
360            )
361            .unwrap();
362        let arr = row.unpack_first().unwrap_array();
363        assert!(
364            arr.has_int2vector_dims(),
365            "1-D array should have int2vector dims"
366        );
367
368        // 1-D empty array (length 0): valid
369        let mut row = Row::default();
370        row.packer()
371            .try_push_array(
372                &[ArrayDimension {
373                    lower_bound: 1,
374                    length: 0,
375                }],
376                empty::<Datum>(),
377            )
378            .unwrap();
379        let arr = row.unpack_first().unwrap_array();
380        assert!(
381            arr.has_int2vector_dims(),
382            "1-D empty array should have int2vector dims"
383        );
384
385        // 0-D empty array (PostgreSQL convention for empty): valid
386        let mut row = Row::default();
387        row.packer().try_push_array(&[], empty::<Datum>()).unwrap();
388        let arr = row.unpack_first().unwrap_array();
389        assert!(
390            arr.has_int2vector_dims(),
391            "0-D empty array should have int2vector dims"
392        );
393
394        // 2-D array: invalid for int2vector
395        let mut row = Row::default();
396        row.packer()
397            .try_push_array(
398                &[
399                    ArrayDimension {
400                        lower_bound: 1,
401                        length: 1,
402                    },
403                    ArrayDimension {
404                        lower_bound: 1,
405                        length: 2,
406                    },
407                ],
408                [Datum::Int16(1), Datum::Int16(2)],
409            )
410            .unwrap();
411        let arr = row.unpack_first().unwrap_array();
412        assert!(
413            !arr.has_int2vector_dims(),
414            "2-D array should not have int2vector dims"
415        );
416    }
417
418    proptest! {
419        #[mz_ore::test]
420        fn invalid_array_error_protobuf_roundtrip(expect in any::<InvalidArrayError>()) {
421            let actual = protobuf_roundtrip::<_, ProtoInvalidArrayError>(&expect);
422            assert_ok!(actual);
423            assert_eq!(actual.unwrap(), expect);
424        }
425    }
426
427    fn arb_array_dimension() -> impl Strategy<Value = ArrayDimension> {
428        (any::<isize>(), any::<usize>()).prop_map(|(lower, length)| ArrayDimension {
429            lower_bound: lower,
430            length,
431        })
432    }
433
434    #[mz_ore::test]
435    fn proptest_packed_array_dimension_roundtrip() {
436        fn test(og: ArrayDimension) {
437            let packed = PackedArrayDimension::from_value(og);
438            let rnd = packed.into_value();
439            assert_eq!(og, rnd);
440        }
441
442        proptest!(|(dim in arb_array_dimension())| test(dim))
443    }
444
445    #[mz_ore::test]
446    fn proptest_packed_array_dimension_sorts() {
447        fn test(mut og: Vec<ArrayDimension>) {
448            let mut packed: Vec<_> = og
449                .iter()
450                .copied()
451                .map(PackedArrayDimension::from_value)
452                .collect();
453
454            packed.sort();
455            og.sort();
456
457            let rnd: Vec<_> = packed
458                .into_iter()
459                .map(PackedArrayDimension::into_value)
460                .collect();
461            assert_eq!(og, rnd);
462        }
463
464        let strat = proptest::collection::vec(arb_array_dimension(), 0..16);
465        proptest!(|(dim in strat)| test(dim))
466    }
467}