Skip to main content

vortex_array/expr/transform/
match_between.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Rewrites `x >= a AND x < b` into `x BETWEEN a AND b`.
5
6use std::ptr;
7
8use vortex_error::VortexExpect;
9use vortex_error::VortexResult;
10
11use crate::expr::BoundExpression;
12use crate::scalar_fn::ScalarFnVTableExt;
13use crate::scalar_fn::fns::between::Between;
14use crate::scalar_fn::fns::between::BetweenOptions;
15use crate::scalar_fn::fns::between::StrictComparison;
16use crate::scalar_fn::fns::binary::Binary;
17use crate::scalar_fn::fns::get_item::GetItem;
18use crate::scalar_fn::fns::literal::Literal;
19use crate::scalar_fn::fns::operators::Operator;
20
21/// Merge pairs of range comparisons on the same column within any conjunction of `expr` into a
22/// single [`Between`].
23///
24/// This is not an optimizer rule because it is worth running only once, on a scan's filter.
25/// Subtrees without a match keep their identity, so an expression without any match is returned
26/// unchanged.
27pub fn find_between(expr: BoundExpression) -> VortexResult<BoundExpression> {
28    Ok(try_find_between(&expr)?.unwrap_or(expr))
29}
30
31fn try_find_between(expr: &BoundExpression) -> VortexResult<Option<BoundExpression>> {
32    let children = expr.children();
33    let mut new_children: Option<Vec<BoundExpression>> = None;
34    for (idx, child) in children.iter().enumerate() {
35        if let Some(rewritten) = try_find_between(child)? {
36            new_children
37                .get_or_insert_with(|| children[..idx].to_vec())
38                .push(rewritten);
39        } else if let Some(new_children) = new_children.as_mut() {
40            new_children.push(child.clone());
41        }
42    }
43    let rewritten = new_children
44        .map(|children| expr.clone().with_children(children))
45        .transpose()?;
46
47    let current = rewritten.as_ref().unwrap_or(expr);
48    if is_and(current)
49        && let Some(merged) = merge_straddling_pairs(current)?
50    {
51        return Ok(Some(merged));
52    }
53    Ok(rewritten)
54}
55
56/// Merge pairs of range comparisons that sit on opposite sides of the `AND` node `expr`.
57///
58/// Children are rewritten before their parents, so a pair within one side was already merged at
59/// the `AND` node joining it. Only considering straddling pairs keeps the pass linear in the size
60/// of the conjunction instead of re-pairing every conjunct at every level. The rest of the tree
61/// keeps its shape: the lhs conjunct becomes the between and the rhs conjunct is removed.
62fn merge_straddling_pairs(expr: &BoundExpression) -> VortexResult<Option<BoundExpression>> {
63    let mut current: Option<BoundExpression> = None;
64
65    loop {
66        let expr = current.as_ref().unwrap_or(expr);
67        if !is_and(expr) {
68            break;
69        }
70        let lhs = expr.child(0);
71        let rhs = expr.child(1);
72
73        let Some((lower, upper, between)) = find_conjunct(rhs, &mut |rhs_conjunct| {
74            let rhs_cmp = RangeComparison::try_new(rhs_conjunct)?;
75            find_conjunct(lhs, &mut |lhs_conjunct| {
76                let lhs_cmp = RangeComparison::try_new(lhs_conjunct)?;
77                let between = lhs_cmp.between(&rhs_cmp)?;
78                Some((lhs_conjunct, rhs_conjunct, between))
79            })
80        }) else {
81            break;
82        };
83
84        tracing::debug!(%lower, %upper, "merged range comparisons into {between}");
85
86        let new_lhs = rewrite_conjunct(lhs, lower, Some(&between))?
87            .vortex_expect("matched conjunct is part of the lhs")
88            .vortex_expect("replaced conjunct is never removed");
89        let new_rhs = rewrite_conjunct(rhs, upper, None)?
90            .vortex_expect("matched conjunct is part of the rhs");
91
92        current = Some(match new_rhs {
93            Some(new_rhs) => expr.clone().with_children([new_lhs, new_rhs])?,
94            None => new_lhs,
95        });
96    }
97
98    Ok(current)
99}
100
101fn is_and(expr: &BoundExpression) -> bool {
102    expr.as_opt::<Binary>() == Some(&Operator::And)
103}
104
105/// Return the first `Some` produced by `f` over the conjuncts of the `AND` tree rooted at `expr`.
106fn find_conjunct<'a, T>(
107    expr: &'a BoundExpression,
108    f: &mut impl FnMut(&'a BoundExpression) -> Option<T>,
109) -> Option<T> {
110    if is_and(expr) {
111        find_conjunct(expr.child(0), f).or_else(|| find_conjunct(expr.child(1), f))
112    } else {
113        f(expr)
114    }
115}
116
117/// Replace the conjunct `target`, identified by address, in the `AND` tree rooted at `expr`, or
118/// remove it when `replacement` is `None`.
119///
120/// Returns `None` when `target` is not a conjunct of `expr`, otherwise the rewritten tree, which
121/// is itself `None` when removing `target` leaves nothing behind.
122fn rewrite_conjunct(
123    expr: &BoundExpression,
124    target: &BoundExpression,
125    replacement: Option<&BoundExpression>,
126) -> VortexResult<Option<Option<BoundExpression>>> {
127    if ptr::eq(expr, target) {
128        return Ok(Some(replacement.cloned()));
129    }
130    if !is_and(expr) {
131        return Ok(None);
132    }
133
134    for idx in 0..2 {
135        let Some(new_child) = rewrite_conjunct(expr.child(idx), target, replacement)? else {
136            continue;
137        };
138        let sibling = expr.child(1 - idx).clone();
139        let rewritten = match new_child {
140            Some(new_child) if idx == 0 => expr.clone().with_children([new_child, sibling])?,
141            Some(new_child) => expr.clone().with_children([sibling, new_child])?,
142            None => sibling,
143        };
144        return Ok(Some(Some(rewritten)));
145    }
146
147    Ok(None)
148}
149
150/// A conjunct `column op literal` (in either order) that bounds `column` from one side.
151struct RangeComparison<'a> {
152    column: &'a BoundExpression,
153    literal: &'a BoundExpression,
154    is_lower: bool,
155    strict: StrictComparison,
156}
157
158impl<'a> RangeComparison<'a> {
159    fn try_new(expr: &'a BoundExpression) -> Option<Self> {
160        let operator = *expr.as_opt::<Binary>()?;
161        let lhs = expr.child(0);
162        let rhs = expr.child(1);
163
164        // Normalize the column to the left of the operator.
165        let (column, literal, operator) = if lhs.is::<GetItem>() && rhs.is::<Literal>() {
166            (lhs, rhs, operator)
167        } else if rhs.is::<GetItem>() && lhs.is::<Literal>() {
168            (rhs, lhs, operator.swap()?)
169        } else {
170            return None;
171        };
172
173        let (is_lower, strict) = match operator {
174            Operator::Gt => (true, StrictComparison::Strict),
175            Operator::Gte => (true, StrictComparison::NonStrict),
176            Operator::Lt => (false, StrictComparison::Strict),
177            Operator::Lte => (false, StrictComparison::NonStrict),
178            _ => return None,
179        };
180
181        Some(Self {
182            column,
183            literal,
184            is_lower,
185            strict,
186        })
187    }
188
189    /// Combine two comparisons bounding the same column from opposite sides.
190    fn between(&self, other: &Self) -> Option<BoundExpression> {
191        if self.is_lower == other.is_lower || self.column != other.column {
192            return None;
193        }
194        let (lower, upper) = if self.is_lower {
195            (self, other)
196        } else {
197            (other, self)
198        };
199
200        // A between that fails to type-check leaves the comparisons in place.
201        Between
202            .try_new_bound_expr(
203                BetweenOptions {
204                    lower_strict: lower.strict,
205                    upper_strict: upper.strict,
206                },
207                [
208                    self.column.clone(),
209                    lower.literal.clone(),
210                    upper.literal.clone(),
211                ],
212            )
213            .ok()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use rstest::rstest;
220    use vortex_buffer::buffer;
221    use vortex_error::VortexResult;
222
223    use super::find_between;
224    use crate::IntoArray;
225    use crate::VortexSessionExecute;
226    use crate::array_session;
227    use crate::arrays::BoolArray;
228    use crate::arrays::StructArray;
229    use crate::dtype::DType;
230    use crate::dtype::Nullability;
231    use crate::dtype::PType;
232    use crate::dtype::StructFields;
233    use crate::expr::Expression;
234    use crate::expr::and;
235    use crate::expr::between;
236    use crate::expr::col;
237    use crate::expr::gt;
238    use crate::expr::gt_eq;
239    use crate::expr::lit;
240    use crate::expr::lt;
241    use crate::expr::lt_eq;
242    use crate::expr::or;
243    use crate::scalar::Scalar;
244    use crate::scalar_fn::fns::between::BetweenOptions;
245    use crate::scalar_fn::fns::between::StrictComparison;
246
247    fn scope() -> DType {
248        let i32_dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
249        DType::Struct(
250            StructFields::new(
251                ["x", "y", "z"].into(),
252                vec![i32_dtype.clone(), i32_dtype.clone(), i32_dtype],
253            ),
254            Nullability::NonNullable,
255        )
256    }
257
258    fn between_x(lower: i32, upper: i32, lower_strict: bool, upper_strict: bool) -> Expression {
259        let strictness = |strict| {
260            if strict {
261                StrictComparison::Strict
262            } else {
263                StrictComparison::NonStrict
264            }
265        };
266        between(
267            col("x"),
268            lit(lower),
269            lit(upper),
270            BetweenOptions {
271                lower_strict: strictness(lower_strict),
272                upper_strict: strictness(upper_strict),
273            },
274        )
275    }
276
277    #[rstest]
278    // An impossible range is still a between.
279    #[case::bad_match(
280        and(lt_eq(lit(100), col("x")), gt(lit(-100), col("x"))),
281        between_x(100, -100, false, true)
282    )]
283    #[case::literals_on_left(
284        and(lt(lit(2), col("x")), gt_eq(lit(5), col("x"))),
285        between_x(2, 5, true, false)
286    )]
287    #[case::columns_on_left(
288        and(gt_eq(col("x"), lit(2)), lt(col("x"), lit(5))),
289        between_x(2, 5, false, true)
290    )]
291    #[case::mixed_sides(
292        and(gt_eq(col("x"), lit(2)), gt_eq(lit(5), col("x"))),
293        between_x(2, 5, false, false)
294    )]
295    #[case::upper_first(
296        and(gt_eq(lit(5), col("x")), lt(lit(2), col("x"))),
297        between_x(2, 5, true, false)
298    )]
299    #[case::other_conjunct_first(
300        and(
301            and(gt_eq(col("y"), lit(10)), gt_eq(lit(5), col("x"))),
302            lt(lit(2), col("x")),
303        ),
304        and(gt_eq(col("y"), lit(10)), between_x(2, 5, true, false))
305    )]
306    #[case::other_conjunct_last(
307        and(
308            and(gt_eq(lit(5), col("x")), gt_eq(col("y"), lit(10))),
309            lt(lit(2), col("x")),
310        ),
311        and(between_x(2, 5, true, false), gt_eq(col("y"), lit(10)))
312    )]
313    #[case::skips_non_leaf_conjuncts(
314        and(
315            and(gt_eq(col("x"), lit(2)), gt(col("y"), col("z"))),
316            lt(col("x"), lit(5))
317        ),
318        and(between_x(2, 5, false, true), gt(col("y"), col("z")))
319    )]
320    #[case::pair_within_one_side(
321        and(
322            and(gt_eq(col("x"), lit(2)), lt(col("x"), lit(5))),
323            gt(col("y"), col("z"))
324        ),
325        and(between_x(2, 5, false, true), gt(col("y"), col("z")))
326    )]
327    #[case::under_or(
328        or(
329            and(gt_eq(col("x"), lit(2)), lt(col("x"), lit(5))),
330            gt(col("y"), col("z"))
331        ),
332        or(between_x(2, 5, false, true), gt(col("y"), col("z")))
333    )]
334    #[case::no_pair(
335        and(gt_eq(col("x"), lit(2)), lt(col("y"), lit(5))),
336        and(gt_eq(col("x"), lit(2)), lt(col("y"), lit(5)))
337    )]
338    #[case::same_direction(
339        and(gt_eq(col("x"), lit(2)), gt(col("x"), lit(5))),
340        and(gt_eq(col("x"), lit(2)), gt(col("x"), lit(5)))
341    )]
342    fn finds_between(#[case] expr: Expression, #[case] expected: Expression) -> VortexResult<()> {
343        let scope = scope();
344        assert_eq!(find_between(expr.bind(&scope)?)?, expected.bind(&scope)?);
345        Ok(())
346    }
347
348    /// Every pair straddling a single `AND` node is merged.
349    #[test]
350    fn merges_many_pairs() -> VortexResult<()> {
351        let scope = scope();
352        let lowers = ["x", "y", "z"].map(|name| gt_eq(col(name), lit(0)));
353        let uppers = ["x", "y", "z"].map(|name| lt(col(name), lit(10)));
354        let [lx, ly, lz] = lowers;
355        let [ux, uy, uz] = uppers;
356        let expr = and(and(and(lx, ly), lz), and(and(uz, uy), ux));
357
358        let range = |name| {
359            between(
360                col(name),
361                lit(0),
362                lit(10),
363                BetweenOptions {
364                    lower_strict: StrictComparison::NonStrict,
365                    upper_strict: StrictComparison::Strict,
366                },
367            )
368        };
369        let expected = and(and(range("x"), range("y")), range("z"));
370
371        assert_eq!(find_between(expr.bind(&scope)?)?, expected.bind(&scope)?);
372        Ok(())
373    }
374
375    /// A null literal bound must not change the values of the rewritten expression. Kleene `AND`
376    /// keeps a row false when the surviving comparison is false, so the rewrite cannot null it.
377    #[test]
378    fn null_literal_bound_is_value_preserving() -> VortexResult<()> {
379        let session = array_session();
380        let ctx = &mut session.create_execution_ctx();
381        let data = StructArray::from_fields(&[("x", buffer![10, 1].into_array())])?.into_array();
382
383        let null_lit = lit(Scalar::null(DType::Primitive(
384            PType::I32,
385            Nullability::Nullable,
386        )));
387        let expr = and(gt_eq(col("x"), null_lit), lt_eq(col("x"), lit(5i32)));
388
389        let before = data
390            .clone()
391            .apply(&expr)?
392            .execute::<BoolArray>(ctx)?
393            .opt_bool_vec(ctx);
394
395        let rewritten = find_between(expr.bind(data.dtype())?)?;
396        let after = data
397            .apply_bound(&rewritten)?
398            .execute::<BoolArray>(ctx)?
399            .opt_bool_vec(ctx);
400
401        // Row 0 is false rather than null because `$.x <= 5` falsifies it on its own.
402        assert_eq!(before, [Some(false), None]);
403        assert_eq!(before, after);
404
405        Ok(())
406    }
407}