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