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 | QueryStep::SemanticSearch { .. }
50 | QueryStep::RawVectorSearch { .. } => {
51 seen_search = true;
52 }
53 QueryStep::Filter(predicate) if seen_search => {
54 if is_fusable(predicate) {
55 fusable.push(predicate.clone());
56 } else {
57 residual.push(predicate.clone());
58 }
59 }
60 _ => {}
61 }
62 }
63 (fusable, residual)
64}
65
66#[must_use]
68pub fn is_fusable(predicate: &Predicate) -> bool {
69 matches!(
70 predicate,
71 Predicate::KindEq(_)
72 | Predicate::LogicalIdEq(_)
73 | Predicate::SourceRefEq(_)
74 | Predicate::ContentRefEq(_)
75 | Predicate::ContentRefNotNull
76 | Predicate::JsonPathFusedEq { .. }
77 | Predicate::JsonPathFusedTimestampCmp { .. }
78 | Predicate::JsonPathFusedBoolEq { .. }
79 | Predicate::JsonPathFusedIn { .. }
80 )
81}
82
83#[cfg(test)]
84#[allow(clippy::expect_used)]
85mod tests {
86 use super::*;
87 use crate::{ComparisonOp, ScalarValue};
88
89 #[test]
90 fn partition_search_filters_separates_fusable_from_residual() {
91 use crate::TextQuery;
92 let steps = vec![
93 QueryStep::TextSearch {
94 query: TextQuery::Empty,
95 limit: 10,
96 },
97 QueryStep::Filter(Predicate::KindEq("Goal".to_owned())),
98 QueryStep::Filter(Predicate::LogicalIdEq("g-1".to_owned())),
99 QueryStep::Filter(Predicate::SourceRefEq("src".to_owned())),
100 QueryStep::Filter(Predicate::ContentRefEq("uri".to_owned())),
101 QueryStep::Filter(Predicate::ContentRefNotNull),
102 QueryStep::Filter(Predicate::JsonPathEq {
103 path: "$.status".to_owned(),
104 value: ScalarValue::Text("active".to_owned()),
105 }),
106 QueryStep::Filter(Predicate::JsonPathCompare {
107 path: "$.priority".to_owned(),
108 op: ComparisonOp::Gte,
109 value: ScalarValue::Integer(5),
110 }),
111 ];
112
113 let (fusable, residual) = partition_search_filters(&steps);
114 assert_eq!(fusable.len(), 5, "all five fusable variants must fuse");
115 assert_eq!(residual.len(), 2, "both JSON predicates must stay residual");
116 assert!(matches!(fusable[0], Predicate::KindEq(_)));
117 assert!(matches!(fusable[1], Predicate::LogicalIdEq(_)));
118 assert!(matches!(fusable[2], Predicate::SourceRefEq(_)));
119 assert!(matches!(fusable[3], Predicate::ContentRefEq(_)));
120 assert!(matches!(fusable[4], Predicate::ContentRefNotNull));
121 assert!(matches!(residual[0], Predicate::JsonPathEq { .. }));
122 assert!(matches!(residual[1], Predicate::JsonPathCompare { .. }));
123 }
124
125 #[test]
126 fn partition_ignores_non_filter_steps() {
127 use crate::TextQuery;
128 let steps = vec![
129 QueryStep::TextSearch {
130 query: TextQuery::Empty,
131 limit: 5,
132 },
133 QueryStep::Filter(Predicate::KindEq("Goal".to_owned())),
134 ];
135 let (fusable, residual) = partition_search_filters(&steps);
136 assert_eq!(fusable.len(), 1);
137 assert_eq!(residual.len(), 0);
138 }
139
140 #[test]
141 fn partition_search_filters_ignores_filters_before_search_step() {
142 use crate::TextQuery;
143 let steps = vec![
144 QueryStep::Filter(Predicate::KindEq("A".to_owned())),
146 QueryStep::TextSearch {
147 query: TextQuery::Empty,
148 limit: 10,
149 },
150 QueryStep::Filter(Predicate::KindEq("B".to_owned())),
152 ];
153 let (fusable, residual) = partition_search_filters(&steps);
154 assert_eq!(fusable.len(), 1);
155 assert_eq!(fusable[0], Predicate::KindEq("B".to_owned()));
156 assert!(residual.is_empty());
157 }
158
159 #[test]
160 fn fused_json_variants_are_fusable() {
161 assert!(is_fusable(&Predicate::JsonPathFusedEq {
162 path: "$.status".to_owned(),
163 value: "active".to_owned(),
164 }));
165 assert!(is_fusable(&Predicate::JsonPathFusedTimestampCmp {
166 path: "$.written_at".to_owned(),
167 op: ComparisonOp::Gt,
168 value: 1234,
169 }));
170 assert!(is_fusable(&Predicate::JsonPathFusedIn {
171 path: "$.status".to_owned(),
172 values: vec!["open".to_owned(), "pending".to_owned()],
173 }));
174 }
175
176 #[test]
177 fn non_fused_json_variants_stay_residual() {
178 assert!(!is_fusable(&Predicate::JsonPathEq {
179 path: "$.status".to_owned(),
180 value: ScalarValue::Text("active".to_owned()),
181 }));
182 assert!(!is_fusable(&Predicate::JsonPathCompare {
183 path: "$.priority".to_owned(),
184 op: ComparisonOp::Gte,
185 value: ScalarValue::Integer(5),
186 }));
187 assert!(!is_fusable(&Predicate::JsonPathIn {
188 path: "$.category".to_owned(),
189 values: vec![ScalarValue::Text("alpha".to_owned())],
190 }));
191 }
192
193 #[test]
194 fn partition_search_filters_returns_empty_without_search_step() {
195 let steps = vec![QueryStep::Filter(Predicate::KindEq("A".to_owned()))];
196 let (fusable, residual) = partition_search_filters(&steps);
197 assert!(fusable.is_empty());
198 assert!(residual.is_empty());
199 }
200}