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    )
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            // This filter appears BEFORE the search step and must be ignored.
141            QueryStep::Filter(Predicate::KindEq("A".to_owned())),
142            QueryStep::TextSearch {
143                query: TextQuery::Empty,
144                limit: 10,
145            },
146            // This filter appears AFTER the search step and must be fusable.
147            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}