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