Skip to main content

fathomdb_query/
plan.rs

1use std::fmt::Write;
2
3use crate::{Predicate, QueryAst, QueryStep, TraverseDirection};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum DrivingTable {
7    Nodes,
8    FtsNodes,
9    VecNodes,
10}
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ExecutionHints {
14    pub recursion_limit: usize,
15    pub hard_limit: usize,
16}
17
18pub fn choose_driving_table(ast: &QueryAst) -> DrivingTable {
19    let has_deterministic_id_filter = ast.steps.iter().any(|step| {
20        matches!(
21            step,
22            QueryStep::Filter(Predicate::LogicalIdEq(_) | Predicate::SourceRefEq(_))
23        )
24    });
25
26    if has_deterministic_id_filter {
27        DrivingTable::Nodes
28    } else if ast
29        .steps
30        .iter()
31        .any(|step| matches!(step, QueryStep::VectorSearch { .. }))
32    {
33        DrivingTable::VecNodes
34    } else if ast
35        .steps
36        .iter()
37        .any(|step| matches!(step, QueryStep::TextSearch { .. }))
38    {
39        DrivingTable::FtsNodes
40    } else {
41        DrivingTable::Nodes
42    }
43}
44
45pub fn execution_hints(ast: &QueryAst) -> ExecutionHints {
46    let step_limit = ast
47        .steps
48        .iter()
49        .find_map(|step| {
50            if let QueryStep::Traverse { max_depth, .. } = step {
51                Some(*max_depth)
52            } else {
53                None
54            }
55        })
56        .unwrap_or(0);
57    let expansion_limit = ast
58        .expansions
59        .iter()
60        .map(|expansion| expansion.max_depth)
61        .max()
62        .unwrap_or(0);
63    let recursion_limit = step_limit.max(expansion_limit);
64
65    ExecutionHints {
66        recursion_limit,
67        // FIX(review): was .max(1000) — always produced >= 1000, ignoring user's final_limit.
68        // Options considered: (A) use final_limit directly with default, (B) .min(MAX) ceiling,
69        // (C) decouple from final_limit. Chose (A): the CTE LIMIT should honor the user's
70        // requested limit; the depth bound (compile.rs:177) already constrains recursion.
71        hard_limit: ast.final_limit.unwrap_or(1000),
72    }
73}
74
75#[allow(clippy::too_many_lines)]
76pub fn shape_signature(ast: &QueryAst) -> String {
77    let mut signature = String::new();
78    let _ = write!(&mut signature, "Root({})", ast.root_kind);
79
80    for step in &ast.steps {
81        match step {
82            QueryStep::Search { limit, .. } => {
83                let _ = write!(&mut signature, "-Search(limit={limit})");
84            }
85            QueryStep::VectorSearch { limit, .. } => {
86                let _ = write!(&mut signature, "-Vector(limit={limit})");
87            }
88            QueryStep::TextSearch { limit, .. } => {
89                let _ = write!(&mut signature, "-Text(limit={limit})");
90            }
91            QueryStep::Traverse {
92                direction,
93                label,
94                max_depth,
95                filter: _,
96            } => {
97                let dir = match direction {
98                    TraverseDirection::In => "in",
99                    TraverseDirection::Out => "out",
100                };
101                let _ = write!(
102                    &mut signature,
103                    "-Traverse(direction={dir},label={label},depth={max_depth})"
104                );
105            }
106            QueryStep::Filter(predicate) => match predicate {
107                Predicate::LogicalIdEq(_) => signature.push_str("-Filter(logical_id_eq)"),
108                Predicate::KindEq(_) => signature.push_str("-Filter(kind_eq)"),
109                Predicate::JsonPathEq { path, .. } => {
110                    let _ = write!(&mut signature, "-Filter(json_eq:{path})");
111                }
112                Predicate::JsonPathCompare { path, op, .. } => {
113                    let op = match op {
114                        crate::ComparisonOp::Gt => "gt",
115                        crate::ComparisonOp::Gte => "gte",
116                        crate::ComparisonOp::Lt => "lt",
117                        crate::ComparisonOp::Lte => "lte",
118                    };
119                    let _ = write!(&mut signature, "-Filter(json_cmp:{path}:{op})");
120                }
121                Predicate::SourceRefEq(_) => signature.push_str("-Filter(source_ref_eq)"),
122                Predicate::ContentRefNotNull => {
123                    signature.push_str("-Filter(content_ref_not_null)");
124                }
125                Predicate::ContentRefEq(_) => signature.push_str("-Filter(content_ref_eq)"),
126                Predicate::JsonPathFusedEq { path, .. } => {
127                    let _ = write!(&mut signature, "-Filter(json_fused_eq:{path})");
128                }
129                Predicate::JsonPathFusedTimestampCmp { path, op, .. } => {
130                    let op = match op {
131                        crate::ComparisonOp::Gt => "gt",
132                        crate::ComparisonOp::Gte => "gte",
133                        crate::ComparisonOp::Lt => "lt",
134                        crate::ComparisonOp::Lte => "lte",
135                    };
136                    let _ = write!(&mut signature, "-Filter(json_fused_ts_cmp:{path}:{op})");
137                }
138                Predicate::JsonPathFusedBoolEq { path, .. } => {
139                    let _ = write!(&mut signature, "-Filter(json_fused_bool_eq:{path})");
140                }
141                Predicate::EdgePropertyEq { path, .. } => {
142                    let _ = write!(&mut signature, "-Filter(edge_eq:{path})");
143                }
144                Predicate::EdgePropertyCompare { path, op, .. } => {
145                    let op = match op {
146                        crate::ComparisonOp::Gt => "gt",
147                        crate::ComparisonOp::Gte => "gte",
148                        crate::ComparisonOp::Lt => "lt",
149                        crate::ComparisonOp::Lte => "lte",
150                    };
151                    let _ = write!(&mut signature, "-Filter(edge_cmp:{path}:{op})");
152                }
153                Predicate::JsonPathFusedIn { path, values } => {
154                    let _ = write!(
155                        &mut signature,
156                        "-Filter(json_fused_in:{path}:n={})",
157                        values.len()
158                    );
159                }
160                Predicate::JsonPathIn { path, values } => {
161                    let _ = write!(&mut signature, "-Filter(json_in:{path}:n={})", values.len());
162                }
163            },
164        }
165    }
166
167    for expansion in &ast.expansions {
168        let dir = match expansion.direction {
169            TraverseDirection::In => "in",
170            TraverseDirection::Out => "out",
171        };
172        let edge_filter_str = match &expansion.edge_filter {
173            None => String::new(),
174            Some(Predicate::EdgePropertyEq { path, .. }) => {
175                format!(",edge_eq:{path}")
176            }
177            Some(Predicate::EdgePropertyCompare { path, op, .. }) => {
178                let op_str = match op {
179                    crate::ComparisonOp::Gt => "gt",
180                    crate::ComparisonOp::Gte => "gte",
181                    crate::ComparisonOp::Lt => "lt",
182                    crate::ComparisonOp::Lte => "lte",
183                };
184                format!(",edge_cmp:{path}:{op_str}")
185            }
186            Some(p) => unreachable!("edge_filter predicate {p:?} not handled in shape_signature"),
187        };
188        let _ = write!(
189            &mut signature,
190            "-Expand(slot={},direction={dir},label={},depth={}{})",
191            expansion.slot, expansion.label, expansion.max_depth, edge_filter_str
192        );
193    }
194
195    if let Some(limit) = ast.final_limit {
196        let _ = write!(&mut signature, "-Limit({limit})");
197    }
198
199    signature
200}
201
202#[cfg(test)]
203mod tests {
204    use crate::{DrivingTable, QueryBuilder, TraverseDirection};
205
206    use super::{choose_driving_table, execution_hints};
207
208    #[test]
209    fn deterministic_filter_overrides_vector_driver() {
210        let ast = QueryBuilder::nodes("Meeting")
211            .vector_search("budget", 5)
212            .filter_logical_id_eq("meeting-123")
213            .into_ast();
214
215        assert_eq!(choose_driving_table(&ast), DrivingTable::Nodes);
216    }
217
218    #[test]
219    fn hard_limit_honors_user_specified_limit_below_default() {
220        let ast = QueryBuilder::nodes("Meeting")
221            .traverse(TraverseDirection::Out, "HAS_TASK", 3)
222            .limit(10)
223            .into_ast();
224
225        let hints = execution_hints(&ast);
226        assert_eq!(
227            hints.hard_limit, 10,
228            "hard_limit must honor user's final_limit"
229        );
230    }
231
232    #[test]
233    fn hard_limit_defaults_to_1000_when_no_limit_set() {
234        let ast = QueryBuilder::nodes("Meeting")
235            .traverse(TraverseDirection::Out, "HAS_TASK", 3)
236            .into_ast();
237
238        let hints = execution_hints(&ast);
239        assert_eq!(hints.hard_limit, 1000, "hard_limit defaults to 1000");
240    }
241
242    #[test]
243    fn shape_signature_differs_for_different_edge_filters() {
244        use crate::{ComparisonOp, ExpansionSlot, Predicate, QueryAst, ScalarValue};
245
246        let base_expansion = ExpansionSlot {
247            slot: "tasks".to_owned(),
248            direction: TraverseDirection::Out,
249            label: "HAS_TASK".to_owned(),
250            max_depth: 1,
251            filter: None,
252            edge_filter: None,
253        };
254
255        let ast_no_filter = QueryAst {
256            root_kind: "Meeting".to_owned(),
257            steps: vec![],
258            expansions: vec![base_expansion.clone()],
259            final_limit: None,
260        };
261
262        let ast_with_eq_filter = QueryAst {
263            root_kind: "Meeting".to_owned(),
264            steps: vec![],
265            expansions: vec![ExpansionSlot {
266                edge_filter: Some(Predicate::EdgePropertyEq {
267                    path: "$.rel".to_owned(),
268                    value: ScalarValue::Text("cites".to_owned()),
269                }),
270                ..base_expansion.clone()
271            }],
272            final_limit: None,
273        };
274
275        let ast_with_cmp_filter = QueryAst {
276            root_kind: "Meeting".to_owned(),
277            steps: vec![],
278            expansions: vec![ExpansionSlot {
279                edge_filter: Some(Predicate::EdgePropertyCompare {
280                    path: "$.weight".to_owned(),
281                    op: ComparisonOp::Gt,
282                    value: ScalarValue::Integer(5),
283                }),
284                ..base_expansion
285            }],
286            final_limit: None,
287        };
288
289        let sig_no_filter = super::shape_signature(&ast_no_filter);
290        let sig_eq_filter = super::shape_signature(&ast_with_eq_filter);
291        let sig_cmp_filter = super::shape_signature(&ast_with_cmp_filter);
292
293        assert_ne!(
294            sig_no_filter, sig_eq_filter,
295            "no edge_filter and EdgePropertyEq must produce different signatures"
296        );
297        assert_ne!(
298            sig_no_filter, sig_cmp_filter,
299            "no edge_filter and EdgePropertyCompare must produce different signatures"
300        );
301        assert_ne!(
302            sig_eq_filter, sig_cmp_filter,
303            "EdgePropertyEq and EdgePropertyCompare must produce different signatures"
304        );
305    }
306}