Skip to main content

nodedb_query/msgpack_scan/
filter.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Binary filter evaluation on raw MessagePack documents.
4//!
5//! `ScanFilter::matches_binary(doc: &[u8])` evaluates a filter predicate
6//! directly on msgpack bytes without decoding to `serde_json::Value`.
7//! Uses `Value::eq_coerced`/`partial_cmp_coerced` for type coercion — single
8//! source of truth shared with the JSON filter path.
9
10use std::cmp::Ordering;
11
12use crate::expr::EvalError;
13use crate::msgpack_scan::field::extract_field;
14use crate::msgpack_scan::index::FieldIndex;
15use crate::msgpack_scan::reader::{
16    array_header, map_header, read_null, read_str, read_value, skip_value,
17};
18use crate::scan_filter::like::sql_like_match;
19use crate::scan_filter::types::column_compare;
20use crate::scan_filter::{FilterOp, ScanFilter};
21
22impl ScanFilter {
23    /// Evaluate an AND-group of filters, short-circuiting on the first
24    /// `false` or the first evaluation error — same semantics as
25    /// `group.iter().all(|f| f.matches_binary(doc))` had before filter
26    /// evaluation could fail. A `pub` associated
27    /// function (rather than a private free function) so the many call
28    /// sites across the `nodedb` crate that would otherwise write
29    /// `filters.iter().all(|f| f.matches_binary(doc))` have a single
30    /// drop-in replacement instead of each hand-rolling the same
31    /// short-circuit loop.
32    pub fn all_match_binary(group: &[ScanFilter], doc: &[u8]) -> Result<bool, EvalError> {
33        for f in group {
34            if !f.matches_binary(doc)? {
35                return Ok(false);
36            }
37        }
38        Ok(true)
39    }
40
41    /// Indexed counterpart of [`ScanFilter::all_match_binary`].
42    pub fn all_match_binary_indexed(
43        group: &[ScanFilter],
44        doc: &[u8],
45        idx: &FieldIndex,
46    ) -> Result<bool, EvalError> {
47        for f in group {
48            if !f.matches_binary_indexed(doc, idx)? {
49                return Ok(false);
50            }
51        }
52        Ok(true)
53    }
54}
55
56impl ScanFilter {
57    /// Evaluate this filter against a raw MessagePack document.
58    ///
59    /// Zero deserialization — extracts only the needed field bytes.
60    ///
61    /// Returns `Err(EvalError::DivisionByZero)` when this is (or contains,
62    /// via an `OR` group) a `FilterOp::Expr` predicate that divides or
63    /// takes a modulus by zero — see
64    /// `scan_filter::ScanFilter::matches_value` for the WHERE-clause
65    /// behavior-flip rationale, which applies identically here.
66    pub fn matches_binary(&self, doc: &[u8]) -> Result<bool, EvalError> {
67        match self.op {
68            FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return Ok(true),
69            FilterOp::Or => {
70                for clause in &self.clauses {
71                    if Self::all_match_binary(clause, doc)? {
72                        return Ok(true);
73                    }
74                }
75                return Ok(false);
76            }
77            FilterOp::Expr => {
78                return match (self.expr.as_ref(), nodedb_types::value_from_msgpack(doc)) {
79                    (Some(expr), Ok(value)) => Ok(crate::value_ops::is_truthy(&expr.eval(&value)?)),
80                    _ => Ok(false),
81                };
82            }
83            FilterOp::Eq
84            | FilterOp::Ne
85            | FilterOp::Gt
86            | FilterOp::Gte
87            | FilterOp::Lt
88            | FilterOp::Lte
89            | FilterOp::Contains
90            | FilterOp::Like
91            | FilterOp::NotLike
92            | FilterOp::Ilike
93            | FilterOp::NotIlike
94            | FilterOp::In
95            | FilterOp::NotIn
96            | FilterOp::IsNull
97            | FilterOp::IsNotNull
98            | FilterOp::ArrayContains
99            | FilterOp::ArrayContainsAll
100            | FilterOp::ArrayOverlap
101            | FilterOp::GtColumn
102            | FilterOp::GteColumn
103            | FilterOp::LtColumn
104            | FilterOp::LteColumn
105            | FilterOp::EqColumn
106            | FilterOp::NeColumn => {}
107        }
108
109        let (start, end) = match extract_field(doc, 0, &self.field) {
110            Some(r) => r,
111            None => {
112                // Qualified-name fallback: "amount" might be stored as "orders.amount".
113                let suffix = format!(".{}", self.field);
114                match find_field_by_suffix(doc, &suffix) {
115                    Some(r) => r,
116                    None => return Ok(self.op == FilterOp::IsNull),
117                }
118            }
119        };
120
121        Ok(eval_op(self, doc, start, end))
122    }
123
124    /// Evaluate using a pre-built `FieldIndex` for O(1) field lookup.
125    ///
126    /// Use when evaluating multiple predicates on the same document.
127    pub fn matches_binary_indexed(&self, doc: &[u8], idx: &FieldIndex) -> Result<bool, EvalError> {
128        match self.op {
129            FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return Ok(true),
130            FilterOp::Or => {
131                for clause in &self.clauses {
132                    if Self::all_match_binary_indexed(clause, doc, idx)? {
133                        return Ok(true);
134                    }
135                }
136                return Ok(false);
137            }
138            FilterOp::Expr => {
139                return match (self.expr.as_ref(), nodedb_types::value_from_msgpack(doc)) {
140                    (Some(expr), Ok(value)) => Ok(crate::value_ops::is_truthy(&expr.eval(&value)?)),
141                    _ => Ok(false),
142                };
143            }
144            FilterOp::Eq
145            | FilterOp::Ne
146            | FilterOp::Gt
147            | FilterOp::Gte
148            | FilterOp::Lt
149            | FilterOp::Lte
150            | FilterOp::Contains
151            | FilterOp::Like
152            | FilterOp::NotLike
153            | FilterOp::Ilike
154            | FilterOp::NotIlike
155            | FilterOp::In
156            | FilterOp::NotIn
157            | FilterOp::IsNull
158            | FilterOp::IsNotNull
159            | FilterOp::ArrayContains
160            | FilterOp::ArrayContainsAll
161            | FilterOp::ArrayOverlap
162            | FilterOp::GtColumn
163            | FilterOp::GteColumn
164            | FilterOp::LtColumn
165            | FilterOp::LteColumn
166            | FilterOp::EqColumn
167            | FilterOp::NeColumn => {}
168        }
169
170        // Same lookup order as `matches_binary`: the exact key, then a
171        // qualified key carrying the bare name. Absent, and so NULL, only
172        // when neither exists.
173        let (start, end) = match idx
174            .get(&self.field)
175            .or_else(|| find_field_by_suffix(doc, &format!(".{}", self.field)))
176        {
177            Some(r) => r,
178            None => return Ok(self.op == FilterOp::IsNull),
179        };
180
181        Ok(eval_op(self, doc, start, end))
182    }
183}
184
185/// Shared filter op evaluation — used by both `matches_binary` and `matches_binary_indexed`.
186fn eval_op(filter: &ScanFilter, doc: &[u8], start: usize, _end: usize) -> bool {
187    match filter.op {
188        FilterOp::IsNull => read_null(doc, start),
189        FilterOp::IsNotNull => !read_null(doc, start),
190        FilterOp::Eq => eq_value(doc, start, &filter.value),
191        FilterOp::Ne => !eq_value(doc, start, &filter.value),
192        FilterOp::Gt => cmp_value(doc, start, &filter.value) == Some(Ordering::Greater),
193        FilterOp::Gte => matches!(
194            cmp_value(doc, start, &filter.value),
195            Some(Ordering::Greater | Ordering::Equal)
196        ),
197        FilterOp::Lt => cmp_value(doc, start, &filter.value) == Some(Ordering::Less),
198        FilterOp::Lte => matches!(
199            cmp_value(doc, start, &filter.value),
200            Some(Ordering::Less | Ordering::Equal)
201        ),
202        FilterOp::Contains => {
203            if let (Some(s), Some(pattern)) = (read_str(doc, start), filter.value.as_str()) {
204                s.contains(pattern)
205            } else {
206                false
207            }
208        }
209        FilterOp::Like => str_match(doc, start, &filter.value, false, false),
210        FilterOp::NotLike => str_match(doc, start, &filter.value, false, true),
211        FilterOp::Ilike => str_match(doc, start, &filter.value, true, false),
212        FilterOp::NotIlike => str_match(doc, start, &filter.value, true, true),
213        FilterOp::In => {
214            if let Some(mut iter) = filter.value.as_array_iter() {
215                iter.any(|v| eq_value(doc, start, v))
216            } else {
217                false
218            }
219        }
220        FilterOp::NotIn => {
221            if let Some(mut iter) = filter.value.as_array_iter() {
222                !iter.any(|v| eq_value(doc, start, v))
223            } else {
224                true
225            }
226        }
227        FilterOp::ArrayContains => array_any(doc, start, |elem_start| {
228            eq_value(doc, elem_start, &filter.value)
229        }),
230        FilterOp::ArrayContainsAll => {
231            if let Some(mut needles) = filter.value.as_array_iter() {
232                needles.all(|needle| {
233                    array_any(doc, start, |elem_start| eq_value(doc, elem_start, needle))
234                })
235            } else {
236                false
237            }
238        }
239        FilterOp::ArrayOverlap => {
240            if let Some(mut needles) = filter.value.as_array_iter() {
241                needles.any(|needle| {
242                    array_any(doc, start, |elem_start| eq_value(doc, elem_start, needle))
243                })
244            } else {
245                false
246            }
247        }
248        // Column-vs-column: extract right-side field from the same doc.
249        FilterOp::GtColumn
250        | FilterOp::GteColumn
251        | FilterOp::LtColumn
252        | FilterOp::LteColumn
253        | FilterOp::EqColumn
254        | FilterOp::NeColumn => {
255            let other_col = match &filter.value {
256                nodedb_types::Value::String(s) => s.as_str(),
257                _ => return false,
258            };
259            // Try exact field name, then qualified suffix match.
260            let other_range = extract_field(doc, 0, other_col).or_else(|| {
261                let suffix = format!(".{other_col}");
262                find_field_by_suffix(doc, &suffix)
263            });
264            let Some((other_start, _)) = other_range else {
265                return false;
266            };
267            // Read both sides as Value and compare.
268            let left = read_value(doc, start).unwrap_or(nodedb_types::Value::Null);
269            let right = read_value(doc, other_start).unwrap_or(nodedb_types::Value::Null);
270            column_compare(filter.op, &left, &right)
271        }
272        // Handled before field extraction by both `matches_binary` paths.
273        FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => true,
274        FilterOp::Or | FilterOp::Expr => false,
275    }
276}
277
278/// Find a field in a msgpack map by suffix match (e.g., ".amount" matches "orders.amount").
279fn find_field_by_suffix(doc: &[u8], suffix: &str) -> Option<(usize, usize)> {
280    let (count, mut pos) = map_header(doc, 0)?;
281    for _ in 0..count {
282        let key = read_str(doc, pos);
283        let key_end = skip_value(doc, pos)?;
284        let val_start = key_end;
285        let val_end = skip_value(doc, val_start)?;
286        if let Some(k) = key
287            && k.ends_with(suffix)
288        {
289            return Some((val_start, val_end));
290        }
291        pos = val_end;
292    }
293    None
294}
295
296// ── Helpers ────────────────────────────────────────────────────────────
297
298/// Coerced equality: read msgpack value at offset → compare with `Value`.
299/// Uses `Value::eq_coerced` — single source of truth for type coercion.
300#[inline]
301fn eq_value(buf: &[u8], offset: usize, filter_val: &nodedb_types::Value) -> bool {
302    if read_null(buf, offset) {
303        return filter_val.is_null();
304    }
305    match read_value(buf, offset) {
306        Some(field_val) => filter_val.eq_coerced(&field_val),
307        None => false,
308    }
309}
310
311/// Coerced ordering: read msgpack value at offset → compare with `Value`.
312/// Uses `Value::partial_cmp_coerced` — single source of truth for ordering.
313///
314/// Returns ordering of field_val relative to filter_val (field <=> filter),
315/// or `None` when the cell cannot be read or the pair has no defined order,
316/// so a range predicate over it matches nothing.
317#[inline]
318fn cmp_value(buf: &[u8], offset: usize, filter_val: &nodedb_types::Value) -> Option<Ordering> {
319    read_value(buf, offset)?.partial_cmp_coerced(filter_val)
320}
321
322/// LIKE/ILIKE/NOT LIKE/NOT ILIKE helper.
323#[inline]
324fn str_match(
325    buf: &[u8],
326    offset: usize,
327    pattern_val: &nodedb_types::Value,
328    icase: bool,
329    negate: bool,
330) -> bool {
331    let result = if let (Some(s), Some(pattern)) = (read_str(buf, offset), pattern_val.as_str()) {
332        sql_like_match(s, pattern, icase)
333    } else {
334        false
335    };
336    if negate { !result } else { result }
337}
338
339/// Iterate msgpack array elements, return true if any satisfies predicate.
340fn array_any(buf: &[u8], start: usize, mut pred: impl FnMut(usize) -> bool) -> bool {
341    let Some((count, mut pos)) = array_header(buf, start) else {
342        return false;
343    };
344    for _ in 0..count {
345        if pred(pos) {
346            return true;
347        }
348        let Some(next) = skip_value(buf, pos) else {
349            return false;
350        };
351        pos = next;
352    }
353    false
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use serde_json::json;
360
361    fn encode(v: &serde_json::Value) -> Vec<u8> {
362        nodedb_types::json_msgpack::json_to_msgpack(v).expect("encode")
363    }
364
365    fn filter(field: &str, op: &str, value: nodedb_types::Value) -> ScanFilter {
366        ScanFilter {
367            field: field.into(),
368            op: FilterOp::parse(op).expect(op),
369            value,
370            clauses: vec![],
371            expr: None,
372        }
373    }
374
375    #[test]
376    fn eq_integer() {
377        let doc = encode(&json!({"age": 25}));
378        assert!(
379            filter("age", "eq", nodedb_types::Value::Integer(25))
380                .matches_binary(&doc)
381                .unwrap()
382        );
383        assert!(
384            !filter("age", "eq", nodedb_types::Value::Integer(30))
385                .matches_binary(&doc)
386                .unwrap()
387        );
388    }
389
390    /// An instant cell compares by epoch microseconds against a typed
391    /// instant literal and against an ISO-8601 string literal.
392    #[test]
393    fn instant_cell_compares_by_micros() {
394        use nodedb_types::{InstantKind, NdbDateTime, Value};
395        const MICROS: i64 = 1_583_402_400_000_000;
396        let mut doc = Vec::new();
397        crate::msgpack_scan::write_map_header(&mut doc, 1);
398        crate::msgpack_scan::write_kv_instant(&mut doc, "ts", InstantKind::Naive, MICROS);
399
400        let same = Value::NaiveDateTime(NdbDateTime::from_micros(MICROS));
401        let later = Value::NaiveDateTime(NdbDateTime::from_micros(MICROS + 1));
402        assert!(
403            filter("ts", "eq", same.clone())
404                .matches_binary(&doc)
405                .unwrap()
406        );
407        assert!(
408            !filter("ts", "eq", later.clone())
409                .matches_binary(&doc)
410                .unwrap()
411        );
412        assert!(filter("ts", "lt", later).matches_binary(&doc).unwrap());
413        assert!(filter("ts", "gte", same).matches_binary(&doc).unwrap());
414        assert!(
415            filter("ts", "eq", Value::String("2020-03-05T10:00:00Z".into()))
416                .matches_binary(&doc)
417                .unwrap()
418        );
419        assert!(
420            filter("ts", "gt", Value::String("2020-03-05 09:00:00".into()))
421                .matches_binary(&doc)
422                .unwrap()
423        );
424        let idx = FieldIndex::build(&doc, 0).unwrap_or_else(FieldIndex::empty);
425        assert!(
426            filter(
427                "ts",
428                "eq",
429                Value::NaiveDateTime(NdbDateTime::from_micros(MICROS))
430            )
431            .matches_binary_indexed(&doc, &idx)
432            .unwrap()
433        );
434    }
435
436    #[test]
437    fn eq_coerces_string_to_integer() {
438        let doc = encode(&json!({"age": 25}));
439        assert!(
440            filter("age", "eq", nodedb_types::Value::String("25".into()))
441                .matches_binary(&doc)
442                .unwrap()
443        );
444    }
445
446    #[test]
447    fn gt_coerces_string_to_integer() {
448        let doc = encode(&json!({"score": "90"}));
449        assert!(
450            filter("score", "gt", nodedb_types::Value::Integer(80))
451                .matches_binary(&doc)
452                .unwrap()
453        );
454    }
455
456    #[test]
457    fn eq_string() {
458        let doc = encode(&json!({"name": "alice"}));
459        assert!(
460            filter("name", "eq", nodedb_types::Value::String("alice".into()))
461                .matches_binary(&doc)
462                .unwrap()
463        );
464    }
465
466    #[test]
467    fn eq_coercion_int_vs_string() {
468        let doc = encode(&json!({"age": 25}));
469        assert!(
470            filter("age", "eq", nodedb_types::Value::String("25".into()))
471                .matches_binary(&doc)
472                .unwrap()
473        );
474    }
475
476    #[test]
477    fn eq_coercion_string_vs_int() {
478        let doc = encode(&json!({"score": "90"}));
479        assert!(
480            filter("score", "eq", nodedb_types::Value::Integer(90))
481                .matches_binary(&doc)
482                .unwrap()
483        );
484    }
485
486    #[test]
487    fn ne() {
488        let doc = encode(&json!({"x": 1}));
489        assert!(
490            filter("x", "ne", nodedb_types::Value::Integer(2))
491                .matches_binary(&doc)
492                .unwrap()
493        );
494        assert!(
495            !filter("x", "ne", nodedb_types::Value::Integer(1))
496                .matches_binary(&doc)
497                .unwrap()
498        );
499    }
500
501    #[test]
502    fn gt_lt() {
503        let doc = encode(&json!({"v": 10}));
504        assert!(
505            filter("v", "gt", nodedb_types::Value::Integer(5))
506                .matches_binary(&doc)
507                .unwrap()
508        );
509        assert!(
510            !filter("v", "gt", nodedb_types::Value::Integer(15))
511                .matches_binary(&doc)
512                .unwrap()
513        );
514        assert!(
515            filter("v", "lt", nodedb_types::Value::Integer(15))
516                .matches_binary(&doc)
517                .unwrap()
518        );
519        assert!(
520            !filter("v", "lt", nodedb_types::Value::Integer(5))
521                .matches_binary(&doc)
522                .unwrap()
523        );
524    }
525
526    #[test]
527    fn gte_lte() {
528        let doc = encode(&json!({"v": 10}));
529        assert!(
530            filter("v", "gte", nodedb_types::Value::Integer(10))
531                .matches_binary(&doc)
532                .unwrap()
533        );
534        assert!(
535            filter("v", "gte", nodedb_types::Value::Integer(5))
536                .matches_binary(&doc)
537                .unwrap()
538        );
539        assert!(
540            !filter("v", "gte", nodedb_types::Value::Integer(15))
541                .matches_binary(&doc)
542                .unwrap()
543        );
544        assert!(
545            filter("v", "lte", nodedb_types::Value::Integer(10))
546                .matches_binary(&doc)
547                .unwrap()
548        );
549    }
550
551    #[test]
552    fn is_null_not_null() {
553        let doc = encode(&json!({"a": null, "b": 1}));
554        assert!(
555            filter("a", "is_null", nodedb_types::Value::Null)
556                .matches_binary(&doc)
557                .unwrap()
558        );
559        assert!(
560            !filter("b", "is_null", nodedb_types::Value::Null)
561                .matches_binary(&doc)
562                .unwrap()
563        );
564        assert!(
565            filter("b", "is_not_null", nodedb_types::Value::Null)
566                .matches_binary(&doc)
567                .unwrap()
568        );
569    }
570
571    #[test]
572    fn missing_field_is_null() {
573        let doc = encode(&json!({"x": 1}));
574        assert!(
575            filter("missing", "is_null", nodedb_types::Value::Null)
576                .matches_binary(&doc)
577                .unwrap()
578        );
579    }
580
581    #[test]
582    fn contains_str() {
583        let doc = encode(&json!({"msg": "hello world"}));
584        assert!(
585            filter(
586                "msg",
587                "contains",
588                nodedb_types::Value::String("world".into())
589            )
590            .matches_binary(&doc)
591            .unwrap()
592        );
593    }
594
595    #[test]
596    fn like_ilike() {
597        let doc = encode(&json!({"name": "Alice"}));
598        assert!(
599            filter("name", "like", nodedb_types::Value::String("Ali%".into()))
600                .matches_binary(&doc)
601                .unwrap()
602        );
603        assert!(
604            !filter("name", "like", nodedb_types::Value::String("ali%".into()))
605                .matches_binary(&doc)
606                .unwrap()
607        );
608        assert!(
609            filter("name", "ilike", nodedb_types::Value::String("ali%".into()))
610                .matches_binary(&doc)
611                .unwrap()
612        );
613        assert!(
614            filter(
615                "name",
616                "not_like",
617                nodedb_types::Value::String("Bob%".into())
618            )
619            .matches_binary(&doc)
620            .unwrap()
621        );
622    }
623
624    #[test]
625    fn in_not_in() {
626        let doc = encode(&json!({"status": "active"}));
627        let vals = nodedb_types::Value::Array(vec![
628            nodedb_types::Value::String("active".into()),
629            nodedb_types::Value::String("pending".into()),
630        ]);
631        assert!(
632            ScanFilter {
633                field: "status".into(),
634                op: FilterOp::In,
635                value: vals.clone(),
636                clauses: vec![],
637                expr: None
638            }
639            .matches_binary(&doc)
640            .unwrap()
641        );
642
643        let doc2 = encode(&json!({"status": "deleted"}));
644        assert!(
645            ScanFilter {
646                field: "status".into(),
647                op: FilterOp::NotIn,
648                value: vals,
649                clauses: vec![],
650                expr: None
651            }
652            .matches_binary(&doc2)
653            .unwrap()
654        );
655    }
656
657    #[test]
658    fn array_contains() {
659        let doc = encode(&json!({"tags": ["rust", "db", "fast"]}));
660        assert!(
661            filter(
662                "tags",
663                "array_contains",
664                nodedb_types::Value::String("rust".into())
665            )
666            .matches_binary(&doc)
667            .unwrap()
668        );
669        assert!(
670            !filter(
671                "tags",
672                "array_contains",
673                nodedb_types::Value::String("slow".into())
674            )
675            .matches_binary(&doc)
676            .unwrap()
677        );
678    }
679
680    #[test]
681    fn array_contains_all() {
682        let doc = encode(&json!({"tags": ["a", "b", "c"]}));
683        let needles = nodedb_types::Value::Array(vec![
684            nodedb_types::Value::String("a".into()),
685            nodedb_types::Value::String("c".into()),
686        ]);
687        assert!(
688            ScanFilter {
689                field: "tags".into(),
690                op: FilterOp::ArrayContainsAll,
691                value: needles,
692                clauses: vec![],
693                expr: None
694            }
695            .matches_binary(&doc)
696            .unwrap()
697        );
698    }
699
700    #[test]
701    fn array_overlap() {
702        let doc = encode(&json!({"tags": ["x", "y"]}));
703        let needles = nodedb_types::Value::Array(vec![
704            nodedb_types::Value::String("y".into()),
705            nodedb_types::Value::String("z".into()),
706        ]);
707        assert!(
708            ScanFilter {
709                field: "tags".into(),
710                op: FilterOp::ArrayOverlap,
711                value: needles,
712                clauses: vec![],
713                expr: None
714            }
715            .matches_binary(&doc)
716            .unwrap()
717        );
718    }
719
720    #[test]
721    fn or_clauses() {
722        let doc = encode(&json!({"x": 5}));
723        let f = ScanFilter {
724            field: String::new(),
725            op: FilterOp::Or,
726            value: nodedb_types::Value::Null,
727            clauses: vec![
728                vec![filter("x", "eq", nodedb_types::Value::Integer(10))],
729                vec![filter("x", "eq", nodedb_types::Value::Integer(5))],
730            ],
731            expr: None,
732        };
733        assert!(f.matches_binary(&doc).unwrap());
734    }
735
736    #[test]
737    fn match_all() {
738        let doc = encode(&json!({"any": "thing"}));
739        assert!(
740            filter("", "match_all", nodedb_types::Value::Null)
741                .matches_binary(&doc)
742                .unwrap()
743        );
744    }
745
746    #[test]
747    fn float_comparison() {
748        let doc = encode(&json!({"temp": 36.6}));
749        assert!(
750            filter("temp", "gt", nodedb_types::Value::Float(30.0))
751                .matches_binary(&doc)
752                .unwrap()
753        );
754        assert!(
755            filter("temp", "lt", nodedb_types::Value::Float(40.0))
756                .matches_binary(&doc)
757                .unwrap()
758        );
759    }
760
761    #[test]
762    fn bool_eq() {
763        let doc = encode(&json!({"active": true}));
764        assert!(
765            filter("active", "eq", nodedb_types::Value::Bool(true))
766                .matches_binary(&doc)
767                .unwrap()
768        );
769        assert!(
770            !filter("active", "eq", nodedb_types::Value::Bool(false))
771                .matches_binary(&doc)
772                .unwrap()
773        );
774    }
775
776    #[test]
777    fn gt_coercion_string_field() {
778        let doc = encode(&json!({"score": "90"}));
779        assert!(
780            filter("score", "gt", nodedb_types::Value::Integer(80))
781                .matches_binary(&doc)
782                .unwrap()
783        );
784    }
785
786    #[test]
787    fn timestamp_upper_bound_matches() {
788        let doc = encode(&json!({"t": "2026-07-02T13:00:00.000000Z"}));
789        assert!(
790            filter(
791                "t",
792                "lte",
793                nodedb_types::Value::String("2026-07-02 14:00:00".into())
794            )
795            .matches_binary(&doc)
796            .unwrap()
797        );
798        assert!(
799            filter(
800                "t",
801                "lt",
802                nodedb_types::Value::String("2026-07-02 14:00:00".into())
803            )
804            .matches_binary(&doc)
805            .unwrap()
806        );
807        assert!(
808            filter(
809                "t",
810                "gte",
811                nodedb_types::Value::String("2026-07-02 12:00:00".into())
812            )
813            .matches_binary(&doc)
814            .unwrap()
815        );
816        assert!(
817            filter(
818                "t",
819                "eq",
820                nodedb_types::Value::String("2026-07-02 13:00:00".into())
821            )
822            .matches_binary(&doc)
823            .unwrap()
824        );
825    }
826
827    // ── Indexed variant tests ──────────────────────────────────────────
828
829    #[test]
830    fn indexed_matches_same_as_sequential() {
831        let doc = encode(&json!({"a": 1, "b": "hello", "c": true, "d": null}));
832        let idx = FieldIndex::build(&doc, 0).unwrap();
833
834        let filters = vec![
835            filter("a", "eq", nodedb_types::Value::Integer(1)),
836            filter("b", "contains", nodedb_types::Value::String("ell".into())),
837            filter("c", "eq", nodedb_types::Value::Bool(true)),
838            filter("d", "is_null", nodedb_types::Value::Null),
839            filter("missing", "is_null", nodedb_types::Value::Null),
840        ];
841
842        for f in &filters {
843            assert_eq!(
844                f.matches_binary(&doc).unwrap(),
845                f.matches_binary_indexed(&doc, &idx).unwrap(),
846                "mismatch for field={} op={:?}",
847                f.field,
848                f.op
849            );
850        }
851    }
852}