Skip to main content

uni_store/backend/
eval.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Native evaluation of [`FilterExpr`] — no SQL, no DataFusion.
5//!
6//! This is the half of the structured-filter work that makes a non-Lance
7//! backend possible. [`FilterExpr::to_sql`] lets a SQL-speaking backend keep
8//! doing what it does; [`FilterExpr::eval`] lets one that speaks no SQL answer
9//! the same predicate over Arrow rows directly.
10//!
11//! # Three-valued logic is not optional
12//!
13//! SQL comparisons against NULL yield NULL, and a row is kept only when the
14//! predicate is *true*. A two-valued evaluator gets this backwards in a way
15//! that silently returns extra rows: `col != 'x'` over a NULL `col` is NULL in
16//! SQL (row excluded) but `Some(_) != Some("x")` in Rust (row included). So
17//! [`FilterExpr::eval`] returns `Option<bool>`, `None` meaning unknown, and the
18//! caller keeps a row only on `Ok(Some(true))`.
19
20use std::borrow::Cow;
21use std::cmp::Ordering;
22
23use arrow_array::{Array, RecordBatch, cast::AsArray, types::*};
24use arrow_schema::DataType;
25
26use crate::backend::types::{CmpOp, FilterExpr, Scalar, StringMatchKind};
27
28/// Why a [`FilterExpr`] could not be evaluated natively.
29///
30/// Every variant is a *refusal*, never a silent `false`. A filter the evaluator
31/// does not understand must not quietly drop or admit rows.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum EvalError {
34    /// The row has no such column.
35    UnknownColumn(String),
36    /// The evaluator has no defined semantics here — an unsupported Arrow type,
37    /// a cross-domain comparison (string vs number), or a [`FilterExpr::Raw`]
38    /// predicate this backend cannot parse.
39    Unsupported(String),
40}
41
42impl std::fmt::Display for EvalError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            EvalError::UnknownColumn(c) => write!(f, "no such column: {c}"),
46            EvalError::Unsupported(why) => write!(f, "cannot evaluate filter: {why}"),
47        }
48    }
49}
50
51impl std::error::Error for EvalError {}
52
53/// A single cell's value, borrowed from the underlying storage.
54#[derive(Debug, Clone, PartialEq)]
55pub enum Cell<'a> {
56    Null,
57    Bool(bool),
58    Int(i64),
59    UInt(u64),
60    Float(f64),
61    /// Borrowed straight from a string array when the cell is a top-level
62    /// column; owned when it is an element of a list, whose backing
63    /// `ArrayRef` is materialized per-call and does not outlive the read.
64    Str(Cow<'a, str>),
65    /// A list-typed cell, for [`FilterExpr::ArrayContains`].
66    List(Vec<Cell<'a>>),
67}
68
69/// One row, addressable by column name.
70///
71/// Deliberately minimal: a backend implements this over whatever it already
72/// has, and gets the engine's whole predicate vocabulary for free.
73pub trait RowAccessor {
74    /// Fetch a column's value.
75    ///
76    /// # Errors
77    ///
78    /// [`EvalError::UnknownColumn`] if absent, [`EvalError::Unsupported`] if
79    /// the physical type has no [`Cell`] representation.
80    fn column(&self, name: &str) -> Result<Cell<'_>, EvalError>;
81}
82
83/// A row of an Arrow [`RecordBatch`].
84pub struct ArrowRow<'a> {
85    batch: &'a RecordBatch,
86    row: usize,
87}
88
89impl<'a> ArrowRow<'a> {
90    /// Borrow row `row` of `batch`.
91    pub fn new(batch: &'a RecordBatch, row: usize) -> Self {
92        Self { batch, row }
93    }
94}
95
96impl RowAccessor for ArrowRow<'_> {
97    fn column(&self, name: &str) -> Result<Cell<'_>, EvalError> {
98        let idx = self
99            .batch
100            .schema()
101            .index_of(name)
102            .map_err(|_| EvalError::UnknownColumn(name.to_string()))?;
103        cell_at(self.batch.column(idx).as_ref(), self.row)
104    }
105}
106
107/// Read one Arrow cell.
108///
109/// Unsupported physical types are an error rather than [`Cell::Null`]: a
110/// timestamp column compared against a string literal must refuse, not report
111/// "no match" (hazard 6 in the design — `value_to_lance` emits temporals as
112/// strings and leans on backend coercion we do not reimplement here).
113fn cell_at(array: &dyn Array, row: usize) -> Result<Cell<'_>, EvalError> {
114    if array.is_null(row) {
115        return Ok(Cell::Null);
116    }
117    Ok(match array.data_type() {
118        DataType::Boolean => Cell::Bool(array.as_boolean().value(row)),
119        DataType::Int8 => Cell::Int(array.as_primitive::<Int8Type>().value(row) as i64),
120        DataType::Int16 => Cell::Int(array.as_primitive::<Int16Type>().value(row) as i64),
121        DataType::Int32 => Cell::Int(array.as_primitive::<Int32Type>().value(row) as i64),
122        DataType::Int64 => Cell::Int(array.as_primitive::<Int64Type>().value(row)),
123        DataType::UInt8 => Cell::UInt(array.as_primitive::<UInt8Type>().value(row) as u64),
124        DataType::UInt16 => Cell::UInt(array.as_primitive::<UInt16Type>().value(row) as u64),
125        DataType::UInt32 => Cell::UInt(array.as_primitive::<UInt32Type>().value(row) as u64),
126        DataType::UInt64 => Cell::UInt(array.as_primitive::<UInt64Type>().value(row)),
127        DataType::Float32 => Cell::Float(array.as_primitive::<Float32Type>().value(row) as f64),
128        DataType::Float64 => Cell::Float(array.as_primitive::<Float64Type>().value(row)),
129        DataType::Utf8 => Cell::Str(Cow::Borrowed(array.as_string::<i32>().value(row))),
130        DataType::LargeUtf8 => Cell::Str(Cow::Borrowed(array.as_string::<i64>().value(row))),
131        DataType::List(_) => {
132            let inner = array.as_list::<i32>().value(row);
133            Cell::List(collect_list(inner.as_ref())?)
134        }
135        DataType::LargeList(_) => {
136            let inner = array.as_list::<i64>().value(row);
137            Cell::List(collect_list(inner.as_ref())?)
138        }
139        other => {
140            return Err(EvalError::Unsupported(format!(
141                "no native evaluation for column type {other}"
142            )));
143        }
144    })
145}
146
147/// Materialize a list cell's elements as owned values.
148///
149/// `ListArray::value` hands back an `ArrayRef` that does not outlive this
150/// call, so element strings are cloned. That is why [`Cell::Str`] is a `Cow`:
151/// the common top-level-column read still borrows.
152fn collect_list(inner: &dyn Array) -> Result<Vec<Cell<'static>>, EvalError> {
153    (0..inner.len())
154        .map(|i| {
155            Ok(match cell_at(inner, i)? {
156                Cell::Null => Cell::Null,
157                Cell::Bool(b) => Cell::Bool(b),
158                Cell::Int(v) => Cell::Int(v),
159                Cell::UInt(v) => Cell::UInt(v),
160                Cell::Float(v) => Cell::Float(v),
161                Cell::Str(s) => Cell::Str(Cow::Owned(s.into_owned())),
162                Cell::List(_) => {
163                    return Err(EvalError::Unsupported("nested lists".to_string()));
164                }
165            })
166        })
167        .collect()
168}
169
170impl FilterExpr {
171    /// Evaluate this predicate against one row, in SQL's three-valued logic.
172    ///
173    /// Returns `Some(true)` / `Some(false)` for a determinate answer and `None`
174    /// for SQL NULL ("unknown"). **Keep a row only on `Ok(Some(true))`** —
175    /// treating `None` as either boolean is how NULL-handling bugs get in.
176    ///
177    /// # Errors
178    ///
179    /// [`EvalError`] when the predicate references a missing column, touches a
180    /// type with no native semantics, or contains a [`FilterExpr::Raw`] this
181    /// backend cannot parse. Failing loudly is deliberate: a `Raw` treated as
182    /// "match all" leaks rows the caller asked to exclude.
183    pub fn eval(&self, row: &dyn RowAccessor) -> Result<Option<bool>, EvalError> {
184        match self {
185            FilterExpr::Literal(b) => Ok(Some(*b)),
186            FilterExpr::And(parts) => {
187                // Kleene AND: FALSE absorbs, so a determinate false short-circuits
188                // even if a sibling is unknown.
189                let mut unknown = false;
190                for p in parts {
191                    match p.eval(row)? {
192                        Some(false) => return Ok(Some(false)),
193                        Some(true) => {}
194                        None => unknown = true,
195                    }
196                }
197                Ok(if unknown { None } else { Some(true) })
198            }
199            FilterExpr::Or(parts) => {
200                // Kleene OR: TRUE absorbs, mirroring `And`.
201                let mut unknown = false;
202                for p in parts {
203                    match p.eval(row)? {
204                        Some(true) => return Ok(Some(true)),
205                        Some(false) => {}
206                        None => unknown = true,
207                    }
208                }
209                Ok(if unknown { None } else { Some(false) })
210            }
211            // `NOT NULL` is NULL — negation propagates unknown rather than
212            // resolving it, which is what makes `NOT (x IN (…))` over a NULL
213            // `x` exclude the row instead of admitting it.
214            FilterExpr::Not(inner) => Ok(inner.eval(row)?.map(|b| !b)),
215            FilterExpr::Compare { column, op, value } => compare(&row.column(column)?, *op, value),
216            FilterExpr::In { column, values } => {
217                // `x IN (a, b)` is `x = a OR x = b`: a hit wins over any NULL,
218                // and only a miss-with-NULLs is unknown.
219                let cell = row.column(column)?;
220                let mut unknown = false;
221                for v in values {
222                    match compare(&cell, CmpOp::Eq, v)? {
223                        Some(true) => return Ok(Some(true)),
224                        Some(false) => {}
225                        None => unknown = true,
226                    }
227                }
228                Ok(if unknown { None } else { Some(false) })
229            }
230            FilterExpr::ArrayContains { column, value } => {
231                let cell = row.column(column)?;
232                list_contains(&cell, value)
233            }
234            FilterExpr::StringMatch {
235                column,
236                kind,
237                pattern,
238            } => match row.column(column)? {
239                Cell::Null => Ok(None),
240                // Exact substring semantics — no wildcard interpretation at all,
241                // which is precisely what `to_sql` cannot promise.
242                Cell::Str(s) => Ok(Some(match kind {
243                    StringMatchKind::Contains => s.contains(pattern.as_str()),
244                    StringMatchKind::StartsWith => s.starts_with(pattern.as_str()),
245                    StringMatchKind::EndsWith => s.ends_with(pattern.as_str()),
246                })),
247                other => Err(EvalError::Unsupported(format!(
248                    "string match on a non-string cell {other:?}"
249                ))),
250            },
251            // Determinate by definition: "is this NULL" is never itself unknown.
252            FilterExpr::IsNull(column) => Ok(Some(matches!(row.column(column)?, Cell::Null))),
253            FilterExpr::IsNotNull(column) => Ok(Some(!matches!(row.column(column)?, Cell::Null))),
254            FilterExpr::Raw(s) => Err(EvalError::Unsupported(format!(
255                "raw backend predicate {s:?} has no native evaluation"
256            ))),
257        }
258    }
259}
260
261/// `column <op> value` in three-valued logic.
262fn compare(cell: &Cell<'_>, op: CmpOp, value: &Scalar) -> Result<Option<bool>, EvalError> {
263    if matches!(cell, Cell::Null) || matches!(value, Scalar::Null) {
264        return Ok(None);
265    }
266    Ok(order(cell, value)?.map(|o| match op {
267        CmpOp::Eq => o == Ordering::Equal,
268        CmpOp::NotEq => o != Ordering::Equal,
269        CmpOp::Lt => o == Ordering::Less,
270        CmpOp::LtEq => o != Ordering::Greater,
271        CmpOp::Gt => o == Ordering::Greater,
272        CmpOp::GtEq => o != Ordering::Less,
273    }))
274}
275
276/// `array_contains(column, value)` in three-valued logic.
277///
278/// A NULL list is unknown. A present match wins over NULL elements; otherwise
279/// NULL elements make the answer unknown — same shape as `IN`, which is what
280/// the predicate means.
281fn list_contains(cell: &Cell<'_>, value: &Scalar) -> Result<Option<bool>, EvalError> {
282    let Cell::List(items) = cell else {
283        if matches!(cell, Cell::Null) {
284            return Ok(None);
285        }
286        return Err(EvalError::Unsupported(
287            "array_contains on a non-list column".to_string(),
288        ));
289    };
290    if matches!(value, Scalar::Null) {
291        return Ok(None);
292    }
293    let mut unknown = false;
294    for item in items {
295        match compare(item, CmpOp::Eq, value)? {
296            Some(true) => return Ok(Some(true)),
297            Some(false) => {}
298            None => unknown = true,
299        }
300    }
301    Ok(if unknown { None } else { Some(false) })
302}
303
304/// Numeric domain for cross-type comparison.
305#[derive(Debug, Clone, Copy)]
306enum Num {
307    I(i64),
308    U(u64),
309    F(f64),
310}
311
312fn cell_num(c: &Cell<'_>) -> Option<Num> {
313    match c {
314        Cell::Int(v) => Some(Num::I(*v)),
315        Cell::UInt(v) => Some(Num::U(*v)),
316        Cell::Float(v) => Some(Num::F(*v)),
317        _ => None,
318    }
319}
320
321fn scalar_num(s: &Scalar) -> Option<Num> {
322    match s {
323        Scalar::Int(v) => Some(Num::I(*v)),
324        Scalar::UInt(v) => Some(Num::U(*v)),
325        Scalar::Float(v) => Some(Num::F(*v)),
326        _ => None,
327    }
328}
329
330/// Order a cell against a scalar, or refuse.
331///
332/// `Ok(None)` is reserved for NaN — genuinely unordered — and never used for
333/// "these types don't compare", which is an error.
334fn order(cell: &Cell<'_>, value: &Scalar) -> Result<Option<Ordering>, EvalError> {
335    match (cell, value) {
336        (Cell::Bool(a), Scalar::Bool(b)) => Ok(Some(a.cmp(b))),
337        (Cell::Str(a), Scalar::Str(b)) => Ok(Some(a.as_ref().cmp(b.as_str()))),
338        _ => match (cell_num(cell), scalar_num(value)) {
339            (Some(a), Some(b)) => Ok(cmp_num(a, b)),
340            _ => Err(EvalError::Unsupported(format!(
341                "cannot compare cell {cell:?} with literal {value:?}"
342            ))),
343        },
344    }
345}
346
347/// Compare two numbers **exactly**, without a lossy common cast.
348///
349/// SQL says `1 = 1.0`, so mixed integer/float comparison must work — but the
350/// obvious implementation (cast both to `f64`) is wrong above 2^53, and every
351/// id column in the engine is `u64` with `Vid::INVALID == u64::MAX`. Each pair
352/// is therefore compared in a domain that can represent both operands.
353///
354/// This is a deliberate, documented divergence from a backend that coerces
355/// through `f64` first (DataFusion does): for integers beyond 2^53 compared
356/// against a float literal, we answer exactly and it answers approximately. No
357/// engine-generated filter mixes the domains — only a user filter could, and
358/// those arrive as [`FilterExpr::Raw`], which this evaluator refuses outright.
359fn cmp_num(a: Num, b: Num) -> Option<Ordering> {
360    match (a, b) {
361        (Num::I(x), Num::I(y)) => Some(x.cmp(&y)),
362        (Num::U(x), Num::U(y)) => Some(x.cmp(&y)),
363        (Num::I(x), Num::U(y)) => Some(if x < 0 {
364            Ordering::Less
365        } else {
366            (x as u64).cmp(&y)
367        }),
368        (Num::U(x), Num::I(y)) => Some(if y < 0 {
369            Ordering::Greater
370        } else {
371            x.cmp(&(y as u64))
372        }),
373        (Num::F(x), Num::F(y)) => x.partial_cmp(&y),
374        (Num::I(x), Num::F(y)) => cmp_i64_f64(x, y),
375        (Num::F(x), Num::I(y)) => cmp_i64_f64(y, x).map(Ordering::reverse),
376        (Num::U(x), Num::F(y)) => cmp_u64_f64(x, y),
377        (Num::F(x), Num::U(y)) => cmp_u64_f64(y, x).map(Ordering::reverse),
378    }
379}
380
381/// 2^63 — the first `f64` above every `i64`, exactly representable.
382const TWO_POW_63: f64 = 9_223_372_036_854_775_808.0;
383/// 2^64 — the first `f64` above every `u64`, exactly representable.
384const TWO_POW_64: f64 = 18_446_744_073_709_551_616.0;
385
386fn cmp_i64_f64(i: i64, f: f64) -> Option<Ordering> {
387    if f.is_nan() {
388        return None;
389    }
390    if f >= TWO_POW_63 {
391        return Some(Ordering::Less);
392    }
393    if f < -TWO_POW_63 {
394        return Some(Ordering::Greater);
395    }
396    // `floor` is exact and now in `i64` range, so the integer part compares
397    // losslessly; a surviving fraction breaks the tie.
398    let floor = f.floor();
399    Some(match i.cmp(&(floor as i64)) {
400        Ordering::Equal if f > floor => Ordering::Less,
401        o => o,
402    })
403}
404
405fn cmp_u64_f64(u: u64, f: f64) -> Option<Ordering> {
406    if f.is_nan() {
407        return None;
408    }
409    if f < 0.0 {
410        return Some(Ordering::Greater);
411    }
412    if f >= TWO_POW_64 {
413        return Some(Ordering::Less);
414    }
415    let floor = f.floor();
416    Some(match u.cmp(&(floor as u64)) {
417        Ordering::Equal if f > floor => Ordering::Less,
418        o => o,
419    })
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::backend::types::{CmpOp, FilterExpr, Scalar, StringMatchKind, ToSqlError};
426    use arrow_array::builder::{ListBuilder, StringBuilder};
427    use arrow_array::{
428        BooleanArray, Float64Array, Int64Array, RecordBatchIterator, StringArray, UInt64Array,
429    };
430    use arrow_schema::{Field, Schema};
431    use futures::TryStreamExt;
432    use proptest::prelude::*;
433    use std::sync::Arc;
434
435    // Small, deliberately overlapping domains: filters must actually select a
436    // proper subset for the comparison to mean anything. `NULL` appears in
437    // every nullable column, and one string carries an apostrophe so every
438    // generated case exercises the escape path in `scalar_to_sql`.
439    const INTS: [Option<i64>; 6] = [Some(-2), Some(-1), Some(0), Some(1), Some(2), None];
440    const UINTS: [Option<u64>; 5] = [Some(0), Some(1), Some(2), Some(3), None];
441    const FLOATS: [Option<f64>; 5] = [Some(-1.5), Some(0.0), Some(1.0), Some(2.5), None];
442    const STRS: [Option<&str>; 5] = [Some("a"), Some("b"), Some("it's"), Some("a%b_c"), None];
443    const BOOLS: [Option<bool>; 3] = [Some(true), Some(false), None];
444    const LABEL_SETS: [Option<&[&str]>; 5] = [
445        Some(&["Person"]),
446        Some(&["Person", "Admin"]),
447        Some(&[]),
448        Some(&["it's"]),
449        None,
450    ];
451
452    const ROWS: usize = 120;
453
454    fn schema() -> Arc<Schema> {
455        Arc::new(Schema::new(vec![
456            Field::new("rid", arrow_schema::DataType::UInt64, false),
457            Field::new("i", arrow_schema::DataType::Int64, true),
458            Field::new("u", arrow_schema::DataType::UInt64, true),
459            Field::new("f", arrow_schema::DataType::Float64, true),
460            Field::new("s", arrow_schema::DataType::Utf8, true),
461            Field::new("b", arrow_schema::DataType::Boolean, true),
462            Field::new(
463                "labels",
464                arrow_schema::DataType::List(Arc::new(Field::new(
465                    "item",
466                    arrow_schema::DataType::Utf8,
467                    true,
468                ))),
469                true,
470            ),
471        ]))
472    }
473
474    /// A deterministic batch whose columns advance at coprime strides, so the
475    /// rows cover the cross-product of value domains without enumerating it.
476    fn batch() -> RecordBatch {
477        let mut labels = ListBuilder::new(StringBuilder::new());
478        for r in 0..ROWS {
479            match LABEL_SETS[r % LABEL_SETS.len()] {
480                Some(items) => {
481                    for it in items {
482                        labels.values().append_value(it);
483                    }
484                    labels.append(true);
485                }
486                None => labels.append(false),
487            }
488        }
489        let labels = labels.finish();
490        // Re-declare the list field as the builder names it, so the written
491        // schema and the declared one agree.
492        let schema = Arc::new(Schema::new(vec![
493            schema().field(0).clone(),
494            schema().field(1).clone(),
495            schema().field(2).clone(),
496            schema().field(3).clone(),
497            schema().field(4).clone(),
498            schema().field(5).clone(),
499            Field::new("labels", labels.data_type().clone(), true),
500        ]));
501        RecordBatch::try_new(
502            schema,
503            vec![
504                Arc::new(UInt64Array::from_iter_values(0..ROWS as u64)),
505                Arc::new(Int64Array::from_iter(
506                    (0..ROWS).map(|r| INTS[r % INTS.len()]),
507                )),
508                Arc::new(UInt64Array::from_iter(
509                    (0..ROWS).map(|r| UINTS[(r / 2) % UINTS.len()]),
510                )),
511                Arc::new(Float64Array::from_iter(
512                    (0..ROWS).map(|r| FLOATS[(r / 3) % FLOATS.len()]),
513                )),
514                Arc::new(StringArray::from_iter(
515                    (0..ROWS).map(|r| STRS[(r / 5) % STRS.len()]),
516                )),
517                Arc::new(BooleanArray::from_iter(
518                    (0..ROWS).map(|r| BOOLS[(r / 7) % BOOLS.len()]),
519                )),
520                Arc::new(labels),
521            ],
522        )
523        .expect("batch")
524    }
525
526    /// Row ids the native evaluator keeps — `Some(true)` only.
527    fn eval_rids(expr: &FilterExpr, batch: &RecordBatch) -> Result<Vec<u64>, EvalError> {
528        let rid = batch.column(0).as_primitive::<UInt64Type>();
529        let mut out = Vec::new();
530        for r in 0..batch.num_rows() {
531            if expr.eval(&ArrowRow::new(batch, r))? == Some(true) {
532                out.push(rid.value(r));
533            }
534        }
535        Ok(out)
536    }
537
538    /// Row ids Lance keeps for the rendered SQL.
539    async fn sql_rids(ds: &lance::Dataset, sql: &str) -> anyhow::Result<Vec<u64>> {
540        let mut scanner = ds.scan();
541        scanner.project(&["rid"])?;
542        scanner.filter(sql)?;
543        let batches: Vec<RecordBatch> = scanner.try_into_stream().await?.try_collect().await?;
544        let mut out: Vec<u64> = batches
545            .iter()
546            .flat_map(|b| {
547                let a = b.column(0).as_primitive::<UInt64Type>();
548                (0..b.num_rows()).map(|i| a.value(i)).collect::<Vec<_>>()
549            })
550            .collect();
551        out.sort_unstable();
552        Ok(out)
553    }
554
555    fn cmp_op() -> impl Strategy<Value = CmpOp> {
556        prop_oneof![
557            Just(CmpOp::Eq),
558            Just(CmpOp::NotEq),
559            Just(CmpOp::Lt),
560            Just(CmpOp::LtEq),
561            Just(CmpOp::Gt),
562            Just(CmpOp::GtEq),
563        ]
564    }
565
566    /// A `(column, scalar)` pair drawn from one type domain.
567    ///
568    /// Well-typed on purpose: cross-domain comparison is a *refusal* in the
569    /// evaluator and a coercion in DataFusion, so it is pinned by explicit
570    /// tests below rather than folded into the agreement property.
571    fn typed_operand() -> impl Strategy<Value = (String, Scalar)> {
572        prop_oneof![
573            (-3i64..4).prop_map(|v| ("i".to_string(), Scalar::Int(v))),
574            (0u64..5).prop_map(|v| ("u".to_string(), Scalar::UInt(v))),
575            prop_oneof![
576                Just(-1.5f64),
577                Just(0.0),
578                Just(1.0),
579                Just(2.5),
580                Just(3.0),
581                Just(0.5)
582            ]
583            .prop_map(|v| ("f".to_string(), Scalar::Float(v))),
584            prop_oneof![Just("a"), Just("b"), Just("it's"), Just("z")]
585                .prop_map(|v| ("s".to_string(), Scalar::Str(v.to_string()))),
586            any::<bool>().prop_map(|v| ("b".to_string(), Scalar::Bool(v))),
587            Just(("i".to_string(), Scalar::Null)),
588            Just(("s".to_string(), Scalar::Null)),
589        ]
590    }
591
592    fn expr_strategy() -> impl Strategy<Value = FilterExpr> {
593        let leaf = prop_oneof![
594            8 => (typed_operand(), cmp_op())
595                .prop_map(|((column, value), op)| FilterExpr::Compare { column, op, value }),
596            3 => proptest::collection::vec(typed_operand(), 0..4).prop_map(|ops| {
597                // `IN` needs one column; take the first operand's and keep only
598                // the literals that belong to that domain.
599                let column = ops
600                    .first()
601                    .map(|(c, _)| c.clone())
602                    .unwrap_or_else(|| "i".to_string());
603                let values = ops
604                    .into_iter()
605                    .filter(|(c, _)| *c == column)
606                    .map(|(_, v)| v)
607                    .collect();
608                FilterExpr::In { column, values }
609            }),
610            3 => prop_oneof![Just("Person"), Just("Admin"), Just("it's"), Just("Ghost")]
611                .prop_map(|v| FilterExpr::ArrayContains {
612                    column: "labels".to_string(),
613                    value: Scalar::Str(v.to_string()),
614                }),
615            // Wildcard-free patterns only: a pattern holding `%`/`_` is exactly
616            // the case `to_sql` refuses, so it cannot participate in an
617            // agreement property. It is pinned by `wildcard_pattern_*` below.
618            3 => (
619                prop_oneof![
620                    Just(StringMatchKind::Contains),
621                    Just(StringMatchKind::StartsWith),
622                    Just(StringMatchKind::EndsWith),
623                ],
624                prop_oneof![Just("a"), Just("b"), Just("it"), Just("'"), Just("z")],
625            )
626                .prop_map(|(kind, pattern)| FilterExpr::StringMatch {
627                    column: "s".to_string(),
628                    kind,
629                    pattern: pattern.to_string(),
630                }),
631            2 => prop_oneof![Just("i"), Just("s"), Just("b"), Just("labels")].prop_map(|c| {
632                FilterExpr::IsNull(c.to_string())
633            }),
634            2 => prop_oneof![Just("u"), Just("f"), Just("s")].prop_map(|c| {
635                FilterExpr::IsNotNull(c.to_string())
636            }),
637            1 => any::<bool>().prop_map(FilterExpr::Literal),
638        ];
639        leaf.prop_recursive(3, 16, 4, |inner| {
640            prop_oneof![
641                3 => proptest::collection::vec(inner.clone(), 0..4).prop_map(FilterExpr::And),
642                3 => proptest::collection::vec(inner.clone(), 0..4).prop_map(FilterExpr::Or),
643                1 => inner.prop_map(FilterExpr::negate),
644            ]
645        })
646    }
647
648    /// The phase's acceptance criterion: over randomly generated expressions,
649    /// the native evaluator selects exactly the rows Lance's SQL filter does.
650    ///
651    /// Verified to bite, by mutation: making the Kleene `And` two-valued, and
652    /// dropping the apostrophe escape in `scalar_to_sql`, both fail this test.
653    /// The mixed `And`/`Or`/`Not` nesting is what exercises `to_sql`'s
654    /// unconditional parenthesisation: dropping the `paren` call also fails
655    /// this test, because `a OR b AND c` binds differently than the tree says.
656    #[test]
657    fn eval_agrees_with_lance_sql() {
658        let rt = tokio::runtime::Runtime::new().expect("runtime");
659        let tmp = tempfile::tempdir().expect("tempdir");
660        let uri = tmp.path().join("t.lance").to_string_lossy().to_string();
661        let batch = batch();
662        let schema = batch.schema();
663        rt.block_on(async {
664            lance::Dataset::write(
665                RecordBatchIterator::new(vec![Ok(batch.clone())], schema),
666                &uri,
667                None,
668            )
669            .await
670            .expect("write");
671        });
672        let ds = rt.block_on(lance::Dataset::open(&uri)).expect("open");
673
674        proptest!(ProptestConfig::with_cases(200), |(expr in expr_strategy())| {
675            let sql = expr.to_sql().expect("renderable");
676            let native = eval_rids(&expr, &batch).expect("evaluable");
677            let lance = rt.block_on(sql_rids(&ds, &sql)).expect("scan");
678            prop_assert_eq!(
679                native, lance,
680                "\nexpr: {:?}\nsql:  {}\n", expr, sql
681            );
682        });
683    }
684
685    // ---- explicit cases the plan calls out ----
686
687    /// A NULL in an `IN` list makes a miss unknown, not false, so the
688    /// predicate can never be FALSE. Rendering it literally as
689    /// `i IN (NULL, ...)` let Lance's multi-`InList` rewrite collapse the
690    /// unknown: `NOT ((i IN (NULL)) AND (i IN (0)))` selected every row
691    /// instead of only the non-null, non-zero ones. Pinned as SQL text so the
692    /// rendering cannot regress silently.
693    #[test]
694    fn in_list_with_null_renders_as_explicit_unknown() {
695        let all_null = FilterExpr::In {
696            column: "i".into(),
697            values: vec![Scalar::Null],
698        };
699        assert_eq!(all_null.to_sql().unwrap(), "CAST(NULL AS BOOLEAN)");
700
701        let mixed = FilterExpr::In {
702            column: "i".into(),
703            values: vec![Scalar::Int(0), Scalar::Null],
704        };
705        assert_eq!(
706            mixed.to_sql().unwrap(),
707            "(i IN (0) OR CAST(NULL AS BOOLEAN))"
708        );
709
710        // The common, NULL-free case keeps the plain rendering.
711        let plain = FilterExpr::In {
712            column: "i".into(),
713            values: vec![Scalar::Int(0), Scalar::Int(1)],
714        };
715        assert_eq!(plain.to_sql().unwrap(), "i IN (0, 1)");
716    }
717
718    /// End-to-end for the shape the proptest shrank to: the native evaluator
719    /// and Lance must select the same rows.
720    #[test]
721    fn not_and_of_two_in_lists_with_null_agrees_with_lance() {
722        let rt = tokio::runtime::Runtime::new().expect("runtime");
723        let tmp = tempfile::tempdir().expect("tempdir");
724        let uri = tmp.path().join("t.lance").to_string_lossy().to_string();
725        let b = batch();
726        let schema = b.schema();
727        rt.block_on(async {
728            lance::Dataset::write(
729                RecordBatchIterator::new(vec![Ok(b.clone())], schema),
730                &uri,
731                None,
732            )
733            .await
734            .expect("write");
735        });
736        let ds = rt.block_on(lance::Dataset::open(&uri)).expect("open");
737
738        let expr = FilterExpr::Not(Box::new(FilterExpr::And(vec![
739            FilterExpr::In {
740                column: "i".into(),
741                values: vec![Scalar::Null],
742            },
743            FilterExpr::In {
744                column: "i".into(),
745                values: vec![Scalar::Int(0)],
746            },
747        ])));
748
749        let native = eval_rids(&expr, &b).expect("evaluable");
750        let lance = rt
751            .block_on(sql_rids(&ds, &expr.to_sql().expect("renderable")))
752            .expect("scan");
753        assert_eq!(native, lance);
754
755        // `i` cycles [-2, -1, 0, 1, 2, NULL]: the answer is every row whose
756        // `i` is neither NULL nor 0, i.e. four of every six.
757        assert_eq!(native.len(), 80, "expected the non-null, non-zero rows");
758    }
759
760    #[test]
761    fn null_yields_unknown_not_false() {
762        let b = batch();
763        // `s != 'a'` must exclude NULL `s` rows, the classic two-valued bug.
764        let expr = FilterExpr::Compare {
765            column: "s".to_string(),
766            op: CmpOp::NotEq,
767            value: Scalar::Str("a".to_string()),
768        };
769        let kept = eval_rids(&expr, &b).unwrap();
770        let s = b.column(4).as_string::<i32>();
771        for r in 0..b.num_rows() {
772            if s.is_null(r) {
773                assert!(!kept.contains(&(r as u64)), "NULL row {r} must not survive");
774            }
775        }
776        assert!(!kept.is_empty(), "non-NULL non-'a' rows must survive");
777
778        // A NULL literal makes the whole conjunct unknown.
779        let with_null = FilterExpr::all([
780            FilterExpr::Compare {
781                column: "i".to_string(),
782                op: CmpOp::Eq,
783                value: Scalar::Null,
784            },
785            FilterExpr::Literal(true),
786        ]);
787        assert!(eval_rids(&with_null, &b).unwrap().is_empty());
788    }
789
790    #[test]
791    fn empty_in_is_false_and_renders() {
792        let expr = FilterExpr::In {
793            column: "i".to_string(),
794            values: vec![],
795        };
796        assert_eq!(expr.to_sql().unwrap(), "false");
797        assert!(eval_rids(&expr, &batch()).unwrap().is_empty());
798    }
799
800    #[test]
801    fn in_with_null_is_unknown_only_on_miss() {
802        let b = batch();
803        let hit = FilterExpr::In {
804            column: "i".to_string(),
805            values: vec![Scalar::Int(1), Scalar::Null],
806        };
807        // A real match outranks the NULL.
808        assert!(!eval_rids(&hit, &b).unwrap().is_empty());
809
810        let miss = FilterExpr::In {
811            column: "i".to_string(),
812            values: vec![Scalar::Int(99), Scalar::Null],
813        };
814        assert!(eval_rids(&miss, &b).unwrap().is_empty());
815    }
816
817    #[test]
818    fn mixed_numeric_compares_by_value_not_representation() {
819        // SQL says 1 = 1.0. `Scalar`'s own `PartialEq` says otherwise, and
820        // that split is deliberate — structural equality serves plan caching.
821        assert_ne!(Scalar::Int(1), Scalar::Float(1.0));
822        assert_eq!(cmp_num(Num::I(1), Num::F(1.0)), Some(Ordering::Equal));
823        assert_eq!(cmp_num(Num::U(1), Num::F(1.5)), Some(Ordering::Less));
824        assert_eq!(cmp_num(Num::I(-1), Num::U(0)), Some(Ordering::Less));
825    }
826
827    #[test]
828    fn u64_sentinels_survive_the_int_domain() {
829        // `Vid::INVALID` is `u64::MAX`; through an `i64` it would read as -1.
830        assert_eq!(
831            cmp_num(Num::U(u64::MAX), Num::I(-1)),
832            Some(Ordering::Greater)
833        );
834        assert_eq!(
835            FilterExpr::version_at_most(u64::MAX).to_sql().unwrap(),
836            "_version <= 18446744073709551615"
837        );
838    }
839
840    #[test]
841    fn large_integers_compare_exactly_against_floats() {
842        // Above 2^53 a common `f64` cast loses the distinction; we do not.
843        let big = (1u64 << 53) + 1;
844        assert_eq!(
845            cmp_num(Num::U(big), Num::F((1u64 << 53) as f64)),
846            Some(Ordering::Greater)
847        );
848        assert_eq!(
849            cmp_num(Num::I(i64::MAX), Num::F(f64::MAX)),
850            Some(Ordering::Less)
851        );
852        assert_eq!(
853            cmp_num(Num::I(i64::MIN), Num::F(f64::NEG_INFINITY)),
854            Some(Ordering::Greater)
855        );
856    }
857
858    #[test]
859    fn nan_is_unordered_so_every_comparison_is_unknown() {
860        assert_eq!(cmp_num(Num::F(f64::NAN), Num::F(1.0)), None);
861        assert_eq!(cmp_num(Num::I(1), Num::F(f64::NAN)), None);
862        let unknown = compare(&Cell::Float(f64::NAN), CmpOp::Eq, &Scalar::Float(f64::NAN));
863        assert_eq!(unknown.unwrap(), None);
864    }
865
866    #[test]
867    fn raw_and_cross_domain_refuse_loudly() {
868        let b = batch();
869        let raw = FilterExpr::Raw("i > 0".to_string());
870        assert!(matches!(
871            raw.eval(&ArrowRow::new(&b, 0)),
872            Err(EvalError::Unsupported(_))
873        ));
874
875        let crossed = FilterExpr::Compare {
876            column: "i".to_string(),
877            op: CmpOp::Eq,
878            value: Scalar::Str("a".to_string()),
879        };
880        assert!(matches!(
881            crossed.eval(&ArrowRow::new(&b, 0)),
882            Err(EvalError::Unsupported(_))
883        ));
884
885        let missing = FilterExpr::Compare {
886            column: "nope".to_string(),
887            op: CmpOp::Eq,
888            value: Scalar::Int(1),
889        };
890        assert!(matches!(
891            missing.eval(&ArrowRow::new(&b, 0)),
892            Err(EvalError::UnknownColumn(_))
893        ));
894    }
895
896    #[test]
897    fn apostrophes_round_trip_through_the_single_escaper() {
898        let expr = FilterExpr::Compare {
899            column: "s".to_string(),
900            op: CmpOp::Eq,
901            value: Scalar::Str("it's".to_string()),
902        };
903        assert_eq!(expr.to_sql().unwrap(), "s = 'it''s'");
904        assert!(!eval_rids(&expr, &batch()).unwrap().is_empty());
905    }
906
907    #[test]
908    fn wildcard_pattern_refuses_in_sql_but_evaluates_exactly() {
909        let b = batch();
910        // `a%b_c` is a literal row value; the pattern below is a literal `%b_`,
911        // which LIKE would read as "anything, b, any single char".
912        let expr = FilterExpr::StringMatch {
913            column: "s".to_string(),
914            kind: StringMatchKind::Contains,
915            pattern: "%b_".to_string(),
916        };
917        assert!(matches!(expr.to_sql(), Err(ToSqlError::Unsupported(_))));
918
919        // The native evaluator has no wildcard notion at all, so it answers.
920        let kept = eval_rids(&expr, &b).unwrap();
921        assert!(
922            !kept.is_empty(),
923            "the literal substring `%b_` occurs in `a%b_c` rows"
924        );
925        let s = b.column(4).as_string::<i32>();
926        for r in 0..b.num_rows() {
927            let expect = !s.is_null(r) && s.value(r).contains("%b_");
928            assert_eq!(kept.contains(&(r as u64)), expect, "row {r}");
929        }
930    }
931
932    #[test]
933    fn sql_pushable_drops_an_or_whole_never_one_branch() {
934        let bad = FilterExpr::StringMatch {
935            column: "s".to_string(),
936            kind: StringMatchKind::Contains,
937            pattern: "%".to_string(),
938        };
939        let good = FilterExpr::equals("i", Scalar::Int(1));
940
941        // An `And` sheds the unrenderable conjunct — a widening, which the
942        // caller's residual re-narrows.
943        let anded = FilterExpr::And(vec![good.clone(), bad.clone()]);
944        assert_eq!(anded.sql_pushable(), good);
945
946        // An `Or` must go whole. Keeping only `good` would NARROW the result
947        // and silently lose the rows `bad` would have matched.
948        let ored = FilterExpr::Or(vec![good.clone(), bad.clone()]);
949        assert_eq!(ored.sql_pushable(), FilterExpr::Literal(true));
950
951        // Nested: the bad `Or` is dropped, its sibling conjunct survives.
952        let nested = FilterExpr::And(vec![good.clone(), ored]);
953        assert_eq!(nested.sql_pushable(), good);
954
955        // Fully renderable trees are returned untouched.
956        let fine = FilterExpr::And(vec![good.clone(), FilterExpr::IsNull("s".to_string())]);
957        assert_eq!(fine.sql_pushable(), fine);
958    }
959
960    #[test]
961    fn scalar_from_value_excludes_null_so_producers_keep_bailing() {
962        use uni_common::Value;
963        assert_eq!(
964            Scalar::from_value(&Value::String("x".into())),
965            Some(Scalar::Str("x".into()))
966        );
967        assert_eq!(Scalar::from_value(&Value::Int(1)), Some(Scalar::Int(1)));
968        assert_eq!(
969            Scalar::from_value(&Value::Bool(true)),
970            Some(Scalar::Bool(true))
971        );
972        // Null and the composite types must stay `None`: every caller reads that
973        // as "cannot build this probe" and falls back to a general path.
974        assert_eq!(Scalar::from_value(&Value::Null), None);
975        assert_eq!(Scalar::from_value(&Value::List(vec![])), None);
976    }
977
978    /// A rendered `Compare` must actually narrow a real Lance scan.
979    ///
980    /// The regression this pins is not a bad-looking string — `"createdAt" >= 2`
981    /// reads fine. Lance parses a double-quoted name as a **string literal**, so
982    /// the clause became a data-independent constant matching every row. Only a
983    /// scan proves the difference, which is why this test writes a dataset with
984    /// a camelCase column rather than asserting on text.
985    #[test]
986    fn rendered_compare_narrows_a_real_scan() {
987        let rt = tokio::runtime::Runtime::new().unwrap();
988        let tmp = tempfile::tempdir().unwrap();
989        let uri = tmp.path().join("c.lance").to_string_lossy().to_string();
990        let schema = Arc::new(Schema::new(vec![
991            Field::new("rid", arrow_schema::DataType::UInt64, false),
992            Field::new("createdAt", arrow_schema::DataType::Int64, true),
993        ]));
994        let b = RecordBatch::try_new(
995            schema.clone(),
996            vec![
997                Arc::new(UInt64Array::from_iter_values(0..5u64)),
998                Arc::new(Int64Array::from_iter_values(1..6i64)),
999            ],
1000        )
1001        .unwrap();
1002        rt.block_on(async {
1003            lance::Dataset::write(RecordBatchIterator::new(vec![Ok(b)], schema), &uri, None)
1004                .await
1005                .unwrap();
1006        });
1007        let ds = rt.block_on(lance::Dataset::open(&uri)).unwrap();
1008
1009        let range = FilterExpr::all([
1010            FilterExpr::compare("createdAt", CmpOp::GtEq, Scalar::Int(2)),
1011            FilterExpr::compare("createdAt", CmpOp::LtEq, Scalar::Int(4)),
1012        ]);
1013        let sql = range.to_sql().unwrap();
1014        assert!(!sql.contains('"'), "column must be bare: {sql}");
1015        assert_eq!(rt.block_on(sql_rids(&ds, &sql)).unwrap(), vec![1, 2, 3]);
1016
1017        // What the old fused form did. `"createdAt"` is the string literal
1018        // `'createdAt'`, so the clause is a constant: which constant depends on
1019        // the operator (`>` matched every row in isolation, this two-sided form
1020        // matches none), but it never depends on the data.
1021        let quoted = "\"createdAt\" >= 2 AND \"createdAt\" <= 4";
1022        assert_eq!(
1023            rt.block_on(sql_rids(&ds, quoted)).unwrap(),
1024            Vec::<u64>::new(),
1025            "a double-quoted column is a string literal, not an identifier"
1026        );
1027    }
1028}