1use crate::{Predicate, QueryStep};
15
16#[must_use]
40pub fn partition_search_filters(steps: &[QueryStep]) -> (Vec<Predicate>, Vec<Predicate>) {
41 let mut fusable = Vec::new();
42 let mut residual = Vec::new();
43 let mut seen_search = false;
44 for step in steps {
45 match step {
46 QueryStep::Search { .. }
47 | QueryStep::TextSearch { .. }
48 | QueryStep::VectorSearch { .. } => {
49 seen_search = true;
50 }
51 QueryStep::Filter(predicate) if seen_search => {
52 if is_fusable(predicate) {
53 fusable.push(predicate.clone());
54 } else {
55 residual.push(predicate.clone());
56 }
57 }
58 _ => {}
59 }
60 }
61 (fusable, residual)
62}
63
64#[must_use]
66pub fn is_fusable(predicate: &Predicate) -> bool {
67 matches!(
68 predicate,
69 Predicate::KindEq(_)
70 | Predicate::LogicalIdEq(_)
71 | Predicate::SourceRefEq(_)
72 | Predicate::ContentRefEq(_)
73 | Predicate::ContentRefNotNull
74 )
75}
76
77#[cfg(test)]
78#[allow(clippy::expect_used)]
79mod tests {
80 use super::*;
81 use crate::{ComparisonOp, ScalarValue};
82
83 #[test]
84 fn partition_search_filters_separates_fusable_from_residual() {
85 use crate::TextQuery;
86 let steps = vec![
87 QueryStep::TextSearch {
88 query: TextQuery::Empty,
89 limit: 10,
90 },
91 QueryStep::Filter(Predicate::KindEq("Goal".to_owned())),
92 QueryStep::Filter(Predicate::LogicalIdEq("g-1".to_owned())),
93 QueryStep::Filter(Predicate::SourceRefEq("src".to_owned())),
94 QueryStep::Filter(Predicate::ContentRefEq("uri".to_owned())),
95 QueryStep::Filter(Predicate::ContentRefNotNull),
96 QueryStep::Filter(Predicate::JsonPathEq {
97 path: "$.status".to_owned(),
98 value: ScalarValue::Text("active".to_owned()),
99 }),
100 QueryStep::Filter(Predicate::JsonPathCompare {
101 path: "$.priority".to_owned(),
102 op: ComparisonOp::Gte,
103 value: ScalarValue::Integer(5),
104 }),
105 ];
106
107 let (fusable, residual) = partition_search_filters(&steps);
108 assert_eq!(fusable.len(), 5, "all five fusable variants must fuse");
109 assert_eq!(residual.len(), 2, "both JSON predicates must stay residual");
110 assert!(matches!(fusable[0], Predicate::KindEq(_)));
111 assert!(matches!(fusable[1], Predicate::LogicalIdEq(_)));
112 assert!(matches!(fusable[2], Predicate::SourceRefEq(_)));
113 assert!(matches!(fusable[3], Predicate::ContentRefEq(_)));
114 assert!(matches!(fusable[4], Predicate::ContentRefNotNull));
115 assert!(matches!(residual[0], Predicate::JsonPathEq { .. }));
116 assert!(matches!(residual[1], Predicate::JsonPathCompare { .. }));
117 }
118
119 #[test]
120 fn partition_ignores_non_filter_steps() {
121 use crate::TextQuery;
122 let steps = vec![
123 QueryStep::TextSearch {
124 query: TextQuery::Empty,
125 limit: 5,
126 },
127 QueryStep::Filter(Predicate::KindEq("Goal".to_owned())),
128 ];
129 let (fusable, residual) = partition_search_filters(&steps);
130 assert_eq!(fusable.len(), 1);
131 assert_eq!(residual.len(), 0);
132 }
133
134 #[test]
135 fn partition_search_filters_ignores_filters_before_search_step() {
136 use crate::TextQuery;
137 let steps = vec![
138 QueryStep::Filter(Predicate::KindEq("A".to_owned())),
140 QueryStep::TextSearch {
141 query: TextQuery::Empty,
142 limit: 10,
143 },
144 QueryStep::Filter(Predicate::KindEq("B".to_owned())),
146 ];
147 let (fusable, residual) = partition_search_filters(&steps);
148 assert_eq!(fusable.len(), 1);
149 assert_eq!(fusable[0], Predicate::KindEq("B".to_owned()));
150 assert!(residual.is_empty());
151 }
152
153 #[test]
154 fn partition_search_filters_returns_empty_without_search_step() {
155 let steps = vec![QueryStep::Filter(Predicate::KindEq("A".to_owned()))];
156 let (fusable, residual) = partition_search_filters(&steps);
157 assert!(fusable.is_empty());
158 assert!(residual.is_empty());
159 }
160}