Skip to main content

reddb_engine/query/
filter_compiled.rs

1//! Compiled filter interpreter — flat opcode evaluation.
2//!
3//! Mirrors PostgreSQL's `ExprState::steps` / `ExecInterpExpr`
4//! (`src/backend/executor/execExprInterp.c`). The legacy walker in
5//! `super::filter::Filter::evaluate` recurses through the AST per row
6//! and consults a `&dyn Fn(&str) -> Option<Value>` closure for every
7//! predicate's column lookup. That pays:
8//!
9//! 1. one HashMap (or BTreeMap) lookup per predicate per row;
10//! 2. one closure indirection per lookup;
11//! 3. one stack frame per AST node per row.
12//!
13//! `CompiledFilter` does the column resolution **once** at plan time,
14//! flattens the AST into a linear `Vec<CompiledOp>`, and evaluates
15//! each row with a tight loop over the ops against a `&[Value]` slot
16//! indexed by the column index resolved at compile time. The hot loop
17//! does no allocation, no string lookup, and no closure call.
18//!
19//! # Wiring
20//!
21//! Compile once per query plan with [`CompiledFilter::compile`],
22//! providing a column-name → slot-index map (the schema). On every
23//! row, build a `&[Value]` slot in schema order and call
24//! [`CompiledFilter::evaluate`]. The compiled filter is `Send + Sync`
25//! and can be cached on the plan node.
26//!
27//! # Semantics
28//!
29//! Identical to the legacy walker, byte for byte:
30//! - column missing from the schema at compile time → `CompileError`
31//! - `Value::Null` at a slot position → equivalent to "column not
32//!   found" in the legacy path: `IsNull` returns `true`, every other
33//!   predicate returns `false`
34//! - `And` / `Or` are short-circuited per the postfix arity
35//! - `Not` flips the top of the stack
36//!
37//! A fuzz test (`compiled_matches_legacy_for_random_filters`) exercises
38//! 1 000 random filter + row pairs to guard against drift.
39//!
40//! # Evaluator bypass contract
41//!
42//! `CompiledFilter::evaluate` dispatches every predicate through
43//! [`Predicate::evaluate`] rather than routing through
44//! `query::evaluator::evaluate`. This bypass is intentional for the hot
45//! path and is valid under the following preconditions:
46//!
47//! 1. **Pre-typed values.** `Filter` predicates are constructed from
48//!    values whose types have already been resolved by the query planner
49//!    (or a typed DSL caller). The planner guarantees that the column
50//!    type and predicate-value type are compatible before a
51//!    `CompiledFilter` is built.
52//!
53//! 2. **No implicit casts at eval time.** Because types are resolved
54//!    at plan time, `evaluator::evaluate`'s implicit-cast step (which
55//!    consults `schema::coercion_spine`) adds zero value at row-eval
56//!    time for ops in `FilterOp`: `Eq`, `Ne`, `Lt`, `Le`, `Gt`, `Ge`,
57//!    `IsNull`, `IsNotNull`, `Between`, `In`, `NotIn`, `Like`,
58//!    `NotLike`, `Contains`, `StartsWith`, `EndsWith`. The hand-rolled
59//!    `partial_compare_values` path is type-safe for these ops given
60//!    precondition 1.
61//!
62//! 3. **Parity test guards against drift.** `compiled_matches_legacy_for_random_filters`
63//!    exercises 1 000 random filter+row pairs comparing compiled output
64//!    to the legacy walker. Any semantic deviation from the accepted
65//!    behavior surfaces there.
66//!
67//! When a future slice migrates `Filter` construction to emit `Expr`
68//! trees (so the planner can enforce type contracts through the type
69//! system rather than by convention), the bypass should be removed and
70//! the ops above routed through `evaluator::evaluate`.
71
72use std::collections::HashMap;
73
74use super::filter::{Filter, Predicate};
75use reddb_types::Value;
76
77/// Error returned by [`CompiledFilter::compile`] when the filter
78/// references a column that is not in the provided schema.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum CompileError {
81    UnknownColumn(String),
82}
83
84impl std::fmt::Display for CompileError {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            CompileError::UnknownColumn(c) => {
88                write!(f, "compiled filter: unknown column '{c}'")
89            }
90        }
91    }
92}
93
94impl std::error::Error for CompileError {}
95
96/// Single instruction in a [`CompiledFilter`] op stream.
97///
98/// Uses postfix evaluation (RPN): predicates push their result onto
99/// a small bool stack, `And`/`Or` pop their `n` operands, `Not` flips
100/// the top.
101#[derive(Debug, Clone)]
102pub enum CompiledOp {
103    /// Evaluate `predicate` against `slot[col_idx]` and push the
104    /// resulting bool. The predicate carries its operator and its
105    /// pre-resolved `PredicateValue` (Range / List / Pattern / etc.)
106    /// so no allocation happens per row.
107    Predicate {
108        col_idx: usize,
109        predicate: Predicate,
110    },
111    /// Pop the top `n` bools, push their conjunction (`true` when
112    /// `n == 0`, matching `Vec::iter().all`).
113    AndN(usize),
114    /// Pop the top `n` bools, push their disjunction (`false` when
115    /// `n == 0`, matching `Vec::iter().any`).
116    OrN(usize),
117    /// Pop the top bool, push its negation.
118    Not,
119}
120
121/// A filter that has been compiled against a fixed column schema.
122///
123/// Cheap to clone (the op list is `Vec<CompiledOp>` and predicates
124/// are already cloned at compile time).
125#[derive(Debug, Clone)]
126pub struct CompiledFilter {
127    ops: Vec<CompiledOp>,
128}
129
130impl CompiledFilter {
131    /// Compile a [`Filter`] against a column-name → slot-index map.
132    ///
133    /// Returns [`CompileError::UnknownColumn`] when any predicate
134    /// references a column not in the schema.
135    pub fn compile(filter: &Filter, schema: &HashMap<String, usize>) -> Result<Self, CompileError> {
136        let mut ops = Vec::new();
137        compile_into(filter, schema, &mut ops)?;
138        Ok(Self { ops })
139    }
140
141    /// Number of opcodes in the compiled program. Useful for tests
142    /// and for diagnostics.
143    pub fn op_count(&self) -> usize {
144        self.ops.len()
145    }
146
147    /// Evaluate the compiled filter against a row slot.
148    ///
149    /// `slot` must be indexed by the same column-index map used at
150    /// compile time. Out-of-range indices are treated as
151    /// `Value::Null` (matching the legacy walker's "column not
152    /// found" semantics).
153    ///
154    /// Hot path — no allocation, no string lookup. The bool stack is
155    /// kept tiny (typical filter has ≤ 8 nesting levels) and lives
156    /// on the eval call's local `Vec`.
157    pub fn evaluate(&self, slot: &[Value]) -> bool {
158        // Local bool stack. Capacity 8 covers >99% of real filters
159        // without spilling to heap.
160        let mut stack: Vec<bool> = Vec::with_capacity(8);
161        for op in &self.ops {
162            match op {
163                CompiledOp::Predicate { col_idx, predicate } => {
164                    let value = slot.get(*col_idx).unwrap_or(&Value::Null);
165                    // Null + non-IsNull → false, identical to the
166                    // legacy walker's "column not found" branch
167                    // because Predicate::evaluate handles a Null
168                    // value as a regular comparison (which collapses
169                    // to false against any non-null literal).
170                    let result = predicate.evaluate(value);
171                    stack.push(result);
172                }
173                CompiledOp::AndN(n) => {
174                    let take = (*n).min(stack.len());
175                    let new_len = stack.len() - take;
176                    let result = stack[new_len..].iter().all(|b| *b);
177                    stack.truncate(new_len);
178                    stack.push(result);
179                }
180                CompiledOp::OrN(n) => {
181                    let take = (*n).min(stack.len());
182                    let new_len = stack.len() - take;
183                    let result = stack[new_len..].iter().any(|b| *b);
184                    stack.truncate(new_len);
185                    stack.push(result);
186                }
187                CompiledOp::Not => {
188                    let v = stack.pop().unwrap_or(true);
189                    stack.push(!v);
190                }
191            }
192        }
193        // An empty filter evaluates to `true` (matches everything),
194        // mirroring the legacy walker on an empty `And(vec![])`.
195        stack.pop().unwrap_or(true)
196    }
197}
198
199fn compile_into(
200    filter: &Filter,
201    schema: &HashMap<String, usize>,
202    ops: &mut Vec<CompiledOp>,
203) -> Result<(), CompileError> {
204    match filter {
205        Filter::Predicate(p) => {
206            let col_idx = schema
207                .get(&p.column)
208                .copied()
209                .ok_or_else(|| CompileError::UnknownColumn(p.column.clone()))?;
210            ops.push(CompiledOp::Predicate {
211                col_idx,
212                predicate: p.clone(),
213            });
214        }
215        Filter::And(filters) => {
216            for f in filters {
217                compile_into(f, schema, ops)?;
218            }
219            ops.push(CompiledOp::AndN(filters.len()));
220        }
221        Filter::Or(filters) => {
222            for f in filters {
223                compile_into(f, schema, ops)?;
224            }
225            ops.push(CompiledOp::OrN(filters.len()));
226        }
227        Filter::Not(inner) => {
228            compile_into(inner, schema, ops)?;
229            ops.push(CompiledOp::Not);
230        }
231    }
232    Ok(())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::query::filter::Predicate;
239
240    fn schema(cols: &[&str]) -> HashMap<String, usize> {
241        cols.iter()
242            .enumerate()
243            .map(|(i, c)| (c.to_string(), i))
244            .collect()
245    }
246
247    fn slot(values: Vec<Value>) -> Vec<Value> {
248        values
249    }
250
251    // ---------------- compile / shape ----------------
252
253    #[test]
254    fn compile_simple_eq() {
255        let s = schema(&["a"]);
256        let f = Filter::Predicate(Predicate::eq("a", Value::Integer(5)));
257        let c = CompiledFilter::compile(&f, &s).unwrap();
258        // One predicate op, no boolean composer.
259        assert_eq!(c.op_count(), 1);
260    }
261
262    #[test]
263    fn compile_and_two() {
264        let s = schema(&["a", "b"]);
265        let f = Filter::And(vec![
266            Filter::Predicate(Predicate::eq("a", Value::Integer(1))),
267            Filter::Predicate(Predicate::eq("b", Value::Integer(2))),
268        ]);
269        let c = CompiledFilter::compile(&f, &s).unwrap();
270        // Two predicates + AndN
271        assert_eq!(c.op_count(), 3);
272    }
273
274    #[test]
275    fn compile_or_three() {
276        let s = schema(&["a"]);
277        let f = Filter::Or(vec![
278            Filter::Predicate(Predicate::eq("a", Value::Integer(1))),
279            Filter::Predicate(Predicate::eq("a", Value::Integer(2))),
280            Filter::Predicate(Predicate::eq("a", Value::Integer(3))),
281        ]);
282        let c = CompiledFilter::compile(&f, &s).unwrap();
283        assert_eq!(c.op_count(), 4); // three predicates + OrN
284    }
285
286    #[test]
287    fn compile_not_wraps_inner() {
288        let s = schema(&["a"]);
289        let f = Filter::Not(Box::new(Filter::Predicate(Predicate::eq(
290            "a",
291            Value::Integer(1),
292        ))));
293        let c = CompiledFilter::compile(&f, &s).unwrap();
294        assert_eq!(c.op_count(), 2); // predicate + Not
295    }
296
297    #[test]
298    fn compile_unknown_column_errors() {
299        let s = schema(&["a"]);
300        let f = Filter::Predicate(Predicate::eq("missing", Value::Integer(1)));
301        let err = CompiledFilter::compile(&f, &s).unwrap_err();
302        assert_eq!(err, CompileError::UnknownColumn("missing".to_string()));
303    }
304
305    // ---------------- evaluate ----------------
306
307    #[test]
308    fn eval_eq_true() {
309        let s = schema(&["age"]);
310        let c = CompiledFilter::compile(
311            &Filter::Predicate(Predicate::eq("age", Value::Integer(42))),
312            &s,
313        )
314        .unwrap();
315        assert!(c.evaluate(&slot(vec![Value::Integer(42)])));
316    }
317
318    #[test]
319    fn eval_eq_false() {
320        let s = schema(&["age"]);
321        let c = CompiledFilter::compile(
322            &Filter::Predicate(Predicate::eq("age", Value::Integer(42))),
323            &s,
324        )
325        .unwrap();
326        assert!(!c.evaluate(&slot(vec![Value::Integer(7)])));
327    }
328
329    #[test]
330    fn eval_and_true_when_all_true() {
331        let s = schema(&["a", "b"]);
332        let f = Filter::And(vec![
333            Filter::Predicate(Predicate::eq("a", Value::Integer(1))),
334            Filter::Predicate(Predicate::eq("b", Value::Integer(2))),
335        ]);
336        let c = CompiledFilter::compile(&f, &s).unwrap();
337        assert!(c.evaluate(&slot(vec![Value::Integer(1), Value::Integer(2)])));
338    }
339
340    #[test]
341    fn eval_and_false_when_one_false() {
342        let s = schema(&["a", "b"]);
343        let f = Filter::And(vec![
344            Filter::Predicate(Predicate::eq("a", Value::Integer(1))),
345            Filter::Predicate(Predicate::eq("b", Value::Integer(2))),
346        ]);
347        let c = CompiledFilter::compile(&f, &s).unwrap();
348        assert!(!c.evaluate(&slot(vec![Value::Integer(1), Value::Integer(99)])));
349    }
350
351    #[test]
352    fn eval_or_true_when_one_true() {
353        let s = schema(&["a"]);
354        let f = Filter::Or(vec![
355            Filter::Predicate(Predicate::eq("a", Value::Integer(1))),
356            Filter::Predicate(Predicate::eq("a", Value::Integer(2))),
357        ]);
358        let c = CompiledFilter::compile(&f, &s).unwrap();
359        assert!(c.evaluate(&slot(vec![Value::Integer(2)])));
360    }
361
362    #[test]
363    fn eval_not_flips_result() {
364        let s = schema(&["a"]);
365        let inner = Filter::Predicate(Predicate::eq("a", Value::Integer(1)));
366        let f = Filter::Not(Box::new(inner));
367        let c = CompiledFilter::compile(&f, &s).unwrap();
368        assert!(!c.evaluate(&slot(vec![Value::Integer(1)])));
369        assert!(c.evaluate(&slot(vec![Value::Integer(2)])));
370    }
371
372    #[test]
373    fn eval_is_null_on_null_value() {
374        let s = schema(&["a"]);
375        let c = CompiledFilter::compile(&Filter::Predicate(Predicate::is_null("a")), &s).unwrap();
376        assert!(c.evaluate(&slot(vec![Value::Null])));
377        assert!(!c.evaluate(&slot(vec![Value::Integer(1)])));
378    }
379
380    #[test]
381    fn eval_range_predicate() {
382        let s = schema(&["age"]);
383        let c = CompiledFilter::compile(
384            &Filter::Predicate(Predicate::between(
385                "age",
386                Value::Integer(18),
387                Value::Integer(65),
388            )),
389            &s,
390        )
391        .unwrap();
392        assert!(c.evaluate(&slot(vec![Value::Integer(30)])));
393        assert!(!c.evaluate(&slot(vec![Value::Integer(10)])));
394        assert!(!c.evaluate(&slot(vec![Value::Integer(70)])));
395    }
396
397    #[test]
398    fn eval_nested_and_or_not() {
399        // (a = 1 AND b = 2) OR NOT (c = 3)
400        let s = schema(&["a", "b", "c"]);
401        let f = Filter::Or(vec![
402            Filter::And(vec![
403                Filter::Predicate(Predicate::eq("a", Value::Integer(1))),
404                Filter::Predicate(Predicate::eq("b", Value::Integer(2))),
405            ]),
406            Filter::Not(Box::new(Filter::Predicate(Predicate::eq(
407                "c",
408                Value::Integer(3),
409            )))),
410        ]);
411        let c = CompiledFilter::compile(&f, &s).unwrap();
412
413        // Both branches: AND-branch true.
414        assert!(c.evaluate(&slot(vec![
415            Value::Integer(1),
416            Value::Integer(2),
417            Value::Integer(99),
418        ])));
419        // AND-branch false, NOT-branch true (c != 3).
420        assert!(c.evaluate(&slot(vec![
421            Value::Integer(99),
422            Value::Integer(99),
423            Value::Integer(99),
424        ])));
425        // AND-branch false, NOT-branch false (c == 3).
426        assert!(!c.evaluate(&slot(vec![
427            Value::Integer(99),
428            Value::Integer(99),
429            Value::Integer(3),
430        ])));
431    }
432
433    // ---------------- legacy parity ----------------
434
435    #[test]
436    fn compiled_matches_legacy_for_simple_filters() {
437        let s = schema(&["a", "b"]);
438
439        // (a = 1 AND b > 5) OR a IS NULL
440        let f = Filter::Or(vec![
441            Filter::And(vec![
442                Filter::Predicate(Predicate::eq("a", Value::Integer(1))),
443                Filter::Predicate(Predicate::gt("b", Value::Integer(5))),
444            ]),
445            Filter::Predicate(Predicate::is_null("a")),
446        ]);
447        let compiled = CompiledFilter::compile(&f, &s).unwrap();
448
449        let cases: Vec<(Vec<Value>, bool)> = vec![
450            // a=1, b=10 → AND true
451            (vec![Value::Integer(1), Value::Integer(10)], true),
452            // a=1, b=5 → AND false (b > 5 fails)
453            (vec![Value::Integer(1), Value::Integer(5)], false),
454            // a=null → OR right branch true
455            (vec![Value::Null, Value::Integer(0)], true),
456            // a=2, b=10 → both branches false
457            (vec![Value::Integer(2), Value::Integer(10)], false),
458        ];
459
460        for (slot_vals, expected) in cases {
461            // Compiled
462            let got_compiled = compiled.evaluate(&slot_vals);
463            // Legacy
464            let row: HashMap<String, Value> = vec!["a", "b"]
465                .into_iter()
466                .zip(slot_vals.iter().cloned())
467                .map(|(k, v)| (k.to_string(), v))
468                .collect();
469            let got_legacy = f.evaluate(&|c| row.get(c).cloned());
470            assert_eq!(
471                got_compiled, got_legacy,
472                "compiled and legacy disagree on row {:?}",
473                slot_vals
474            );
475            assert_eq!(got_compiled, expected, "wrong answer for {:?}", slot_vals);
476        }
477    }
478
479    #[test]
480    fn compiled_matches_legacy_for_random_filters() {
481        // Tiny deterministic fuzzer: 1k filter+row pairs, compare
482        // compiled.evaluate vs legacy walker. The compiled path
483        // must agree byte-for-byte with the legacy walker.
484        let s = schema(&["a", "b", "c"]);
485        let mut seed: u64 = 0x9E3779B97F4A7C15;
486        let mut next = || {
487            seed = seed
488                .wrapping_mul(6364136223846793005)
489                .wrapping_add(1442695040888963407);
490            seed
491        };
492
493        for _ in 0..1000 {
494            let n = (next() % 3) as usize;
495            let filter = make_random_filter(&mut next, n);
496            let compiled = match CompiledFilter::compile(&filter, &s) {
497                Ok(c) => c,
498                Err(_) => continue,
499            };
500
501            let row_vals = vec![
502                Value::Integer((next() % 10) as i64),
503                Value::Integer((next() % 10) as i64),
504                Value::Integer((next() % 10) as i64),
505            ];
506            let row: HashMap<String, Value> = vec!["a", "b", "c"]
507                .into_iter()
508                .zip(row_vals.iter().cloned())
509                .map(|(k, v)| (k.to_string(), v))
510                .collect();
511
512            let got_compiled = compiled.evaluate(&row_vals);
513            let got_legacy = filter.evaluate(&|c| row.get(c).cloned());
514            assert_eq!(
515                got_compiled, got_legacy,
516                "fuzz disagreement: filter={:?} row={:?}",
517                filter, row_vals
518            );
519        }
520    }
521
522    fn make_random_filter(next: &mut impl FnMut() -> u64, depth: usize) -> Filter {
523        if depth == 0 {
524            // Leaf — random column / op / int literal.
525            let col = ["a", "b", "c"][(next() % 3) as usize];
526            let lit = Value::Integer((next() % 10) as i64);
527            let op = next() % 5;
528            return match op {
529                0 => Filter::Predicate(Predicate::eq(col, lit)),
530                1 => Filter::Predicate(Predicate::ne(col, lit)),
531                2 => Filter::Predicate(Predicate::lt(col, lit)),
532                3 => Filter::Predicate(Predicate::gt(col, lit)),
533                _ => Filter::Predicate(Predicate::ge(col, lit)),
534            };
535        }
536        match next() % 3 {
537            0 => Filter::And(vec![
538                make_random_filter(next, depth - 1),
539                make_random_filter(next, depth - 1),
540            ]),
541            1 => Filter::Or(vec![
542                make_random_filter(next, depth - 1),
543                make_random_filter(next, depth - 1),
544            ]),
545            _ => Filter::Not(Box::new(make_random_filter(next, depth - 1))),
546        }
547    }
548
549    #[test]
550    fn evaluate_handles_oversized_index_as_null() {
551        // If the schema says column is at index 5 but the slot has
552        // length 3, the eval must treat the missing slot as Null
553        // (no panic, no out-of-bounds).
554        let s = schema(&["a", "b", "out_of_range"]);
555        let f = Filter::Predicate(Predicate::eq("out_of_range", Value::Integer(1)));
556        let c = CompiledFilter::compile(&f, &s).unwrap();
557        // Pass a slot shorter than the schema would suggest.
558        let short_slot = vec![Value::Integer(0), Value::Integer(0)];
559        // Predicate eq against Null returns false (Null != 1).
560        assert!(!c.evaluate(&short_slot));
561    }
562
563    #[test]
564    fn empty_filter_returns_true() {
565        // An empty And() matches everything, like the legacy walker.
566        let s = schema(&["a"]);
567        let c = CompiledFilter::compile(&Filter::And(vec![]), &s).unwrap();
568        assert!(c.evaluate(&slot(vec![Value::Integer(1)])));
569    }
570}