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
373        .indexes
374        .iter()
375        .any(|idx| idx.columns.iter().any(|c| c == col_name))
376        || table
377            .unique_constraints
378            .iter()
379            .any(|uq| uq.columns.iter().any(|c| c == col_name))
380        || table.primary_key.contains(&col_name.to_string())
381}
382
383/// A column backed by a real **bitmap** index (declared index or unique
384/// constraint) — unlike [`has_bitmap_index`], excludes the primary key, which
385/// gets a HOT (not bitmap) index in `to_core_schema`. `BitmapIn` on a column
386/// without a bitmap index returns an empty set (not a superset), so the FK-join
387/// probe must only build one when this is true.
388pub(crate) fn has_declared_bitmap_index(table: &KitTable, col_name: &str) -> bool {
389    table
390        .indexes
391        .iter()
392        .any(|idx| idx.columns.iter().any(|c| c == col_name))
393        || table
394            .unique_constraints
395            .iter()
396            .any(|uq| uq.columns.iter().any(|c| c == col_name))
397}
398
399/// Encode a JSON value into the bitmap-index key bytes for column type `ty`,
400/// matching [`literal_to_index_key`]. Returns `None` for nulls / unencodable
401/// values (which then simply don't contribute a probe key).
402pub(crate) fn value_index_key(v: &Value, ty: ColumnType) -> Option<Vec<u8>> {
403    let lit = match v {
404        Value::Bool(b) => Literal::Bool(*b),
405        Value::Number(n) => n
406            .as_i64()
407            .map(Literal::Int)
408            .or_else(|| n.as_f64().map(Literal::Float))?,
409        Value::String(s) => Literal::Text(s.clone()),
410        _ => return None,
411    };
412    literal_to_index_key(&lit, ty)
413}
414
415fn is_int_type(ty: ColumnType) -> bool {
416    matches!(
417        ty,
418        ColumnType::Int8
419            | ColumnType::Int16
420            | ColumnType::Int32
421            | ColumnType::Int64
422            | ColumnType::Bool
423            | ColumnType::TimestampNanos
424    )
425}
426
427fn is_float_type(ty: ColumnType) -> bool {
428    matches!(ty, ColumnType::Float32 | ColumnType::Float64)
429}
430
431/// Encode a `Literal` to the byte form that core's bitmap/HOT indexes use
432/// (matching [`mongreldb_core::memtable::Value::encode_key`]).
433fn literal_to_index_key(lit: &Literal, ty: ColumnType) -> Option<Vec<u8>> {
434    match lit {
435        Literal::Null => None, // Nulls are not indexed.
436        Literal::Bool(b) => Some(vec![*b as u8]),
437        Literal::Int(n) => {
438            if is_float_type(ty) {
439                // The column stores Float64; encode as f64 bits.
440                Some((*n as f64).to_bits().to_be_bytes().to_vec())
441            } else {
442                Some(n.to_be_bytes().to_vec())
443            }
444        }
445        Literal::Float(f) => Some(f.to_bits().to_be_bytes().to_vec()),
446        Literal::Text(s) => Some(s.as_bytes().to_vec()),
447        Literal::Json(v) => Some(serde_json::to_vec(v).ok()?),
448    }
449}
450
451fn literal_to_i64(lit: &Literal) -> Option<i64> {
452    match lit {
453        Literal::Int(n) => Some(*n),
454        Literal::Bool(b) => Some(*b as i64),
455        _ => None,
456    }
457}
458
459fn literal_to_f64(lit: &Literal) -> Option<f64> {
460    match lit {
461        Literal::Int(n) => Some(*n as f64),
462        Literal::Float(f) => Some(*f),
463        _ => None,
464    }
465}
466
467/// Convert a JSON `Value` to a `Literal` for the PK-conditions builder.
468fn json_to_literal(value: &Value) -> Option<Literal> {
469    match value {
470        Value::Null => Some(Literal::Null),
471        Value::Bool(b) => Some(Literal::Bool(*b)),
472        Value::Number(n) => {
473            if let Some(i) = n.as_i64() {
474                Some(Literal::Int(i))
475            } else {
476                Some(Literal::Float(n.as_f64()?))
477            }
478        }
479        Value::String(s) => Some(Literal::Text(s.clone())),
480        Value::Array(_) | Value::Object(_) => Some(Literal::Json(value.clone())),
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use mongreldb_kit_core::query::Expr;
488    use mongreldb_kit_core::schema::{Column, Index, Table as KitTable};
489
490    fn sample_table() -> KitTable {
491        KitTable {
492            id: 1,
493            name: "users".into(),
494            columns: vec![
495                {
496                    let mut c = Column::new(1, "id", ColumnType::Int64);
497                    c.primary_key = true;
498                    c
499                },
500                Column::new(2, "email", ColumnType::Text),
501                {
502                    let mut c = Column::new(3, "age", ColumnType::Int64);
503                    c.nullable = true;
504                    c
505                },
506                {
507                    let mut c = Column::new(4, "score", ColumnType::Float64);
508                    c.nullable = true;
509                    c
510                },
511            ],
512            primary_key: vec!["id".into()],
513            indexes: vec![Index {
514                name: "idx_email".into(),
515                columns: vec!["email".into()],
516                unique: false,
517                kind: Default::default(),
518            }],
519            foreign_keys: vec![],
520            unique_constraints: vec![],
521            check_constraints: vec![],
522        }
523    }
524
525    #[test]
526    fn translate_pk_eq() {
527        let t = sample_table();
528        let expr = Expr::Eq(
529            Box::new(Expr::Column("id".into())),
530            Box::new(Expr::Literal(Literal::Int(42))),
531        );
532        let plan = translate_predicate(&t, &expr).expect("should translate");
533        assert!(plan.fully_translated);
534        assert_eq!(plan.conditions.len(), 1);
535        assert!(matches!(plan.conditions[0], Condition::Pk(_)));
536    }
537
538    #[test]
539    fn translate_bitmap_eq() {
540        let t = sample_table();
541        let expr = Expr::Eq(
542            Box::new(Expr::Column("email".into())),
543            Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
544        );
545        let plan = translate_predicate(&t, &expr).expect("should translate");
546        assert!(plan.fully_translated);
547        assert!(matches!(
548            plan.conditions[0],
549            Condition::BitmapEq { column_id: 2, .. }
550        ));
551    }
552
553    #[test]
554    fn translate_int_range() {
555        let t = sample_table();
556        let expr = Expr::Gte(
557            Box::new(Expr::Column("age".into())),
558            Box::new(Expr::Literal(Literal::Int(18))),
559        );
560        let plan = translate_predicate(&t, &expr).expect("should translate");
561        assert!(plan.fully_translated);
562        assert!(matches!(
563            plan.conditions[0],
564            Condition::Range {
565                column_id: 3,
566                lo: 18,
567                ..
568            }
569        ));
570    }
571
572    #[test]
573    fn translate_and_of_translatable() {
574        let t = sample_table();
575        let expr = Expr::And(vec![
576            Expr::Eq(
577                Box::new(Expr::Column("email".into())),
578                Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
579            ),
580            Expr::Gt(
581                Box::new(Expr::Column("age".into())),
582                Box::new(Expr::Literal(Literal::Int(21))),
583            ),
584        ]);
585        let plan = translate_predicate(&t, &expr).expect("should translate");
586        assert!(plan.fully_translated);
587        assert_eq!(plan.conditions.len(), 2);
588    }
589
590    #[test]
591    fn translate_and_partial() {
592        let t = sample_table();
593        // email = 'a@b.com' AND (age > 21 OR score < 5)
594        let expr = Expr::And(vec![
595            Expr::Eq(
596                Box::new(Expr::Column("email".into())),
597                Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
598            ),
599            Expr::Or(vec![
600                Expr::Gt(
601                    Box::new(Expr::Column("age".into())),
602                    Box::new(Expr::Literal(Literal::Int(21))),
603                ),
604                Expr::Lt(
605                    Box::new(Expr::Column("score".into())),
606                    Box::new(Expr::Literal(Literal::Float(5.0))),
607                ),
608            ]),
609        ]);
610        let plan = translate_predicate(&t, &expr).expect("should partially translate");
611        assert!(!plan.fully_translated); // OR is not translatable
612        assert_eq!(plan.conditions.len(), 1); // only the bitmap eq pushed
613    }
614
615    #[test]
616    fn translate_unsupported_returns_none() {
617        let t = sample_table();
618        let expr = Expr::Or(vec![
619            Expr::Eq(
620                Box::new(Expr::Column("email".into())),
621                Box::new(Expr::Literal(Literal::Text("a@b.com".into()))),
622            ),
623            Expr::Eq(
624                Box::new(Expr::Column("email".into())),
625                Box::new(Expr::Literal(Literal::Text("c@d.com".into()))),
626            ),
627        ]);
628        assert!(translate_predicate(&t, &expr).is_none());
629    }
630
631    #[test]
632    fn translate_in_to_bitmap_in() {
633        let t = sample_table();
634        let expr = Expr::In(
635            Box::new(Expr::Column("email".into())),
636            vec![
637                Literal::Text("a@b.com".into()),
638                Literal::Text("c@d.com".into()),
639            ],
640        );
641        let plan = translate_predicate(&t, &expr).expect("should translate");
642        assert!(plan.fully_translated);
643        assert!(matches!(
644            plan.conditions[0],
645            Condition::BitmapIn { column_id: 2, .. }
646        ));
647    }
648
649    #[test]
650    fn pk_conditions_single_col() {
651        let t = sample_table();
652        let mut pk_map = Map::new();
653        pk_map.insert("id".into(), Value::Number(42.into()));
654        let conds = pk_conditions(&t, &pk_map).expect("should build");
655        assert_eq!(conds.len(), 1);
656        assert!(matches!(conds[0], Condition::Pk(_)));
657    }
658}