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    )
75}
76
77#[cfg(test)]
78#[allow(clippy::expect_used)]
79mod tests {
80    use super::*;
81    use crate::{ComparisonOp, ScalarValue};
82
83    #[test]
84    fn partition_search_filters_separates_fusable_from_residual() {
85        use crate::TextQuery;
86        let steps = vec![
87            QueryStep::TextSearch {
88                query: TextQuery::Empty,
89                limit: 10,
90            },
91            QueryStep::Filter(Predicate::KindEq("Goal".to_owned())),
92            QueryStep::Filter(Predicate::LogicalIdEq("g-1".to_owned())),
93            QueryStep::Filter(Predicate::SourceRefEq("src".to_owned())),
94            QueryStep::Filter(Predicate::ContentRefEq("uri".to_owned())),
95            QueryStep::Filter(Predicate::ContentRefNotNull),
96            QueryStep::Filter(Predicate::JsonPathEq {
97                path: "$.status".to_owned(),
98                value: ScalarValue::Text("active".to_owned()),
99            }),
100            QueryStep::Filter(Predicate::JsonPathCompare {
101                path: "$.priority".to_owned(),
102                op: ComparisonOp::Gte,
103                value: ScalarValue::Integer(5),
104            }),
105        ];
106
107        let (fusable, residual) = partition_search_filters(&steps);
108        assert_eq!(fusable.len(), 5, "all five fusable variants must fuse");
109        assert_eq!(residual.len(), 2, "both JSON predicates must stay residual");
110        assert!(matches!(fusable[0], Predicate::KindEq(_)));
111        assert!(matches!(fusable[1], Predicate::LogicalIdEq(_)));
112        assert!(matches!(fusable[2], Predicate::SourceRefEq(_)));
113        assert!(matches!(fusable[3], Predicate::ContentRefEq(_)));
114        assert!(matches!(fusable[4], Predicate::ContentRefNotNull));
115        assert!(matches!(residual[0], Predicate::JsonPathEq { .. }));
116        assert!(matches!(residual[1], Predicate::JsonPathCompare { .. }));
117    }
118
119    #[test]
120    fn partition_ignores_non_filter_steps() {
121        use crate::TextQuery;
122        let steps = vec![
123            QueryStep::TextSearch {
124                query: TextQuery::Empty,
125                limit: 5,
126            },
127            QueryStep::Filter(Predicate::KindEq("Goal".to_owned())),
128        ];
129        let (fusable, residual) = partition_search_filters(&steps);
130        assert_eq!(fusable.len(), 1);
131        assert_eq!(residual.len(), 0);
132    }
133
134    #[test]
135    fn partition_search_filters_ignores_filters_before_search_step() {
136        use crate::TextQuery;
137        let steps = vec![
138            // This filter appears BEFORE the search step and must be ignored.
139            QueryStep::Filter(Predicate::KindEq("A".to_owned())),
140            QueryStep::TextSearch {
141                query: TextQuery::Empty,
142                limit: 10,
143            },
144            // This filter appears AFTER the search step and must be fusable.
145            QueryStep::Filter(Predicate::KindEq("B".to_owned())),
146        ];
147        let (fusable, residual) = partition_search_filters(&steps);
148        assert_eq!(fusable.len(), 1);
149        assert_eq!(fusable[0], Predicate::KindEq("B".to_owned()));
150        assert!(residual.is_empty());
151    }
152
153    #[test]
154    fn partition_search_filters_returns_empty_without_search_step() {
155        let steps = vec![QueryStep::Filter(Predicate::KindEq("A".to_owned()))];
156        let (fusable, residual) = partition_search_filters(&steps);
157        assert!(fusable.is_empty());
158        assert!(residual.is_empty());
159    }
160}