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