Skip to main content

hawdb_optimizer_predicate/
predicate.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use hawdb_cascades::{RuleEvent, RuleOutcome};
16use std::collections::{BTreeMap, BTreeSet};
17use std::fmt;
18
19mod construction;
20pub use construction::SearchPredicateConstructionError;
21
22const IN_SUFFIX: &str = "__in";
23const NOT_IN_SUFFIX: &str = "__not_in";
24const GT_SUFFIX: &str = "__gt";
25const GTE_SUFFIX: &str = "__gte";
26const LT_SUFFIX: &str = "__lt";
27const LTE_SUFFIX: &str = "__lte";
28const EXISTS_SUFFIX: &str = "__exists";
29const MISSING_SUFFIX: &str = "__missing";
30
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub struct SearchFieldRef {
33    name: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum SearchScalarValue {
38    String(String),
39    Enum(String),
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum SearchPredicateOp {
44    Eq(SearchScalarValue),
45    In(BTreeSet<SearchScalarValue>),
46    NotIn(BTreeSet<SearchScalarValue>),
47    Gt(SearchScalarValue),
48    Gte(SearchScalarValue),
49    Lt(SearchScalarValue),
50    Lte(SearchScalarValue),
51    Exists,
52    IsMissing,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SearchPredicate {
57    field: SearchFieldRef,
58    op: SearchPredicateOp,
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct SearchPredicateSet {
63    predicates: Vec<SearchPredicate>,
64    unsatisfiable: bool,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct SearchScanPredicateSupport {
69    pub equality: bool,
70    pub in_list: bool,
71    pub not_in_list: bool,
72    pub range: bool,
73    pub presence: bool,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct SearchPredicatePushdown {
78    pushed: SearchPredicateSet,
79    residual: SearchPredicateSet,
80    events: Vec<RuleEvent>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SearchPredicateParseError {
85    filter_key: String,
86    reason: &'static str,
87}
88
89impl SearchFieldRef {
90    pub fn new(name: impl Into<String>) -> Self {
91        Self { name: name.into() }
92    }
93
94    pub fn name(&self) -> &str {
95        &self.name
96    }
97}
98
99impl SearchScalarValue {
100    pub fn string(value: impl Into<String>) -> Self {
101        Self::String(value.into())
102    }
103
104    pub fn enumeration(value: impl Into<String>) -> Self {
105        Self::Enum(value.into())
106    }
107
108    pub fn as_str(&self) -> &str {
109        match self {
110            Self::String(value) | Self::Enum(value) => value,
111        }
112    }
113
114    pub fn kind(&self) -> &'static str {
115        match self {
116            Self::String(_) => "string",
117            Self::Enum(_) => "enum",
118        }
119    }
120}
121
122impl SearchPredicate {
123    pub fn eq(field: impl Into<String>, value: impl Into<String>) -> Self {
124        let field = field.into();
125        let op = SearchPredicateOp::Eq(search_scalar_value_for_field(&field, value.into()));
126        Self {
127            field: SearchFieldRef::new(field),
128            op,
129        }
130    }
131
132    pub fn in_list(field: impl Into<String>, values: impl IntoIterator<Item = String>) -> Self {
133        let field = field.into();
134        let values = search_scalar_set_for_field(&field, values);
135        Self {
136            field: SearchFieldRef::new(field),
137            op: SearchPredicateOp::In(values),
138        }
139    }
140
141    pub fn not_in_list(field: impl Into<String>, values: impl IntoIterator<Item = String>) -> Self {
142        let field = field.into();
143        let values = search_scalar_set_for_field(&field, values);
144        Self {
145            field: SearchFieldRef::new(field),
146            op: SearchPredicateOp::NotIn(values),
147        }
148    }
149
150    pub fn gt(field: impl Into<String>, value: impl Into<String>) -> Self {
151        Self {
152            field: SearchFieldRef::new(field),
153            op: SearchPredicateOp::Gt(SearchScalarValue::string(value)),
154        }
155    }
156
157    pub fn gte(field: impl Into<String>, value: impl Into<String>) -> Self {
158        Self {
159            field: SearchFieldRef::new(field),
160            op: SearchPredicateOp::Gte(SearchScalarValue::string(value)),
161        }
162    }
163
164    pub fn lt(field: impl Into<String>, value: impl Into<String>) -> Self {
165        Self {
166            field: SearchFieldRef::new(field),
167            op: SearchPredicateOp::Lt(SearchScalarValue::string(value)),
168        }
169    }
170
171    pub fn lte(field: impl Into<String>, value: impl Into<String>) -> Self {
172        Self {
173            field: SearchFieldRef::new(field),
174            op: SearchPredicateOp::Lte(SearchScalarValue::string(value)),
175        }
176    }
177
178    pub fn exists(field: impl Into<String>) -> Self {
179        Self {
180            field: SearchFieldRef::new(field),
181            op: SearchPredicateOp::Exists,
182        }
183    }
184
185    pub fn is_missing(field: impl Into<String>) -> Self {
186        Self {
187            field: SearchFieldRef::new(field),
188            op: SearchPredicateOp::IsMissing,
189        }
190    }
191
192    pub fn field(&self) -> &SearchFieldRef {
193        &self.field
194    }
195
196    pub fn op(&self) -> &SearchPredicateOp {
197        &self.op
198    }
199
200    fn pushdown_supported(&self, support: SearchScanPredicateSupport) -> bool {
201        match self.op {
202            SearchPredicateOp::Eq(_) => support.equality,
203            SearchPredicateOp::In(_) => support.in_list,
204            SearchPredicateOp::NotIn(_) => support.not_in_list,
205            SearchPredicateOp::Gt(_)
206            | SearchPredicateOp::Gte(_)
207            | SearchPredicateOp::Lt(_)
208            | SearchPredicateOp::Lte(_) => support.range,
209            SearchPredicateOp::Exists | SearchPredicateOp::IsMissing => support.presence,
210        }
211    }
212}
213
214fn search_scalar_value_for_field(field: &str, mut value: String) -> SearchScalarValue {
215    if search_field_is_enum_like(field) {
216        // Preserve the original allocation, including Unicode trim boundaries.
217        let leading = value.len() - value.trim_start().len();
218        let end = value.trim_end().len().max(leading);
219        value.truncate(end);
220        value.drain(..leading);
221        value.make_ascii_lowercase();
222        SearchScalarValue::enumeration(value)
223    } else {
224        SearchScalarValue::string(value)
225    }
226}
227
228fn search_scalar_set_for_field(
229    field: &str,
230    values: impl IntoIterator<Item = String>,
231) -> BTreeSet<SearchScalarValue> {
232    // Insert directly; BTreeSet::from_iter also builds a temporary sorting Vec.
233    let mut output = BTreeSet::new();
234    for value in values {
235        output.insert(search_scalar_value_for_field(field, value));
236    }
237    output
238}
239
240pub fn search_field_is_enum_like(field: &str) -> bool {
241    matches!(
242        field,
243        "kind" | "labels" | "unit_type" | "lifecycle_state" | "review_status" | "temporal_context"
244    )
245}
246
247pub fn normalize_search_enum_value(value: &str) -> String {
248    value.trim().to_ascii_lowercase()
249}
250
251impl SearchPredicateSet {
252    pub fn empty() -> Self {
253        Self::default()
254    }
255
256    pub fn unsatisfiable() -> Self {
257        Self {
258            predicates: Vec::new(),
259            unsatisfiable: true,
260        }
261    }
262
263    pub fn new(predicates: Vec<SearchPredicate>) -> Self {
264        Self {
265            predicates,
266            unsatisfiable: false,
267        }
268    }
269
270    pub fn from_metadata_filters(
271        filters: &BTreeMap<String, String>,
272    ) -> Result<Self, SearchPredicateParseError> {
273        use std::convert::Infallible;
274        match Self::from_metadata_filters_with_admission(filters, |_| Ok::<_, Infallible>(())) {
275            Ok(predicates) => Ok(predicates),
276            Err(SearchPredicateConstructionError::Parse(error)) => Err(error),
277            Err(SearchPredicateConstructionError::Admission(never)) => match never {},
278            Err(SearchPredicateConstructionError::SizeOverflow) => Err(SearchPredicateParseError {
279                filter_key: String::new(),
280                reason: "metadata predicate construction size overflow",
281            }),
282        }
283    }
284
285    /// Admit construction capacity before allocation. The caller must retain
286    /// every admitted byte until the returned predicate set and parse error are
287    /// destroyed. This includes conservative transient parser capacity; it is
288    /// not an allocator or RSS measurement. Admission errors fail the query,
289    /// whereas parse errors retain the usual unsatisfiable-filter semantics.
290    #[doc(hidden)]
291    pub fn from_metadata_filters_with_admission<E>(
292        filters: &BTreeMap<String, String>,
293        mut admit: impl FnMut(usize) -> Result<(), E>,
294    ) -> Result<Self, SearchPredicateConstructionError<E>> {
295        let slots = filters
296            .len()
297            .checked_mul(std::mem::size_of::<SearchPredicate>())
298            .ok_or(SearchPredicateConstructionError::SizeOverflow)?;
299        admit(slots).map_err(SearchPredicateConstructionError::Admission)?;
300        let mut predicates = Vec::with_capacity(filters.len());
301        for (key, value) in filters {
302            let bytes = construction::filter_capacity(key, value)
303                .ok_or(SearchPredicateConstructionError::SizeOverflow)?;
304            admit(bytes).map_err(SearchPredicateConstructionError::Admission)?;
305            if let Some(predicate) = mem_search_filter_alias_predicate(key, value)? {
306                predicates.push(predicate);
307                continue;
308            }
309            if let Some(field) = key.strip_suffix(IN_SUFFIX) {
310                let values = parse_string_list_filter(key, value)?;
311                let field = canonical_search_filter_field(field);
312                let values = normalize_search_filter_values(key, field, values)?;
313                if values.is_empty() {
314                    return Ok(Self::unsatisfiable());
315                }
316                predicates.push(SearchPredicate::in_list(field, values));
317            } else if let Some(field) = key.strip_suffix(NOT_IN_SUFFIX) {
318                let values = parse_string_list_filter(key, value)?;
319                let field = canonical_search_filter_field(field);
320                let values = normalize_search_filter_values(key, field, values)?;
321                if !values.is_empty() {
322                    predicates.push(SearchPredicate::not_in_list(field, values));
323                }
324            } else if let Some(field) = key.strip_suffix(EXISTS_SUFFIX) {
325                let exists = parse_bool_filter_value(key, value)?;
326                let field = canonical_search_filter_field(field);
327                predicates.push(if exists {
328                    SearchPredicate::exists(field)
329                } else {
330                    SearchPredicate::is_missing(field)
331                });
332            } else if let Some(field) = key.strip_suffix(MISSING_SUFFIX) {
333                let missing = parse_bool_filter_value(key, value)?;
334                let field = canonical_search_filter_field(field);
335                predicates.push(if missing {
336                    SearchPredicate::is_missing(field)
337                } else {
338                    SearchPredicate::exists(field)
339                });
340            } else if let Some(field) = key.strip_suffix(GTE_SUFFIX) {
341                reject_boolean_alias_range_filter(key, field)?;
342                predicates.push(SearchPredicate::gte(field, value));
343            } else if let Some(field) = key.strip_suffix(GT_SUFFIX) {
344                reject_boolean_alias_range_filter(key, field)?;
345                predicates.push(SearchPredicate::gt(field, value));
346            } else if let Some(field) = key.strip_suffix(LTE_SUFFIX) {
347                reject_boolean_alias_range_filter(key, field)?;
348                predicates.push(SearchPredicate::lte(field, value));
349            } else if let Some(field) = key.strip_suffix(LT_SUFFIX) {
350                reject_boolean_alias_range_filter(key, field)?;
351                predicates.push(SearchPredicate::lt(field, value));
352            } else {
353                let field = canonical_search_filter_field(key);
354                let value = normalize_search_filter_value(key, field, value.clone())?;
355                predicates.push(SearchPredicate::eq(field, value));
356            }
357        }
358        Ok(Self::new(predicates))
359    }
360
361    pub fn predicates(&self) -> &[SearchPredicate] {
362        &self.predicates
363    }
364
365    pub fn is_empty(&self) -> bool {
366        self.predicates.is_empty() && !self.unsatisfiable
367    }
368
369    pub fn is_unsatisfiable(&self) -> bool {
370        self.unsatisfiable
371    }
372}
373
374impl Default for SearchScanPredicateSupport {
375    fn default() -> Self {
376        Self {
377            equality: true,
378            in_list: true,
379            not_in_list: true,
380            range: true,
381            presence: true,
382        }
383    }
384}
385
386impl SearchPredicatePushdown {
387    pub fn pushed(&self) -> &SearchPredicateSet {
388        &self.pushed
389    }
390
391    pub fn residual(&self) -> &SearchPredicateSet {
392        &self.residual
393    }
394
395    pub fn events(&self) -> &[RuleEvent] {
396        &self.events
397    }
398}
399
400impl SearchPredicateParseError {
401    pub fn filter_key(&self) -> &str {
402        &self.filter_key
403    }
404
405    pub fn reason(&self) -> &'static str {
406        self.reason
407    }
408}
409
410impl fmt::Display for SearchPredicateParseError {
411    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
412        write!(formatter, "{}: {}", self.filter_key, self.reason)
413    }
414}
415
416impl std::error::Error for SearchPredicateParseError {}
417
418pub fn push_search_predicates(
419    predicates: &SearchPredicateSet,
420    support: SearchScanPredicateSupport,
421) -> SearchPredicatePushdown {
422    if predicates.is_unsatisfiable() {
423        return SearchPredicatePushdown {
424            pushed: SearchPredicateSet::unsatisfiable(),
425            residual: SearchPredicateSet::empty(),
426            events: vec![RuleEvent::new(
427                "transformation:push_search_predicates",
428                RuleOutcome::Applied,
429                "input predicate set is unsatisfiable",
430            )],
431        };
432    }
433
434    let mut pushed = Vec::new();
435    let mut residual = Vec::new();
436    for predicate in predicates.predicates() {
437        if predicate.pushdown_supported(support) {
438            pushed.push(predicate.clone());
439        } else {
440            residual.push(predicate.clone());
441        }
442    }
443
444    let pushed_count = pushed.len();
445    let residual_count = residual.len();
446    let outcome = if pushed_count == 0 {
447        RuleOutcome::Skipped
448    } else {
449        RuleOutcome::Applied
450    };
451    SearchPredicatePushdown {
452        pushed: SearchPredicateSet::new(pushed),
453        residual: SearchPredicateSet::new(residual),
454        events: vec![RuleEvent::new(
455            "transformation:push_search_predicates",
456            outcome,
457            format!("pushed={pushed_count} residual={residual_count}"),
458        )],
459    }
460}
461
462fn parse_string_list_filter(
463    filter_key: &str,
464    value: &str,
465) -> Result<Vec<String>, SearchPredicateParseError> {
466    // Reject other top-level types before Serde can format an arbitrarily long
467    // unexpected string into its own diagnostic allocation.
468    if !value
469        .trim_start_matches([' ', '\t', '\r', '\n'])
470        .starts_with('[')
471    {
472        return Err(SearchPredicateParseError {
473            filter_key: filter_key.to_string(),
474            reason: "expected JSON string array",
475        });
476    }
477    serde_json::from_str::<Vec<String>>(value).map_err(|_| SearchPredicateParseError {
478        filter_key: filter_key.to_string(),
479        reason: "expected JSON string array",
480    })
481}
482
483fn mem_search_filter_alias_predicate(
484    key: &str,
485    value: &str,
486) -> Result<Option<SearchPredicate>, SearchPredicateParseError> {
487    let predicate = match key {
488        "event_date_from" | "event_date__gte" => SearchPredicate::gte("event_end", value),
489        "event_date_after" | "event_date__gt" => SearchPredicate::gt("event_end", value),
490        "event_date_to" | "event_date__lte" => SearchPredicate::lte("event_start", value),
491        "event_date_before" | "event_date__lt" => SearchPredicate::lt("event_start", value),
492        "recorded_date_from" | "recorded_date__gte" => SearchPredicate::gte("created_at", value),
493        "recorded_date_after" | "recorded_date__gt" => SearchPredicate::gt("created_at", value),
494        "recorded_date_to" | "recorded_date__lte" => SearchPredicate::lte("created_at", value),
495        "recorded_date_before" | "recorded_date__lt" => SearchPredicate::lt("created_at", value),
496        "event_date__in" | "event_date__not_in" | "recorded_date__in" | "recorded_date__not_in" => {
497            return Err(SearchPredicateParseError {
498                filter_key: key.to_string(),
499                reason: "date alias filter only supports range predicates",
500            });
501        }
502        _ => return Ok(None),
503    };
504    Ok(Some(predicate))
505}
506
507fn canonical_search_filter_field(field: &str) -> &str {
508    match field {
509        "latest" | "history" => "is_latest",
510        "space" | "spaces" | "space_ids" => "space_id",
511        "temporal" | "temporal_contexts" => "temporal_context",
512        _ => field,
513    }
514}
515
516fn reject_boolean_alias_range_filter(
517    filter_key: &str,
518    field: &str,
519) -> Result<(), SearchPredicateParseError> {
520    if matches!(field, "history" | "latest" | "is_latest") {
521        return Err(SearchPredicateParseError {
522            filter_key: filter_key.to_string(),
523            reason: "boolean filter only supports equality and list predicates",
524        });
525    }
526    Ok(())
527}
528
529fn normalize_search_filter_values(
530    filter_key: &str,
531    field: &str,
532    mut values: Vec<String>,
533) -> Result<Vec<String>, SearchPredicateParseError> {
534    for value in &mut values {
535        *value = normalize_search_filter_value(filter_key, field, std::mem::take(value))?;
536    }
537    Ok(values)
538}
539
540fn normalize_search_filter_value(
541    filter_key: &str,
542    field: &str,
543    value: String,
544) -> Result<String, SearchPredicateParseError> {
545    if filter_key == "history"
546        || filter_key
547            .strip_suffix(IN_SUFFIX)
548            .is_some_and(|field| field == "history")
549        || filter_key
550            .strip_suffix(NOT_IN_SUFFIX)
551            .is_some_and(|field| field == "history")
552    {
553        return invert_history_filter_value(filter_key, &value);
554    }
555    if field == "is_latest" {
556        return normalize_bool_filter_value(filter_key, &value);
557    }
558    Ok(value)
559}
560
561fn invert_history_filter_value(
562    filter_key: &str,
563    value: &str,
564) -> Result<String, SearchPredicateParseError> {
565    let value = parse_bool_filter_value(filter_key, value)?;
566    Ok(if value { "false" } else { "true" }.to_owned())
567}
568
569fn normalize_bool_filter_value(
570    filter_key: &str,
571    value: &str,
572) -> Result<String, SearchPredicateParseError> {
573    parse_bool_filter_value(filter_key, value)
574        .map(|value| if value { "true" } else { "false" }.to_owned())
575}
576
577fn parse_bool_filter_value(
578    filter_key: &str,
579    value: &str,
580) -> Result<bool, SearchPredicateParseError> {
581    let value = value.trim();
582    if value.eq_ignore_ascii_case("true") || value == "1" {
583        Ok(true)
584    } else if value.eq_ignore_ascii_case("false") || value == "0" {
585        Ok(false)
586    } else {
587        Err(SearchPredicateParseError {
588            filter_key: filter_key.to_string(),
589            reason: "expected boolean filter value",
590        })
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::{
597        push_search_predicates, SearchPredicateOp, SearchPredicateSet, SearchScanPredicateSupport,
598    };
599    use std::collections::BTreeMap;
600
601    #[test]
602    fn metadata_filters_lower_to_typed_search_predicates() {
603        let filters = BTreeMap::from([
604            ("kind".to_string(), "Memory".to_string()),
605            (
606                "lifecycle_state__not_in".to_string(),
607                r#"["deleted","forgotten"]"#.to_string(),
608            ),
609            (
610                "unit_type__in".to_string(),
611                r#"["fact","learning"]"#.to_string(),
612            ),
613            ("created_at__gte".to_string(), "1710000000".to_string()),
614            ("updated_at__lt".to_string(), "1720000000".to_string()),
615        ]);
616
617        let predicates = SearchPredicateSet::from_metadata_filters(&filters).unwrap();
618
619        assert_eq!(predicates.predicates().len(), 5);
620        assert!(matches!(
621            predicates.predicates()[0].op(),
622            SearchPredicateOp::Gte(_)
623        ));
624        assert!(matches!(
625            predicates.predicates()[1].op(),
626            SearchPredicateOp::Eq(_)
627        ));
628        assert!(matches!(
629            predicates.predicates()[2].op(),
630            SearchPredicateOp::NotIn(values)
631                if values.len() == 2 && values.iter().all(|value| value.kind() == "enum")
632        ));
633        assert!(matches!(
634            predicates.predicates()[3].op(),
635            SearchPredicateOp::In(values)
636                if values.len() == 2 && values.iter().all(|value| value.kind() == "enum")
637        ));
638        assert!(matches!(
639            predicates.predicates()[4].op(),
640            SearchPredicateOp::Lt(_)
641        ));
642    }
643
644    #[test]
645    fn metadata_filters_canonicalize_latest_and_history_aliases() {
646        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
647            ("history".to_string(), "true".to_string()),
648            ("latest__in".to_string(), r#"["true","0"]"#.to_string()),
649        ]))
650        .unwrap();
651
652        assert_eq!(predicates.predicates().len(), 2);
653        assert_eq!(predicates.predicates()[0].field().name(), "is_latest");
654        assert!(matches!(
655            predicates.predicates()[0].op(),
656            SearchPredicateOp::Eq(value) if value.as_str() == "false"
657        ));
658        assert_eq!(predicates.predicates()[1].field().name(), "is_latest");
659        assert!(matches!(
660            predicates.predicates()[1].op(),
661            SearchPredicateOp::In(values)
662                if values.iter().map(|value| value.as_str()).collect::<Vec<_>>()
663                    == vec!["false", "true"]
664        ));
665    }
666
667    #[test]
668    fn metadata_filters_reject_boolean_alias_ranges() {
669        let error = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([(
670            "history__gte".to_string(),
671            "true".to_string(),
672        )]))
673        .unwrap_err();
674
675        assert_eq!(error.filter_key(), "history__gte");
676        assert_eq!(
677            error.reason(),
678            "boolean filter only supports equality and list predicates"
679        );
680    }
681
682    #[test]
683    fn metadata_filters_lower_mem_date_aliases_to_timestamp_ranges() {
684        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
685            (
686                "event_date_from".to_string(),
687                "2026-07-01T00:00:00Z".to_string(),
688            ),
689            (
690                "event_date_to".to_string(),
691                "2026-07-02T00:00:00Z".to_string(),
692            ),
693            ("recorded_date_from".to_string(), "2026-06-01".to_string()),
694            ("recorded_date_to".to_string(), "2026-06-30".to_string()),
695        ]))
696        .unwrap();
697
698        assert_eq!(predicates.predicates().len(), 4);
699        assert_eq!(predicates.predicates()[0].field().name(), "event_end");
700        assert!(matches!(
701            predicates.predicates()[0].op(),
702            SearchPredicateOp::Gte(_)
703        ));
704        assert_eq!(predicates.predicates()[1].field().name(), "event_start");
705        assert!(matches!(
706            predicates.predicates()[1].op(),
707            SearchPredicateOp::Lte(_)
708        ));
709        assert_eq!(predicates.predicates()[2].field().name(), "created_at");
710        assert!(matches!(
711            predicates.predicates()[2].op(),
712            SearchPredicateOp::Gte(_)
713        ));
714        assert_eq!(predicates.predicates()[3].field().name(), "created_at");
715        assert!(matches!(
716            predicates.predicates()[3].op(),
717            SearchPredicateOp::Lte(_)
718        ));
719    }
720
721    #[test]
722    fn metadata_filters_reject_date_alias_lists() {
723        let error = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([(
724            "event_date__in".to_string(),
725            r#"["2026-07-01"]"#.to_string(),
726        )]))
727        .unwrap_err();
728
729        assert_eq!(error.filter_key(), "event_date__in");
730        assert_eq!(
731            error.reason(),
732            "date alias filter only supports range predicates"
733        );
734    }
735
736    #[test]
737    fn metadata_filters_canonicalize_temporal_context_aliases() {
738        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
739            ("temporal".to_string(), " Recent ".to_string()),
740            (
741                "temporal_contexts__in".to_string(),
742                r#"["Current","Archived"]"#.to_string(),
743            ),
744        ]))
745        .unwrap();
746
747        assert_eq!(predicates.predicates().len(), 2);
748        assert_eq!(
749            predicates.predicates()[0].field().name(),
750            "temporal_context"
751        );
752        assert!(matches!(
753            predicates.predicates()[0].op(),
754            SearchPredicateOp::Eq(value) if value.kind() == "enum" && value.as_str() == "recent"
755        ));
756        assert_eq!(
757            predicates.predicates()[1].field().name(),
758            "temporal_context"
759        );
760        assert!(matches!(
761            predicates.predicates()[1].op(),
762            SearchPredicateOp::In(values)
763                if values.iter().all(|value| value.kind() == "enum")
764                    && values.iter().map(|value| value.as_str()).collect::<Vec<_>>()
765                        == vec!["archived", "current"]
766        ));
767    }
768
769    #[test]
770    fn metadata_filters_treat_labels_as_enum_predicates() {
771        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
772            ("labels".to_string(), " Database ".to_string()),
773            ("labels__in".to_string(), r#"["Rust","Graph"]"#.to_string()),
774            (
775                "labels__not_in".to_string(),
776                r#"["Deleted","Forgotten"]"#.to_string(),
777            ),
778        ]))
779        .unwrap();
780
781        assert_eq!(predicates.predicates().len(), 3);
782        assert!(matches!(
783            predicates.predicates()[0].op(),
784            SearchPredicateOp::Eq(value)
785                if value.kind() == "enum" && value.as_str() == "database"
786        ));
787        assert!(matches!(
788            predicates.predicates()[1].op(),
789            SearchPredicateOp::In(values)
790                if values.iter().all(|value| value.kind() == "enum")
791                    && values.iter().map(|value| value.as_str()).collect::<Vec<_>>()
792                        == vec!["graph", "rust"]
793        ));
794        assert!(matches!(
795            predicates.predicates()[2].op(),
796            SearchPredicateOp::NotIn(values)
797                if values.iter().all(|value| value.kind() == "enum")
798                    && values.iter().map(|value| value.as_str()).collect::<Vec<_>>()
799                        == vec!["deleted", "forgotten"]
800        ));
801    }
802
803    #[test]
804    fn metadata_filters_canonicalize_space_scope_aliases() {
805        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
806            ("space".to_string(), "Default".to_string()),
807            (
808                "space_ids__in".to_string(),
809                r#"["Default","Team"]"#.to_string(),
810            ),
811        ]))
812        .unwrap();
813
814        assert_eq!(predicates.predicates().len(), 2);
815        assert_eq!(predicates.predicates()[0].field().name(), "space_id");
816        assert!(matches!(
817            predicates.predicates()[0].op(),
818            SearchPredicateOp::Eq(value) if value.kind() == "string" && value.as_str() == "Default"
819        ));
820        assert_eq!(predicates.predicates()[1].field().name(), "space_id");
821        assert!(matches!(
822            predicates.predicates()[1].op(),
823            SearchPredicateOp::In(values)
824                if values.iter().all(|value| value.kind() == "string")
825                    && values.iter().map(|value| value.as_str()).collect::<Vec<_>>()
826                        == vec!["Default", "Team"]
827        ));
828    }
829
830    #[test]
831    fn metadata_filters_lower_exists_and_missing_predicates() {
832        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
833            ("source_id__exists".to_string(), "true".to_string()),
834            ("labels__missing".to_string(), "1".to_string()),
835            ("confidence__exists".to_string(), "false".to_string()),
836            ("importance__missing".to_string(), "0".to_string()),
837        ]))
838        .unwrap();
839
840        assert_eq!(predicates.predicates().len(), 4);
841        assert_eq!(predicates.predicates()[0].field().name(), "confidence");
842        assert!(matches!(
843            predicates.predicates()[0].op(),
844            SearchPredicateOp::IsMissing
845        ));
846        assert_eq!(predicates.predicates()[1].field().name(), "importance");
847        assert!(matches!(
848            predicates.predicates()[1].op(),
849            SearchPredicateOp::Exists
850        ));
851        assert_eq!(predicates.predicates()[2].field().name(), "labels");
852        assert!(matches!(
853            predicates.predicates()[2].op(),
854            SearchPredicateOp::IsMissing
855        ));
856        assert_eq!(predicates.predicates()[3].field().name(), "source_id");
857        assert!(matches!(
858            predicates.predicates()[3].op(),
859            SearchPredicateOp::Exists
860        ));
861    }
862
863    #[test]
864    fn metadata_filters_reject_malformed_presence_predicates() {
865        let error = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([(
866            "source_id__exists".to_string(),
867            "maybe".to_string(),
868        )]))
869        .unwrap_err();
870
871        assert_eq!(error.filter_key(), "source_id__exists");
872        assert_eq!(error.reason(), "expected boolean filter value");
873    }
874
875    #[test]
876    fn malformed_list_filters_are_parse_errors() {
877        let filters = BTreeMap::from([(
878            "lifecycle_state__not_in".to_string(),
879            "deleted,forgotten".to_string(),
880        )]);
881
882        let error = SearchPredicateSet::from_metadata_filters(&filters).unwrap_err();
883
884        assert_eq!(error.filter_key(), "lifecycle_state__not_in");
885        assert_eq!(error.reason(), "expected JSON string array");
886    }
887
888    #[test]
889    fn predicate_pushdown_splits_unsupported_predicates() {
890        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
891            ("kind".to_string(), "memory".to_string()),
892            (
893                "lifecycle_state__not_in".to_string(),
894                r#"["deleted"]"#.to_string(),
895            ),
896        ]))
897        .unwrap();
898
899        let pushdown = push_search_predicates(
900            &predicates,
901            SearchScanPredicateSupport {
902                equality: true,
903                in_list: true,
904                not_in_list: false,
905                range: true,
906                presence: true,
907            },
908        );
909
910        assert_eq!(pushdown.pushed().predicates().len(), 1);
911        assert_eq!(pushdown.residual().predicates().len(), 1);
912        assert_eq!(pushdown.events()[0].detail(), "pushed=1 residual=1");
913    }
914
915    #[test]
916    fn empty_in_list_is_unsatisfiable() {
917        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([(
918            "unit_type__in".to_string(),
919            "[]".to_string(),
920        )]))
921        .unwrap();
922
923        assert!(predicates.is_unsatisfiable());
924    }
925
926    #[test]
927    fn predicate_pushdown_splits_unsupported_range_predicates() {
928        let predicates = SearchPredicateSet::from_metadata_filters(&BTreeMap::from([
929            ("kind".to_string(), "memory".to_string()),
930            ("created_at__gte".to_string(), "1710000000".to_string()),
931        ]))
932        .unwrap();
933
934        let pushdown = push_search_predicates(
935            &predicates,
936            SearchScanPredicateSupport {
937                equality: true,
938                in_list: true,
939                not_in_list: true,
940                range: false,
941                presence: true,
942            },
943        );
944
945        assert_eq!(pushdown.pushed().predicates().len(), 1);
946        assert_eq!(pushdown.residual().predicates().len(), 1);
947        assert!(matches!(
948            pushdown.residual().predicates()[0].op(),
949            SearchPredicateOp::Gte(_)
950        ));
951    }
952}