1use 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}