Skip to main content

fathomdb_query/
fusion.rs

1//! Filter-fusion helpers for search-driven query pipelines.
2//!
3//! Phase 2 filter fusion classifies `Filter(Predicate)` steps following a
4//! search step into **fusable** predicates — those that can be pushed into
5//! the driving-search CTE's `WHERE` clause so the CTE `LIMIT` applies *after*
6//! filtering — and **residual** predicates that remain in the outer `WHERE`.
7//!
8//! A predicate is fusable when it can be evaluated against columns available
9//! on the `nodes` table joined inside the search CTE (`kind`, `logical_id`,
10//! `source_ref`, `content_ref`). JSON-property predicates are residual: they
11//! require `json_extract` against the `n.properties` column projected by the
12//! outer SELECT.
13
14use crate::{Predicate, QueryStep};
15
16/// Partition `Filter` predicates **following a search step** into fusable
17/// and residual sets, preserving source order within each set.
18///
19/// # Returns
20///
21/// A `(fusable, residual)` pair where:
22///
23/// * `fusable` contains predicates that can be injected into the driving
24///   search CTE's `WHERE` clause (currently
25///   [`Predicate::KindEq`], [`Predicate::LogicalIdEq`],
26///   [`Predicate::SourceRefEq`], [`Predicate::ContentRefEq`], and
27///   [`Predicate::ContentRefNotNull`]).
28/// * `residual` contains predicates that remain in the outer `WHERE`
29///   (currently [`Predicate::JsonPathEq`] and
30///   [`Predicate::JsonPathCompare`]).
31///
32/// Non-`Filter` steps (search steps, traversals) are ignored.
33///
34/// Only `Filter` steps that appear **after** the first `TextSearch` or
35/// `VectorSearch` step contribute to the partition; predicates placed
36/// before a search step do not belong to the search-driven path and are
37/// skipped. When no search step is present, both returned vectors are
38/// empty.
39#[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/// Whether a predicate can be fused into a search CTE's `WHERE` clause.
67#[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            // This filter appears BEFORE the search step and must be ignored.
145            QueryStep::Filter(Predicate::KindEq("A".to_owned())),
146            QueryStep::TextSearch {
147                query: TextQuery::Empty,
148                limit: 10,
149            },
150            // This filter appears AFTER the search step and must be fusable.
151            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}