Skip to main content

vortex_array/scalar_fn/fns/between/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4mod kernel;
5
6use std::fmt::Display;
7use std::fmt::Formatter;
8
9pub use kernel::*;
10use prost::Message;
11use vortex_error::VortexResult;
12use vortex_error::vortex_bail;
13use vortex_session::VortexSession;
14use vortex_session::registry::CachedId;
15
16use crate::ArrayRef;
17use crate::Canonical;
18use crate::ExecutionCtx;
19use crate::IntoArray;
20use crate::arrays::ConstantArray;
21use crate::arrays::Decimal;
22use crate::arrays::Primitive;
23use crate::arrays::ScalarFnArray;
24use crate::builtins::ArrayBuiltins;
25use crate::dtype::DType;
26use crate::dtype::DType::Bool;
27use crate::expr::display::ExprDisplay;
28use crate::proto::expr as pb;
29use crate::scalar::Scalar;
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::scalar_fn::ScalarFnVTableExt;
36use crate::scalar_fn::fns::operators::Operator;
37
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct BetweenOptions {
40    pub lower_strict: StrictComparison,
41    pub upper_strict: StrictComparison,
42}
43
44impl Display for BetweenOptions {
45    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
46        let lower_op = if self.lower_strict.is_strict() {
47            "<"
48        } else {
49            "<="
50        };
51        let upper_op = if self.upper_strict.is_strict() {
52            "<"
53        } else {
54            "<="
55        };
56        write!(f, "lower_strict: {}, upper_strict: {}", lower_op, upper_op)
57    }
58}
59
60/// Strictness of the comparison.
61#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
62pub enum StrictComparison {
63    /// Strict bound (`<`)
64    Strict,
65    /// Non-strict bound (`<=`)
66    NonStrict,
67}
68
69impl StrictComparison {
70    pub const fn to_operator(&self) -> Operator {
71        match self {
72            StrictComparison::Strict => Operator::Lt,
73            StrictComparison::NonStrict => Operator::Lte,
74        }
75    }
76
77    pub const fn is_strict(&self) -> bool {
78        matches!(self, StrictComparison::Strict)
79    }
80}
81
82/// Short-circuits between for the inputs that need no encoding-specific work.
83///
84/// Returns `Some(result)` when the answer is already known (empty array, null bounds), or `None`
85/// when between must proceed with the encoding-specific implementation. Kernels can therefore rely
86/// on both bounds being non-null.
87///
88/// The result can be a lazy [`ScalarFn`] array, so a caller that needs a computed array
89/// **must** execute it.
90///
91/// [`ScalarFn`]: crate::arrays::ScalarFn
92pub(super) fn short_circuit(
93    arr: &ArrayRef,
94    lower: &ArrayRef,
95    upper: &ArrayRef,
96    options: &BetweenOptions,
97) -> VortexResult<Option<ArrayRef>> {
98    let return_dtype =
99        Bool(arr.dtype().nullability() | lower.dtype().nullability() | upper.dtype().nullability());
100
101    // Bail early if the array is empty.
102    if arr.is_empty() {
103        return Ok(Some(Canonical::empty(&return_dtype).into_array()));
104    }
105
106    let lower_is_null = lower.as_constant().is_some_and(|v| v.is_null());
107    let upper_is_null = upper.as_constant().is_some_and(|v| v.is_null());
108
109    // `Between` is not strict, and Kleene `AND` gives `null AND false = false`, so a null bound
110    // cannot falsify a row on its own. Every row is null only when both bounds are null.
111    if lower_is_null && upper_is_null {
112        return Ok(Some(
113            ConstantArray::new(Scalar::null(return_dtype), arr.len()).into_array(),
114        ));
115    }
116
117    // Every kernel requires non-null constant bounds, so a single null bound leaves nothing to
118    // dispatch to. The two compares keep the surviving bound, which can still falsify rows.
119    if lower_is_null || upper_is_null {
120        return as_two_compares(arr, lower, upper, options).map(Some);
121    }
122
123    Ok(None)
124}
125
126/// The two compares that `Between` stands for, combined with Kleene `AND`.
127///
128/// The returned array is lazy, so a reduce rule can call this function.
129fn as_two_compares(
130    arr: &ArrayRef,
131    lower: &ArrayRef,
132    upper: &ArrayRef,
133    options: &BetweenOptions,
134) -> VortexResult<ArrayRef> {
135    let lower_cmp = lower.binary(arr.clone(), options.lower_strict.to_operator())?;
136    let upper_cmp = arr.binary(upper.clone(), options.upper_strict.to_operator())?;
137    lower_cmp.binary(upper_cmp, Operator::And)
138}
139
140/// Between on a canonical array by directly dispatching to the appropriate kernel.
141///
142/// Falls back to [`as_two_compares`] if no kernel handles the input.
143fn between_canonical(
144    arr: &ArrayRef,
145    lower: &ArrayRef,
146    upper: &ArrayRef,
147    options: &BetweenOptions,
148    ctx: &mut ExecutionCtx,
149) -> VortexResult<ArrayRef> {
150    if let Some(result) = short_circuit(arr, lower, upper, options)? {
151        // TODO(joe): return the lazy array directly, blocked on the same executor support as the
152        // fallback below. Only the single-null-bound case is lazy, so this forces it for now.
153        return result.execute::<ArrayRef>(ctx);
154    }
155
156    // Try type-specific kernels
157    if let Some(prim) = arr.as_opt::<Primitive>()
158        && let Some(result) =
159            <Primitive as BetweenKernel>::between(prim, lower, upper, options, ctx)?
160    {
161        return Ok(result);
162    }
163    if let Some(dec) = arr.as_opt::<Decimal>()
164        && let Some(result) = <Decimal as BetweenKernel>::between(dec, lower, upper, options, ctx)?
165    {
166        return Ok(result);
167    }
168
169    // TODO(joe): return lazy compare once the executor supports this
170    // Fall back to compare + boolean and
171    as_two_compares(arr, lower, upper, options)?.execute::<ArrayRef>(ctx)
172}
173
174/// An optimized scalar expression to compute whether values fall between two bounds.
175///
176/// This expression takes three children:
177/// 1. The array of values to check.
178/// 2. The lower bound.
179/// 3. The upper bound.
180///
181/// The comparison strictness is controlled by the metadata.
182///
183/// NOTE: this expression will shortly be removed in favor of pipelined computation of two
184/// separate comparisons combined with a logical AND.
185#[derive(Clone)]
186pub struct Between;
187
188impl Between {
189    /// Creates a lazy between operation over an array and its bounds.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if the children have different lengths or incompatible dtypes.
194    pub fn try_new(
195        array: ArrayRef,
196        lower: ArrayRef,
197        upper: ArrayRef,
198        options: BetweenOptions,
199    ) -> VortexResult<ScalarFnArray> {
200        ScalarFnArray::try_new(Between.bind(options), vec![array, lower, upper])
201    }
202}
203
204impl ScalarFnVTable for Between {
205    type Options = BetweenOptions;
206
207    fn id(&self) -> ScalarFnId {
208        static ID: CachedId = CachedId::new("vortex.between");
209        *ID
210    }
211
212    fn serialize(&self, instance: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
213        Ok(Some(
214            pb::BetweenOpts {
215                lower_strict: instance.lower_strict.is_strict(),
216                upper_strict: instance.upper_strict.is_strict(),
217            }
218            .encode_to_vec(),
219        ))
220    }
221
222    fn deserialize(
223        &self,
224        _metadata: &[u8],
225        _session: &VortexSession,
226    ) -> VortexResult<Self::Options> {
227        let opts = pb::BetweenOpts::decode(_metadata)?;
228        Ok(BetweenOptions {
229            lower_strict: if opts.lower_strict {
230                StrictComparison::Strict
231            } else {
232                StrictComparison::NonStrict
233            },
234            upper_strict: if opts.upper_strict {
235                StrictComparison::Strict
236            } else {
237                StrictComparison::NonStrict
238            },
239        })
240    }
241
242    fn arity(&self, _options: &Self::Options) -> Arity {
243        Arity::Exact(3)
244    }
245
246    fn child_name(&self, _instance: &Self::Options, child_idx: usize) -> ChildName {
247        match child_idx {
248            0 => ChildName::from("array"),
249            1 => ChildName::from("lower"),
250            2 => ChildName::from("upper"),
251            _ => unreachable!("Invalid child index {} for Between expression", child_idx),
252        }
253    }
254
255    fn fmt_sql(
256        &self,
257        options: &Self::Options,
258        expr: &dyn ExprDisplay,
259        f: &mut Formatter<'_>,
260    ) -> std::fmt::Result {
261        let lower_op = if options.lower_strict.is_strict() {
262            "<"
263        } else {
264            "<="
265        };
266        let upper_op = if options.upper_strict.is_strict() {
267            "<"
268        } else {
269            "<="
270        };
271        write!(
272            f,
273            "({} {} {} {} {})",
274            expr.display_child(1),
275            lower_op,
276            expr.display_child(0),
277            upper_op,
278            expr.display_child(2)
279        )
280    }
281
282    fn return_dtype(&self, _options: &Self::Options, arg_dtypes: &[DType]) -> VortexResult<DType> {
283        let arr_dt = &arg_dtypes[0];
284        let lower_dt = &arg_dtypes[1];
285        let upper_dt = &arg_dtypes[2];
286
287        if !arr_dt.eq_ignore_nullability(lower_dt) {
288            vortex_bail!(
289                "Array dtype {} does not match lower dtype {}",
290                arr_dt,
291                lower_dt
292            );
293        }
294        if !arr_dt.eq_ignore_nullability(upper_dt) {
295            vortex_bail!(
296                "Array dtype {} does not match upper dtype {}",
297                arr_dt,
298                upper_dt
299            );
300        }
301
302        Ok(Bool(
303            arr_dt.nullability() | lower_dt.nullability() | upper_dt.nullability(),
304        ))
305    }
306
307    fn execute(
308        &self,
309        options: &Self::Options,
310        args: &dyn ExecutionArgs,
311        ctx: &mut ExecutionCtx,
312    ) -> VortexResult<ArrayRef> {
313        let arr = args.get(0)?;
314        let lower = args.get(1)?;
315        let upper = args.get(2)?;
316
317        // canonicalize the arr and we might be able to run a between kernels over that.
318        if !arr.is_canonical() {
319            return arr.execute::<Canonical>(ctx)?.into_array().between(
320                lower,
321                upper,
322                options.clone(),
323            );
324        }
325
326        between_canonical(&arr, &lower, &upper, options, ctx)
327    }
328
329    fn is_strict(&self, _options: &Self::Options) -> bool {
330        false // NULL AND false = false
331    }
332
333    fn is_infallible(&self, _options: &Self::Options) -> bool {
334        true
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use std::sync::LazyLock;
341
342    use rstest::rstest;
343    use vortex_buffer::buffer;
344
345    use super::*;
346    use crate::IntoArray;
347    use crate::VortexSessionExecute;
348    use crate::arrays::BoolArray;
349    use crate::arrays::DecimalArray;
350    use crate::arrays::PrimitiveArray;
351    use crate::arrays::StructArray;
352    use crate::assert_arrays_eq;
353    use crate::dtype::DType;
354    use crate::dtype::DecimalDType;
355    use crate::dtype::Nullability;
356    use crate::dtype::PType;
357    use crate::expr::between;
358    use crate::expr::col;
359    use crate::expr::get_item;
360    use crate::expr::lit;
361    use crate::expr::root;
362    use crate::scalar::DecimalValue;
363    use crate::scalar::Scalar;
364    use crate::test_harness::to_int_indices;
365    use crate::validity::Validity;
366
367    static SESSION: LazyLock<VortexSession> = LazyLock::new(crate::array_session);
368
369    const NON_STRICT: BetweenOptions = BetweenOptions {
370        lower_strict: StrictComparison::NonStrict,
371        upper_strict: StrictComparison::NonStrict,
372    };
373
374    /// `len` null `i32` values held as a [`ConstantArray`], which is what `as_constant` sees.
375    fn null_i32s(len: usize) -> ArrayRef {
376        let null = Scalar::null(DType::Primitive(PType::I32, Nullability::Nullable));
377        ConstantArray::new(null, len).into_array()
378    }
379
380    #[test]
381    fn lazy_validity() -> VortexResult<()> {
382        let ctx = &mut SESSION.create_execution_ctx();
383
384        let x = PrimitiveArray::from_option_iter([Some(10), Some(10), Some(1)]).into_array();
385        let lo = PrimitiveArray::from_option_iter([None, None, Some(0)]).into_array();
386        let hi = PrimitiveArray::from_option_iter([Some(5), Some(50), Some(5)]).into_array();
387
388        let data = StructArray::from_fields(&[("x", x), ("lo", lo), ("hi", hi)])?.into_array();
389        let lazy = data.apply(&between(col("x"), col("lo"), col("hi"), NON_STRICT))?;
390
391        let Validity::Array(validity) = lazy.validity()? else {
392            vortex_bail!("non-lazy validity");
393        };
394        assert_eq!(
395            validity.encoding_id().to_string(),
396            "vortex.scalar_fn_validity"
397        );
398        assert_arrays_eq!(validity, BoolArray::from_iter([true, false, true]), ctx);
399        Ok(())
400    }
401
402    /// A declared validity expression must agree with the mask of the executed result.
403    ///
404    /// The bounds are columns rather than literals so that a null bound reaches execution
405    /// instead of being intercepted as a constant.
406    #[test]
407    fn validity_agrees_with_execution() -> VortexResult<()> {
408        let ctx = &mut SESSION.create_execution_ctx();
409
410        //  x     lo     hi     expected
411        //  10    null   5      false, since the upper bound alone falsifies the row
412        //  10    null   50     null, since neither bound falsifies the row
413        //  1     0      5      true
414        let x = PrimitiveArray::from_option_iter([Some(10), Some(10), Some(1)]).into_array();
415        let lo = PrimitiveArray::from_option_iter([None, None, Some(0)]).into_array();
416        let hi = PrimitiveArray::from_option_iter([Some(5), Some(50), Some(5)]).into_array();
417        let data = StructArray::from_fields(&[("x", x), ("lo", lo), ("hi", hi)])?.into_array();
418
419        let expr = between(col("x"), col("lo"), col("hi"), NON_STRICT);
420
421        let executed = data
422            .clone()
423            .apply(&expr)?
424            .execute::<BoolArray>(ctx)?
425            .opt_bool_vec(ctx);
426
427        let validity_expr = expr.bind(data.dtype())?.validity()?;
428        let declared = data
429            .apply_bound(&validity_expr)?
430            .execute::<BoolArray>(ctx)?
431            .bool_vec(ctx);
432
433        assert_eq!(executed, [Some(false), None, Some(true)]);
434        assert_eq!(
435            executed.iter().map(Option::is_some).collect::<Vec<_>>(),
436            declared
437        );
438
439        Ok(())
440    }
441
442    #[test]
443    fn is_not_strict() {
444        let expr = between(
445            root(),
446            lit(0),
447            lit(100),
448            BetweenOptions {
449                lower_strict: StrictComparison::NonStrict,
450                upper_strict: StrictComparison::NonStrict,
451            },
452        );
453
454        assert!(!expr.as_scalar().is_some_and(|f| f.signature().is_strict()));
455    }
456
457    #[test]
458    fn test_display() {
459        let expr = between(
460            get_item("score", root()),
461            lit(10),
462            lit(50),
463            BetweenOptions {
464                lower_strict: StrictComparison::NonStrict,
465                upper_strict: StrictComparison::Strict,
466            },
467        );
468        assert_eq!(expr.to_string(), "(10i32 <= $.score < 50i32)");
469
470        let expr2 = between(
471            root(),
472            lit(0),
473            lit(100),
474            BetweenOptions {
475                lower_strict: StrictComparison::Strict,
476                upper_strict: StrictComparison::NonStrict,
477            },
478        );
479        assert_eq!(expr2.to_string(), "(0i32 < $ <= 100i32)");
480    }
481
482    #[rstest]
483    #[case(StrictComparison::NonStrict, StrictComparison::NonStrict, vec![0, 1, 2, 3])]
484    #[case(StrictComparison::NonStrict, StrictComparison::Strict, vec![0, 1])]
485    #[case(StrictComparison::Strict, StrictComparison::NonStrict, vec![0, 2])]
486    #[case(StrictComparison::Strict, StrictComparison::Strict, vec![0])]
487    fn test_bounds(
488        #[case] lower_strict: StrictComparison,
489        #[case] upper_strict: StrictComparison,
490        #[case] expected: Vec<u64>,
491    ) {
492        let lower = buffer![0, 0, 0, 0, 2].into_array();
493        let array = buffer![1, 0, 1, 0, 1].into_array();
494        let upper = buffer![2, 1, 1, 0, 0].into_array();
495        let ctx = &mut SESSION.create_execution_ctx();
496
497        let matches = between_canonical(
498            &array,
499            &lower,
500            &upper,
501            &BetweenOptions {
502                lower_strict,
503                upper_strict,
504            },
505            ctx,
506        )
507        .unwrap()
508        .execute::<BoolArray>(ctx)
509        .unwrap();
510
511        let indices = to_int_indices(matches, ctx).unwrap();
512        assert_eq!(indices, expected);
513    }
514
515    #[test]
516    fn test_constants() {
517        let lower = buffer![0, 0, 2, 0, 2].into_array();
518        let array = buffer![1, 0, 1, 0, 1].into_array();
519        let ctx = &mut SESSION.create_execution_ctx();
520
521        // upper is null
522        let upper = ConstantArray::new(
523            Scalar::null(DType::Primitive(PType::I32, Nullability::Nullable)),
524            5,
525        )
526        .into_array();
527
528        let matches = between_canonical(
529            &array,
530            &lower,
531            &upper,
532            &BetweenOptions {
533                lower_strict: StrictComparison::NonStrict,
534                upper_strict: StrictComparison::NonStrict,
535            },
536            ctx,
537        )
538        .unwrap()
539        .execute::<BoolArray>(ctx)
540        .unwrap();
541
542        // The rows the lower bound already falsified stay false rather than becoming null.
543        assert_eq!(
544            matches.opt_bool_vec(ctx),
545            [None, None, Some(false), None, Some(false)]
546        );
547
548        // upper is a fixed constant
549        let upper = ConstantArray::new(Scalar::from(2), 5).into_array();
550        let matches = between_canonical(
551            &array,
552            &lower,
553            &upper,
554            &BetweenOptions {
555                lower_strict: StrictComparison::NonStrict,
556                upper_strict: StrictComparison::NonStrict,
557            },
558            ctx,
559        )
560        .unwrap()
561        .execute::<BoolArray>(ctx)
562        .unwrap();
563        let indices = to_int_indices(matches, ctx).unwrap();
564        assert_eq!(indices, vec![0, 1, 3]);
565
566        // lower is also a constant
567        let lower = ConstantArray::new(Scalar::from(0), 5).into_array();
568
569        let matches = between_canonical(
570            &array,
571            &lower,
572            &upper,
573            &BetweenOptions {
574                lower_strict: StrictComparison::NonStrict,
575                upper_strict: StrictComparison::NonStrict,
576            },
577            ctx,
578        )
579        .unwrap()
580        .execute::<BoolArray>(ctx)
581        .unwrap();
582        let indices = to_int_indices(matches, ctx).unwrap();
583        assert_eq!(indices, vec![0, 1, 2, 3, 4]);
584    }
585
586    /// `Between` is not strict, so a null bound only makes a row null when the surviving
587    /// comparison is not already false. This must not depend on how the bound is encoded, and
588    /// compression stores an all-null chunk as a [`ConstantArray`].
589    #[rstest]
590    #[case::primitive_nulls(PrimitiveArray::from_option_iter([None::<i32>, None]).into_array())]
591    #[case::constant_null(null_i32s(2))]
592    fn null_lower_bound(#[case] lower: ArrayRef) -> VortexResult<()> {
593        let ctx = &mut SESSION.create_execution_ctx();
594        let array = buffer![10, 10].into_array();
595        let upper = buffer![5, 50].into_array();
596
597        let result = between_canonical(&array, &lower, &upper, &NON_STRICT, ctx)?
598            .execute::<BoolArray>(ctx)?;
599
600        // Row 0 stays false because the upper bound falsifies it on its own.
601        assert_eq!(result.opt_bool_vec(ctx), [Some(false), None]);
602
603        Ok(())
604    }
605
606    /// With both bounds null no comparison can falsify a row, so every row is null.
607    #[test]
608    fn both_bounds_null() -> VortexResult<()> {
609        let ctx = &mut SESSION.create_execution_ctx();
610        let array = buffer![10, 10].into_array();
611        let bound = null_i32s(2);
612
613        let result = between_canonical(&array, &bound, &bound, &NON_STRICT, ctx)?
614            .execute::<BoolArray>(ctx)?;
615
616        assert_eq!(result.opt_bool_vec(ctx), [None, None]);
617
618        Ok(())
619    }
620
621    #[test]
622    fn test_between_decimal() {
623        let ctx = &mut SESSION.create_execution_ctx();
624        let values = buffer![100i128, 200i128, 300i128, 400i128];
625        let decimal_type = DecimalDType::new(3, 2);
626        let array = DecimalArray::new(values, decimal_type, Validity::NonNullable).into_array();
627
628        let lower = ConstantArray::new(
629            Scalar::decimal(
630                DecimalValue::I128(100i128),
631                decimal_type,
632                Nullability::NonNullable,
633            ),
634            array.len(),
635        )
636        .into_array();
637        let upper = ConstantArray::new(
638            Scalar::decimal(
639                DecimalValue::I128(400i128),
640                decimal_type,
641                Nullability::NonNullable,
642            ),
643            array.len(),
644        )
645        .into_array();
646
647        // Strict lower bound, non-strict upper bound
648        let between_strict = between_canonical(
649            &array,
650            &lower,
651            &upper,
652            &BetweenOptions {
653                lower_strict: StrictComparison::Strict,
654                upper_strict: StrictComparison::NonStrict,
655            },
656            ctx,
657        )
658        .unwrap();
659        assert_arrays_eq!(
660            between_strict,
661            BoolArray::from_iter([false, true, true, true]),
662            ctx
663        );
664
665        // Non-strict lower bound, strict upper bound
666        let between_strict = between_canonical(
667            &array,
668            &lower,
669            &upper,
670            &BetweenOptions {
671                lower_strict: StrictComparison::NonStrict,
672                upper_strict: StrictComparison::Strict,
673            },
674            ctx,
675        )
676        .unwrap();
677        assert_arrays_eq!(
678            between_strict,
679            BoolArray::from_iter([true, true, true, false]),
680            ctx
681        );
682    }
683
684    /// Regression test for a fuzzer crash where a bound scalar used a wider storage type (I32)
685    /// than the array's storage type (I16), causing the cast in `between_unpack` to fail.
686    ///
687    /// The fix casts the bound to the array's storage type and, when the cast fails, uses the
688    /// overflow direction to determine the result without falling back to Arrow.
689    #[rstest]
690    // Upper bound too large (I32 > i16::MAX): upper constraint always satisfied → result from lower only.
691    #[case(DecimalValue::I16(1), DecimalValue::I32(82246), vec![0, 1, 2, 3])]
692    // Lower bound too large (I32 > i16::MAX): lower constraint never satisfied → all false.
693    #[case(DecimalValue::I32(82246), DecimalValue::I16(4), vec![])]
694    // Upper bound too small (negative I32 < i16::MIN): upper constraint never satisfied → all false.
695    #[case(DecimalValue::I16(1), DecimalValue::I32(-82246), vec![])]
696    // Lower bound too small (negative I32 < i16::MIN): lower constraint always satisfied → result from upper only.
697    #[case(DecimalValue::I32(-82246), DecimalValue::I16(2), vec![0, 1])]
698    fn test_between_decimal_mismatched_storage_types(
699        #[case] lower_val: DecimalValue,
700        #[case] upper_val: DecimalValue,
701        #[case] expected_indices: Vec<u64>,
702    ) {
703        let ctx = &mut SESSION.create_execution_ctx();
704        // Array uses I16 storage with precision=5 (values fit in i16 even though precision=5
705        // nominally maps to I32 as the smallest storage type).
706        let decimal_type = DecimalDType::new(5, -67);
707        let array = DecimalArray::new(
708            buffer![1i16, 2i16, 3i16, 4i16],
709            decimal_type,
710            Validity::NonNullable,
711        )
712        .into_array();
713
714        let lower = ConstantArray::new(
715            Scalar::decimal(lower_val, decimal_type, Nullability::NonNullable),
716            array.len(),
717        )
718        .into_array();
719        let upper = ConstantArray::new(
720            Scalar::decimal(upper_val, decimal_type, Nullability::NonNullable),
721            array.len(),
722        )
723        .into_array();
724
725        let result = between_canonical(
726            &array,
727            &lower,
728            &upper,
729            &BetweenOptions {
730                lower_strict: StrictComparison::NonStrict,
731                upper_strict: StrictComparison::NonStrict,
732            },
733            ctx,
734        )
735        .unwrap()
736        .execute::<BoolArray>(ctx)
737        .unwrap();
738
739        assert_eq!(to_int_indices(result, ctx).unwrap(), expected_indices);
740    }
741}