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