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    for edge_expansion in &ast.edge_expansions {
196        let dir = match edge_expansion.direction {
197            TraverseDirection::In => "in",
198            TraverseDirection::Out => "out",
199        };
200        let _ = write!(
201            &mut signature,
202            "-EdgeExpand(slot={},direction={dir},label={},depth={},edge_filter={},endpoint_filter={})",
203            edge_expansion.slot,
204            edge_expansion.label,
205            edge_expansion.max_depth,
206            edge_expansion.edge_filter.is_some(),
207            edge_expansion.endpoint_filter.is_some(),
208        );
209    }
210
211    if let Some(limit) = ast.final_limit {
212        let _ = write!(&mut signature, "-Limit({limit})");
213    }
214
215    signature
216}
217
218#[cfg(test)]
219mod tests {
220    use crate::{DrivingTable, QueryBuilder, TraverseDirection};
221
222    use super::{choose_driving_table, execution_hints};
223
224    #[test]
225    fn deterministic_filter_overrides_vector_driver() {
226        let ast = QueryBuilder::nodes("Meeting")
227            .vector_search("budget", 5)
228            .filter_logical_id_eq("meeting-123")
229            .into_ast();
230
231        assert_eq!(choose_driving_table(&ast), DrivingTable::Nodes);
232    }
233
234    #[test]
235    fn hard_limit_honors_user_specified_limit_below_default() {
236        let ast = QueryBuilder::nodes("Meeting")
237            .traverse(TraverseDirection::Out, "HAS_TASK", 3)
238            .limit(10)
239            .into_ast();
240
241        let hints = execution_hints(&ast);
242        assert_eq!(
243            hints.hard_limit, 10,
244            "hard_limit must honor user's final_limit"
245        );
246    }
247
248    #[test]
249    fn hard_limit_defaults_to_1000_when_no_limit_set() {
250        let ast = QueryBuilder::nodes("Meeting")
251            .traverse(TraverseDirection::Out, "HAS_TASK", 3)
252            .into_ast();
253
254        let hints = execution_hints(&ast);
255        assert_eq!(hints.hard_limit, 1000, "hard_limit defaults to 1000");
256    }
257
258    #[test]
259    fn shape_signature_differs_for_different_edge_filters() {
260        use crate::{ComparisonOp, ExpansionSlot, Predicate, QueryAst, ScalarValue};
261
262        let base_expansion = ExpansionSlot {
263            slot: "tasks".to_owned(),
264            direction: TraverseDirection::Out,
265            label: "HAS_TASK".to_owned(),
266            max_depth: 1,
267            filter: None,
268            edge_filter: None,
269        };
270
271        let ast_no_filter = QueryAst {
272            root_kind: "Meeting".to_owned(),
273            steps: vec![],
274            expansions: vec![base_expansion.clone()],
275            edge_expansions: vec![],
276            final_limit: None,
277        };
278
279        let ast_with_eq_filter = QueryAst {
280            root_kind: "Meeting".to_owned(),
281            steps: vec![],
282            expansions: vec![ExpansionSlot {
283                edge_filter: Some(Predicate::EdgePropertyEq {
284                    path: "$.rel".to_owned(),
285                    value: ScalarValue::Text("cites".to_owned()),
286                }),
287                ..base_expansion.clone()
288            }],
289            edge_expansions: vec![],
290            final_limit: None,
291        };
292
293        let ast_with_cmp_filter = QueryAst {
294            root_kind: "Meeting".to_owned(),
295            steps: vec![],
296            expansions: vec![ExpansionSlot {
297                edge_filter: Some(Predicate::EdgePropertyCompare {
298                    path: "$.weight".to_owned(),
299                    op: ComparisonOp::Gt,
300                    value: ScalarValue::Integer(5),
301                }),
302                ..base_expansion
303            }],
304            edge_expansions: vec![],
305            final_limit: None,
306        };
307
308        let sig_no_filter = super::shape_signature(&ast_no_filter);
309        let sig_eq_filter = super::shape_signature(&ast_with_eq_filter);
310        let sig_cmp_filter = super::shape_signature(&ast_with_cmp_filter);
311
312        assert_ne!(
313            sig_no_filter, sig_eq_filter,
314            "no edge_filter and EdgePropertyEq must produce different signatures"
315        );
316        assert_ne!(
317            sig_no_filter, sig_cmp_filter,
318            "no edge_filter and EdgePropertyCompare must produce different signatures"
319        );
320        assert_ne!(
321            sig_eq_filter, sig_cmp_filter,
322            "EdgePropertyEq and EdgePropertyCompare must produce different signatures"
323        );
324    }
325
326    #[test]
327    fn shape_signature_differs_for_different_edge_expansions() {
328        use crate::{EdgeExpansionSlot, Predicate, QueryAst, ScalarValue};
329
330        let base = EdgeExpansionSlot {
331            slot: "cites".to_owned(),
332            direction: TraverseDirection::Out,
333            label: "CITES".to_owned(),
334            max_depth: 1,
335            endpoint_filter: None,
336            edge_filter: None,
337        };
338
339        let ast_no_edge_expansions = QueryAst {
340            root_kind: "Paper".to_owned(),
341            steps: vec![],
342            expansions: vec![],
343            edge_expansions: vec![],
344            final_limit: None,
345        };
346
347        let ast_with_edge_expansion = QueryAst {
348            root_kind: "Paper".to_owned(),
349            steps: vec![],
350            expansions: vec![],
351            edge_expansions: vec![base.clone()],
352            final_limit: None,
353        };
354
355        let ast_with_different_slot_name = QueryAst {
356            root_kind: "Paper".to_owned(),
357            steps: vec![],
358            expansions: vec![],
359            edge_expansions: vec![EdgeExpansionSlot {
360                slot: "references".to_owned(),
361                ..base.clone()
362            }],
363            final_limit: None,
364        };
365
366        let ast_with_edge_filter = QueryAst {
367            root_kind: "Paper".to_owned(),
368            steps: vec![],
369            expansions: vec![],
370            edge_expansions: vec![EdgeExpansionSlot {
371                edge_filter: Some(Predicate::EdgePropertyEq {
372                    path: "$.rel".to_owned(),
373                    value: ScalarValue::Text("primary".to_owned()),
374                }),
375                ..base.clone()
376            }],
377            final_limit: None,
378        };
379
380        let ast_with_endpoint_filter = QueryAst {
381            root_kind: "Paper".to_owned(),
382            steps: vec![],
383            expansions: vec![],
384            edge_expansions: vec![EdgeExpansionSlot {
385                endpoint_filter: Some(Predicate::KindEq("Paper".to_owned())),
386                ..base
387            }],
388            final_limit: None,
389        };
390
391        let sig_none = super::shape_signature(&ast_no_edge_expansions);
392        let sig_basic = super::shape_signature(&ast_with_edge_expansion);
393        let sig_diff_slot = super::shape_signature(&ast_with_different_slot_name);
394        let sig_edge = super::shape_signature(&ast_with_edge_filter);
395        let sig_endpoint = super::shape_signature(&ast_with_endpoint_filter);
396
397        assert_ne!(
398            sig_none, sig_basic,
399            "presence of edge_expansions must change signature"
400        );
401        assert_ne!(
402            sig_basic, sig_diff_slot,
403            "different edge_expansion slot names must change signature"
404        );
405        assert_ne!(
406            sig_basic, sig_edge,
407            "edge_filter presence must change signature"
408        );
409        assert_ne!(
410            sig_basic, sig_endpoint,
411            "endpoint_filter presence must change signature"
412        );
413    }
414}