Skip to main content

mongreldb_kit/
pushdown.rs

1//! Predicate pushdown: translate Kit `Expr` predicates into MongrelDB core
2//! `Condition`s so the native engine can resolve them via indexes instead of a
3//! full-scan + Rust evaluation (Kit Priority 1).
4//!
5//! ## Design
6//!
7//! The Kit's query layer materializes every visible row and evaluates
8//! predicates in Rust. This module is the bridge that lets simple, index-
9//! served predicates bypass that full scan. It produces [`PushdownPlan`] — a
10//! list of native [`Condition`]s that the core engine can resolve via HOT /
11//! bitmap / range indexes, plus a flag indicating whether the translation
12//! covered the entire predicate.
13//!
14//! Core's native `Query` is a **conjunction** (AND of conditions) that resolves
15//! to a row-id set. Conditions always return a **superset** of the matching
16//! rows; the Kit must still re-apply the original `Expr` filter in Rust on the
17//! survivors unless [`PushdownPlan::fully_translated`] is true. This makes
18//! partial translation safe by construction.
19//!
20//! ## Supported translations
21//!
22//! | Kit `Expr` | Core `Condition` | Requirement |
23//! |---|---|---|
24//! | `Eq(Column, Literal)` on PK | `Pk` | single-column PK |
25//! | `Eq(Column, Literal)` | `BitmapEq` | bitmap-indexed column |
26//! | `In(Column, [literals])` | `BitmapIn` | bitmap-indexed column |
27//! | `Lt/Lte/Gt/Gte(Column, Literal)` int | `Range` | int-typed column |
28//! | `Lt/Lte/Gt/Gte(Column, Literal)` float | `RangeF64` | float-typed column |
29//! | `Contains(Column, needle)` | `FmContains` | FM-indexed column (residual re-check) |
30//! | `Like(Column, pattern)` | `FmContainsAll` | FM-indexed column (residual re-check) |
31//! | `IsNull` / `IsNotNull(Column)` | `IsNull` / `IsNotNull` | page-stat-aware (residual re-check) |
32//! | `And([sub-exprs])` | recurse each | each part translated independently |
33//!
34//! Unsupported (`Or`, `Not`, `Ne`, `NotIn`, `InSubquery`, `Exists`, cross-column
35//! comparisons) are left as residual Rust evaluation — the caller falls back to
36//! a full scan for those branches.
37
38use mongreldb_core::query::Condition;
39use mongreldb_kit_core::query::{Expr, Literal};
40use mongreldb_kit_core::schema::{ColumnType, IndexKind as KitIndexKind, Table as KitTable};
41use serde_json::{Map, Value};
42
43/// The result of translating a Kit predicate into core conditions.
44#[derive(Debug, Clone)]
45pub struct PushdownPlan {
46    /// Native conditions to push to core (ANDed together).
47    pub conditions: Vec<Condition>,
48    /// Whether the entire source `Expr` was translated. When `true`, the core
49    /// query result is exact and no Rust-side re-filtering is needed.
50    pub fully_translated: bool,
51}
52
53impl PushdownPlan {
54    /// Whether there are conditions worth pushing (vs. a full scan).
55    pub fn can_push(&self) -> bool {
56        !self.conditions.is_empty()
57    }
58}
59
60/// Attempt to translate `expr` into native `Condition`s for `table`. Returns
61/// `None` when no part of the expression could be translated (caller falls back
62/// to a full scan + Rust evaluation).
63pub fn translate_predicate(table: &KitTable, expr: &Expr) -> Option<PushdownPlan> {
64    let mut conditions = Vec::new();
65    let fully = collect_conditions(table, expr, &mut conditions);
66    if conditions.is_empty() {
67        return None;
68    }
69    Some(PushdownPlan {
70        conditions,
71        fully_translated: fully,
72    })
73}
74
75/// Recursively collect translatable conditions from `expr`. Returns `true` if
76/// the entire sub-expression was translated (no residual needed for it).
77fn collect_conditions(table: &KitTable, expr: &Expr, out: &mut Vec<Condition>) -> bool {
78    match expr {
79        Expr::And(parts) => {
80            let mut all = true;
81            for part in parts {
82                if !collect_conditions(table, part, out) {
83                    all = false;
84                }
85            }
86            all
87        }
88        Expr::Eq(a, b) => push_if_some(out, try_translate_eq(table, a, b)),
89        Expr::Lt(a, b) => push_if_some(out, try_translate_cmp(table, a, b, CmpOp::Lt)),
90        Expr::Lte(a, b) => push_if_some(out, try_translate_cmp(table, a, b, CmpOp::Lte)),
91        Expr::Gt(a, b) => push_if_some(out, try_translate_cmp(table, a, b, CmpOp::Gt)),
92        Expr::Gte(a, b) => push_if_some(out, try_translate_cmp(table, a, b, CmpOp::Gte)),
93        Expr::In(a, list) => push_if_some(out, try_translate_in(table, a, list)),
94        Expr::Contains(a, needle) => {
95            // FM substring: push `FmContains` when the column has an FM index,
96            // but keep `Contains` as a residual (the engine returns a superset)
97            // by reporting the sub-expression as not fully translated.
98            if let Some(c) = try_translate_contains(table, a, needle) {
99                out.push(c);
100            }
101            false
102        }
103        Expr::Like(a, pattern) => {
104            // Multi-segment LIKE: push `FmContainsAll` of the literal runs (a
105            // superset — order and wildcards are re-checked by the residual).
106            if let Some(c) = try_translate_like(table, a, pattern) {
107                out.push(c);
108            }
109            false
110        }
111        Expr::IsNull(a) => {
112            if let Some(c) = try_translate_null(table, a, true) {
113                out.push(c);
114            }
115            false
116        }
117        Expr::IsNotNull(a) => {
118            if let Some(c) = try_translate_null(table, a, false) {
119                out.push(c);
120            }
121            false
122        }
123        // Unsupported: Or, Not, Ne, NotIn, Like, InSubquery, Exists, NotExists →
124        // leave as residual Rust evaluation.
125        _ => false,
126    }
127}
128
129/// Translate `IsNull(Column)` / `IsNotNull(Column)` into the engine's page-stat-
130/// aware null conditions. Kept as a residual (the engine returns a superset).
131fn try_translate_null(table: &KitTable, a: &Expr, is_null: bool) -> Option<Condition> {
132    let Expr::Column(col_name) = a else {
133        return None;
134    };
135    let column_id = table.column(col_name)?.id as u16;
136    Some(if is_null {
137        Condition::IsNull { column_id }
138    } else {
139        Condition::IsNotNull { column_id }
140    })
141}
142
143/// Push `opt` into `out` if `Some`, returning whether it was pushed.
144fn push_if_some(out: &mut Vec<Condition>, opt: Option<Condition>) -> bool {
145    match opt {
146        Some(c) => {
147            out.push(c);
148            true
149        }
150        None => false,
151    }
152}
153
154#[derive(Clone, Copy)]
155enum CmpOp {
156    Lt,
157    Lte,
158    Gt,
159    Gte,
160}
161
162/// Extract a `(column_name, literal)` pair from two expression sides, handling
163/// both `Column op Literal` and `Literal op Column` orderings.
164fn extract_column_literal<'a>(a: &'a Expr, b: &'a Expr) -> Option<(&'a str, &'a Literal)> {
165    match (a, b) {
166        (Expr::Column(name), Expr::Literal(lit)) => Some((name.as_str(), lit)),
167        (Expr::Literal(lit), Expr::Column(name)) => Some((name.as_str(), lit)),
168        _ => None, // Column-op-Column is not translatable.
169    }
170}
171
172/// Translate `Eq(Column, Literal)` into `Pk` (single-col PK) or `BitmapEq`.
173fn try_translate_eq(table: &KitTable, a: &Expr, b: &Expr) -> Option<Condition> {
174    let (col_name, lit) = extract_column_literal(a, b)?;
175    let col = table.column(col_name)?;
176    let col_id = col.id as u16;
177    let ty = col.storage_type;
178
179    // Single-column PK → O(1) HOT probe via Condition::Pk.
180    if table.primary_key.len() == 1 && table.primary_key[0] == col_name {
181        let key = literal_to_index_key(lit, ty)?;
182        return Some(Condition::Pk(key));
183    }
184
185    // Bitmap-indexed column → BitmapEq. The Kit creates bitmap indexes for
186    // every declared index and unique constraint column (see schema::to_core_schema).
187    if has_bitmap_index(table, col_name) {
188        let value = literal_to_index_key(lit, ty)?;
189        return Some(Condition::BitmapEq {
190            column_id: col_id,
191            value,
192        });
193    }
194
195    None
196}
197
198/// Translate `Lt/Lte/Gt/Gte(Column, Literal)` into `Range` (int) or `RangeF64`.
199fn try_translate_cmp(table: &KitTable, a: &Expr, b: &Expr, op: CmpOp) -> Option<Condition> {
200    let (col_name, lit) = extract_column_literal(a, b)?;
201    let col = table.column(col_name)?;
202    let col_id = col.id as u16;
203    let ty = col.storage_type;
204
205    if is_int_type(ty) {
206        let v = literal_to_i64(lit)?;
207        let (lo, hi) = match op {
208            CmpOp::Lt => (i64::MIN, v.saturating_sub(1)),
209            CmpOp::Lte => (i64::MIN, v),
210            CmpOp::Gt => (v.saturating_add(1), i64::MAX),
211            CmpOp::Gte => (v, i64::MAX),
212        };
213        Some(Condition::Range {
214            column_id: col_id,
215            lo,
216            hi,
217        })
218    } else if is_float_type(ty) {
219        let v = literal_to_f64(lit)?;
220        let (lo, lo_inc, hi, hi_inc) = match op {
221            CmpOp::Lt => (f64::NEG_INFINITY, false, v, false),
222            CmpOp::Lte => (f64::NEG_INFINITY, false, v, true),
223            CmpOp::Gt => (v, false, f64::INFINITY, false),
224            CmpOp::Gte => (v, true, f64::INFINITY, false),
225        };
226        Some(Condition::RangeF64 {
227            column_id: col_id,
228            lo,
229            lo_inclusive: lo_inc,
230            hi,
231            hi_inclusive: hi_inc,
232        })
233    } else {
234        None
235    }
236}
237
238/// Whether `col_name` has a declared FM (substring) index.
239fn has_fm_index(table: &KitTable, col_name: &str) -> bool {
240    table
241        .indexes
242        .iter()
243        .any(|idx| idx.kind == KitIndexKind::Fm && idx.columns.iter().any(|c| c == col_name))
244}
245
246/// Translate `Like(Column, pattern)` into `FmContainsAll` of the pattern's
247/// literal runs (the text between `%`/`_` wildcards) when the column has an FM
248/// index. Every literal run is a required substring, so the result is a superset
249/// of the real `LIKE`; the Kit re-checks the pattern in Rust. Escaped patterns
250/// (containing `\`) are left to a full scan.
251fn try_translate_like(table: &KitTable, a: &Expr, pattern: &str) -> Option<Condition> {
252    let Expr::Column(col_name) = a else {
253        return None;
254    };
255    let col = table.column(col_name)?;
256    if !has_fm_index(table, col_name) || pattern.contains('\\') {
257        return None;
258    }
259    let patterns: Vec<Vec<u8>> = pattern
260        .split(['%', '_'])
261        .filter(|s| !s.is_empty())
262        .map(|s| s.as_bytes().to_vec())
263        .collect();
264    if patterns.is_empty() {
265        return None;
266    }
267    Some(Condition::FmContainsAll {
268        column_id: col.id as u16,
269        patterns,
270    })
271}
272
273/// Translate `Contains(Column, needle)` into `FmContains` when the column has an
274/// FM index. The engine returns rows whose column contains `needle` as a
275/// substring — a superset the Kit re-checks in Rust.
276fn try_translate_contains(table: &KitTable, a: &Expr, needle: &str) -> Option<Condition> {
277    let Expr::Column(col_name) = a else {
278        return None;
279    };
280    let col = table.column(col_name)?;
281    if !has_fm_index(table, col_name) {
282        return None;
283    }
284    Some(Condition::FmContains {
285        column_id: col.id as u16,
286        pattern: needle.as_bytes().to_vec(),
287    })
288}
289
290/// Translate `In(Column, [literals])` into `BitmapIn`.
291fn try_translate_in(table: &KitTable, a: &Expr, list: &[Literal]) -> Option<Condition> {
292    let Expr::Column(col_name) = a else {
293        return None;
294    };
295    let col = table.column(col_name)?;
296    let col_id = col.id as u16;
297    let ty = col.storage_type;
298    if !has_bitmap_index(table, col_name) {
299        return None;
300    }
301    let mut values = Vec::with_capacity(list.len());
302    for lit in list {
303        values.push(literal_to_index_key(lit, ty)?);
304    }
305    Some(Condition::BitmapIn {
306        column_id: col_id,
307        values,
308    })
309}
310
311/// Build a `Condition::Pk` (or bitmap fallback) from a PK value map. Used by
312/// `get_by_pk_internal` and FK `parent_exists` — the highest-value pushdown
313/// targets since they currently do O(N) linear scans with the PK in hand.
314///
315/// For a single-column PK, produces one `Condition::Pk` (O(1) HOT probe).
316/// For a composite PK, produces `BitmapEq` conditions for each PK column
317/// (intersected by the core query).
318pub fn pk_conditions(table: &KitTable, pk_map: &Map<String, Value>) -> Option<Vec<Condition>> {
319    if pk_map.is_empty() || table.primary_key.is_empty() {
320        return None;
321    }
322    let mut conditions = Vec::with_capacity(table.primary_key.len());
323    for pk_name in &table.primary_key {
324        let value = pk_map.get(pk_name)?;
325        let col = table.column(pk_name)?;
326        let lit = json_to_literal(value)?;
327        let key = literal_to_index_key(&lit, col.storage_type)?;
328        if table.primary_key.len() == 1 {
329            // Single-column PK: use HOT for O(1) lookup.
330            conditions.push(Condition::Pk(key));
331        } else {
332            // Composite PK: use bitmap eq on each PK column.
333            conditions.push(Condition::BitmapEq {
334                column_id: col.id as u16,
335                value: key,
336            });
337        }
338    }
339    Some(conditions)
340}
341
342// ── value encoding helpers ──────────────────────────────────────────────
343
344/// Check if the Kit table has a bitmap index on `col_name` (declared index or
345/// unique constraint). Mirrors the logic in `schema::to_core_schema`.
346fn has_bitmap_index(table: &KitTable, col_name: &str) -> bool {
347    table
348        .indexes
349        .iter()
350        .any(|idx| idx.columns.iter().any(|c| c == col_name))
351        || table
352            .unique_constraints
353            .iter()
354            .any(|uq| uq.columns.iter().any(|c| c == col_name))
355        || table.primary_key.contains(&col_name.to_string())
356}
357
358/// A column backed by a real **bitmap** index (declared index or unique
359/// constraint) — unlike [`has_bitmap_index`], excludes the primary key, which
360/// gets a HOT (not bitmap) index in `to_core_schema`. `BitmapIn` on a column
361/// without a bitmap index returns an empty set (not a superset), so the FK-join
362/// probe must only build one when this is true.
363pub(crate) fn has_declared_bitmap_index(table: &KitTable, col_name: &str) -> bool {
364    table
365        .indexes
366        .iter()
367        .any(|idx| idx.columns.iter().any(|c| c == col_name))
368        || table
369            .unique_constraints
370            .iter()
371            .any(|uq| uq.columns.iter().any(|c| c == col_name))
372}
373
374/// Encode a JSON value into the bitmap-index key bytes for column type `ty`,
375/// matching [`literal_to_index_key`]. Returns `None` for nulls / unencodable
376/// values (which then simply don't contribute a probe key).
377pub(crate) fn value_index_key(v: &Value, ty: ColumnType) -> Option<Vec<u8>> {
378    let lit = match v {
379        Value::Bool(b) => Literal::Bool(*b),
380        Value::Number(n) => n
381            .as_i64()
382            .map(Literal::Int)
383            .or_else(|| n.as_f64().map(Literal::Float))?,
384        Value::String(s) => Literal::Text(s.clone()),
385        _ => return None,
386    };
387    literal_to_index_key(&lit, ty)
388}
389
390fn is_int_type(ty: ColumnType) -> bool {
391    matches!(
392        ty,
393        ColumnType::Int8
394            | ColumnType::Int16
395            | ColumnType::Int32
396            | ColumnType::Int64
397            | ColumnType::Bool
398            | ColumnType::TimestampNanos
399    )
400}
401
402fn is_float_type(ty: ColumnType) -> bool {
403    matches!(ty, ColumnType::Float32 | ColumnType::Float64)
404}
405
406/// Encode a `Literal` to the byte form that core's bitmap/HOT indexes use
407/// (matching [`mongreldb_core::memtable::Value::encode_key`]).
408fn literal_to_index_key(lit: &Literal, ty: ColumnType) -> Option<Vec<u8>> {
409    match lit {
410        Literal::Null => None, // Nulls are not indexed.
411        Literal::Bool(b) => Some(vec![*b as u8]),
412        Literal::Int(n) => {
413            if is_float_type(ty) {
414                // The column stores Float64; encode as f64 bits.
415                Some((*n as f64).to_bits().to_be_bytes().to_vec())
416            } else {
417                Some(n.to_be_bytes().to_vec())
418            }
419        }
420        Literal::Float(f) => Some(f.to_bits().to_be_bytes().to_vec()),
421        Literal::Text(s) => Some(s.as_bytes().to_vec()),
422        Literal::Json(v) => Some(serde_json::to_vec(v).ok()?),
423    }
424}
425
426fn literal_to_i64(lit: &Literal) -> Option<i64> {
427    match lit {
428        Literal::Int(n) => Some(*n),
429        Literal::Bool(b) => Some(*b as i64),
430        _ => None,
431    }
432}
433
434fn literal_to_f64(lit: &Literal) -> Option<f64> {
435    match lit {
436        Literal::Int(n) => Some(*n as f64),
437        Literal::Float(f) => Some(*f),
438        _ => None,
439    }
440}
441
442/// Convert a JSON `Value` to a `Literal` for the PK-conditions builder.
443fn json_to_literal(value: &Value) -> Option<Literal> {
444    match value {
445        Value::Null => Some(Literal::Null),
446        Value::Bool(b) => Some(Literal::Bool(*b)),
447        Value::Number(n) => {
448            if let Some(i) = n.as_i64() {
449                Some(Literal::Int(i))
450            } else {
451                Some(Literal::Float(n.as_f64()?))
452            }
453        }
454        Value::String(s) => Some(Literal::Text(s.clone())),
455        Value::Array(_) | Value::Object(_) => Some(Literal::Json(value.clone())),
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use mongreldb_kit_core::query::Expr;
463    use mongreldb_kit_core::schema::{Column, Index, Table as KitTable};
464
465    fn sample_table() -> KitTable {
466        KitTable {
467            id: 1,
468            name: "users".into(),
469            columns: vec![
470                {
471                    let mut c = Column::new(1, "id", ColumnType::Int64);
472                    c.primary_key = true;
473                    c
474                },
475                Column::new(2, "email", ColumnType::Text),
476                {
477                    let mut c = Column::new(3, "age", ColumnType::Int64);
478                    c.nullable = true;
479                    c
480                },
481                {
482                    let mut c = Column::new(4, "score", ColumnType::Float64);
483                    c.nullable = true;
484                    c
485                },
486            ],
487            primary_key: vec!["id".into()],
488            indexes: vec![Index {
489                name: "idx_email".into(),
490                columns: vec!["email".into()],
491                unique: false,
492                kind: Default::default(),
493            }],
494            foreign_keys: vec![],
495            unique_constraints: vec![],
496            check_constraints: vec![],
497        }
498    }
499
500    #[test]
501    fn translate_pk_eq() {
502        let t = sample_table();
503        let expr = Expr::Eq(
504            Box::new(Expr::Column("id".into())),
505            Box::new(Expr::Literal(Literal::Int(42))),
506        );
507        let plan = translate_predicate(&t, &expr).expect("should translate");
508        assert!(plan.fully_translated);
509        assert_eq!(plan.conditions.len(), 1);
510        assert!(matches!(plan.conditions[0], Condition::Pk(_)));
511    }
512
513    #[test]
514    fn translate_bitmap_eq() {
515        let t = sample_table();
516        let expr = Expr::Eq(
517            Box::new(Expr::Column("email".into())),
518            Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
519        );
520        let plan = translate_predicate(&t, &expr).expect("should translate");
521        assert!(plan.fully_translated);
522        assert!(matches!(
523            plan.conditions[0],
524            Condition::BitmapEq { column_id: 2, .. }
525        ));
526    }
527
528    #[test]
529    fn translate_int_range() {
530        let t = sample_table();
531        let expr = Expr::Gte(
532            Box::new(Expr::Column("age".into())),
533            Box::new(Expr::Literal(Literal::Int(18))),
534        );
535        let plan = translate_predicate(&t, &expr).expect("should translate");
536        assert!(plan.fully_translated);
537        assert!(matches!(
538            plan.conditions[0],
539            Condition::Range {
540                column_id: 3,
541                lo: 18,
542                ..
543            }
544        ));
545    }
546
547    #[test]
548    fn translate_and_of_translatable() {
549        let t = sample_table();
550        let expr = Expr::And(vec![
551            Expr::Eq(
552                Box::new(Expr::Column("email".into())),
553                Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
554            ),
555            Expr::Gt(
556                Box::new(Expr::Column("age".into())),
557                Box::new(Expr::Literal(Literal::Int(21))),
558            ),
559        ]);
560        let plan = translate_predicate(&t, &expr).expect("should translate");
561        assert!(plan.fully_translated);
562        assert_eq!(plan.conditions.len(), 2);
563    }
564
565    #[test]
566    fn translate_and_partial() {
567        let t = sample_table();
568        // email = 'a@b.com' AND (age > 21 OR score < 5)
569        let expr = Expr::And(vec![
570            Expr::Eq(
571                Box::new(Expr::Column("email".into())),
572                Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
573            ),
574            Expr::Or(vec![
575                Expr::Gt(
576                    Box::new(Expr::Column("age".into())),
577                    Box::new(Expr::Literal(Literal::Int(21))),
578                ),
579                Expr::Lt(
580                    Box::new(Expr::Column("score".into())),
581                    Box::new(Expr::Literal(Literal::Float(5.0))),
582                ),
583            ]),
584        ]);
585        let plan = translate_predicate(&t, &expr).expect("should partially translate");
586        assert!(!plan.fully_translated); // OR is not translatable
587        assert_eq!(plan.conditions.len(), 1); // only the bitmap eq pushed
588    }
589
590    #[test]
591    fn translate_unsupported_returns_none() {
592        let t = sample_table();
593        let expr = Expr::Or(vec![
594            Expr::Eq(
595                Box::new(Expr::Column("email".into())),
596                Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
597            ),
598            Expr::Eq(
599                Box::new(Expr::Column("email".into())),
600                Box::new(Expr::Literal(Literal::Text("c@d.com".into()))),
601            ),
602        ]);
603        assert!(translate_predicate(&t, &expr).is_none());
604    }
605
606    #[test]
607    fn translate_in_to_bitmap_in() {
608        let t = sample_table();
609        let expr = Expr::In(
610            Box::new(Expr::Column("email".into())),
611            vec![
612                Literal::Text("a@b.com".into()),
613                Literal::Text("c@d.com".into()),
614            ],
615        );
616        let plan = translate_predicate(&t, &expr).expect("should translate");
617        assert!(plan.fully_translated);
618        assert!(matches!(
619            plan.conditions[0],
620            Condition::BitmapIn { column_id: 2, .. }
621        ));
622    }
623
624    #[test]
625    fn pk_conditions_single_col() {
626        let t = sample_table();
627        let mut pk_map = Map::new();
628        pk_map.insert("id".into(), Value::Number(42.into()));
629        let conds = pk_conditions(&t, &pk_map).expect("should build");
630        assert_eq!(conds.len(), 1);
631        assert!(matches!(conds[0], Condition::Pk(_)));
632    }
633}