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                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/// Whether a predicate can be fused into a search CTE's `WHERE` clause.
65#[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            // This filter appears BEFORE the search step and must be ignored.
143            QueryStep::Filter(Predicate::KindEq("A".to_owned())),
144            QueryStep::TextSearch {
145                query: TextQuery::Empty,
146                limit: 10,
147            },
148            // This filter appears AFTER the search step and must be fusable.
149            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}