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