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