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::SemanticSearch { limit, .. } => {
89                let _ = write!(&mut signature, "-Semantic(limit={limit})");
90            }
91            QueryStep::RawVectorSearch { vec, limit } => {
92                let _ = write!(&mut signature, "-RawVec(dim={},limit={limit})", vec.len());
93            }
94            QueryStep::TextSearch { limit, .. } => {
95                let _ = write!(&mut signature, "-Text(limit={limit})");
96            }
97            QueryStep::Traverse {
98                direction,
99                label,
100                max_depth,
101                filter: _,
102            } => {
103                let dir = match direction {
104                    TraverseDirection::In => "in",
105                    TraverseDirection::Out => "out",
106                };
107                let _ = write!(
108                    &mut signature,
109                    "-Traverse(direction={dir},label={label},depth={max_depth})"
110                );
111            }
112            QueryStep::Filter(predicate) => match predicate {
113                Predicate::LogicalIdEq(_) => signature.push_str("-Filter(logical_id_eq)"),
114                Predicate::KindEq(_) => signature.push_str("-Filter(kind_eq)"),
115                Predicate::JsonPathEq { path, .. } => {
116                    let _ = write!(&mut signature, "-Filter(json_eq:{path})");
117                }
118                Predicate::JsonPathCompare { path, op, .. } => {
119                    let op = match op {
120                        crate::ComparisonOp::Gt => "gt",
121                        crate::ComparisonOp::Gte => "gte",
122                        crate::ComparisonOp::Lt => "lt",
123                        crate::ComparisonOp::Lte => "lte",
124                    };
125                    let _ = write!(&mut signature, "-Filter(json_cmp:{path}:{op})");
126                }
127                Predicate::SourceRefEq(_) => signature.push_str("-Filter(source_ref_eq)"),
128                Predicate::ContentRefNotNull => {
129                    signature.push_str("-Filter(content_ref_not_null)");
130                }
131                Predicate::ContentRefEq(_) => signature.push_str("-Filter(content_ref_eq)"),
132                Predicate::JsonPathFusedEq { path, .. } => {
133                    let _ = write!(&mut signature, "-Filter(json_fused_eq:{path})");
134                }
135                Predicate::JsonPathFusedTimestampCmp { path, op, .. } => {
136                    let op = match op {
137                        crate::ComparisonOp::Gt => "gt",
138                        crate::ComparisonOp::Gte => "gte",
139                        crate::ComparisonOp::Lt => "lt",
140                        crate::ComparisonOp::Lte => "lte",
141                    };
142                    let _ = write!(&mut signature, "-Filter(json_fused_ts_cmp:{path}:{op})");
143                }
144                Predicate::JsonPathFusedBoolEq { path, .. } => {
145                    let _ = write!(&mut signature, "-Filter(json_fused_bool_eq:{path})");
146                }
147                Predicate::EdgePropertyEq { path, .. } => {
148                    let _ = write!(&mut signature, "-Filter(edge_eq:{path})");
149                }
150                Predicate::EdgePropertyCompare { path, op, .. } => {
151                    let op = match op {
152                        crate::ComparisonOp::Gt => "gt",
153                        crate::ComparisonOp::Gte => "gte",
154                        crate::ComparisonOp::Lt => "lt",
155                        crate::ComparisonOp::Lte => "lte",
156                    };
157                    let _ = write!(&mut signature, "-Filter(edge_cmp:{path}:{op})");
158                }
159                Predicate::JsonPathFusedIn { path, values } => {
160                    let _ = write!(
161                        &mut signature,
162                        "-Filter(json_fused_in:{path}:n={})",
163                        values.len()
164                    );
165                }
166                Predicate::JsonPathIn { path, values } => {
167                    let _ = write!(&mut signature, "-Filter(json_in:{path}:n={})", values.len());
168                }
169            },
170        }
171    }
172
173    for expansion in &ast.expansions {
174        let dir = match expansion.direction {
175            TraverseDirection::In => "in",
176            TraverseDirection::Out => "out",
177        };
178        let edge_filter_str = match &expansion.edge_filter {
179            None => String::new(),
180            Some(Predicate::EdgePropertyEq { path, .. }) => {
181                format!(",edge_eq:{path}")
182            }
183            Some(Predicate::EdgePropertyCompare { path, op, .. }) => {
184                let op_str = match op {
185                    crate::ComparisonOp::Gt => "gt",
186                    crate::ComparisonOp::Gte => "gte",
187                    crate::ComparisonOp::Lt => "lt",
188                    crate::ComparisonOp::Lte => "lte",
189                };
190                format!(",edge_cmp:{path}:{op_str}")
191            }
192            Some(p) => unreachable!("edge_filter predicate {p:?} not handled in shape_signature"),
193        };
194        let _ = write!(
195            &mut signature,
196            "-Expand(slot={},direction={dir},label={},depth={}{})",
197            expansion.slot, expansion.label, expansion.max_depth, edge_filter_str
198        );
199    }
200
201    for edge_expansion in &ast.edge_expansions {
202        let dir = match edge_expansion.direction {
203            TraverseDirection::In => "in",
204            TraverseDirection::Out => "out",
205        };
206        let _ = write!(
207            &mut signature,
208            "-EdgeExpand(slot={},direction={dir},label={},depth={},edge_filter={},endpoint_filter={})",
209            edge_expansion.slot,
210            edge_expansion.label,
211            edge_expansion.max_depth,
212            edge_expansion.edge_filter.is_some(),
213            edge_expansion.endpoint_filter.is_some(),
214        );
215    }
216
217    if let Some(limit) = ast.final_limit {
218        let _ = write!(&mut signature, "-Limit({limit})");
219    }
220
221    signature
222}
223
224#[cfg(test)]
225#[allow(deprecated)]
226mod tests {
227    use crate::{DrivingTable, QueryBuilder, TraverseDirection};
228
229    use super::{choose_driving_table, execution_hints};
230
231    #[test]
232    fn deterministic_filter_overrides_vector_driver() {
233        let ast = QueryBuilder::nodes("Meeting")
234            .vector_search("budget", 5)
235            .filter_logical_id_eq("meeting-123")
236            .into_ast();
237
238        assert_eq!(choose_driving_table(&ast), DrivingTable::Nodes);
239    }
240
241    #[test]
242    fn hard_limit_honors_user_specified_limit_below_default() {
243        let ast = QueryBuilder::nodes("Meeting")
244            .traverse(TraverseDirection::Out, "HAS_TASK", 3)
245            .limit(10)
246            .into_ast();
247
248        let hints = execution_hints(&ast);
249        assert_eq!(
250            hints.hard_limit, 10,
251            "hard_limit must honor user's final_limit"
252        );
253    }
254
255    #[test]
256    fn hard_limit_defaults_to_1000_when_no_limit_set() {
257        let ast = QueryBuilder::nodes("Meeting")
258            .traverse(TraverseDirection::Out, "HAS_TASK", 3)
259            .into_ast();
260
261        let hints = execution_hints(&ast);
262        assert_eq!(hints.hard_limit, 1000, "hard_limit defaults to 1000");
263    }
264
265    #[test]
266    fn shape_signature_differs_for_different_edge_filters() {
267        use crate::{ComparisonOp, ExpansionSlot, Predicate, QueryAst, ScalarValue};
268
269        let base_expansion = ExpansionSlot {
270            slot: "tasks".to_owned(),
271            direction: TraverseDirection::Out,
272            label: "HAS_TASK".to_owned(),
273            max_depth: 1,
274            filter: None,
275            edge_filter: None,
276        };
277
278        let ast_no_filter = QueryAst {
279            root_kind: "Meeting".to_owned(),
280            steps: vec![],
281            expansions: vec![base_expansion.clone()],
282            edge_expansions: vec![],
283            final_limit: None,
284        };
285
286        let ast_with_eq_filter = QueryAst {
287            root_kind: "Meeting".to_owned(),
288            steps: vec![],
289            expansions: vec![ExpansionSlot {
290                edge_filter: Some(Predicate::EdgePropertyEq {
291                    path: "$.rel".to_owned(),
292                    value: ScalarValue::Text("cites".to_owned()),
293                }),
294                ..base_expansion.clone()
295            }],
296            edge_expansions: vec![],
297            final_limit: None,
298        };
299
300        let ast_with_cmp_filter = QueryAst {
301            root_kind: "Meeting".to_owned(),
302            steps: vec![],
303            expansions: vec![ExpansionSlot {
304                edge_filter: Some(Predicate::EdgePropertyCompare {
305                    path: "$.weight".to_owned(),
306                    op: ComparisonOp::Gt,
307                    value: ScalarValue::Integer(5),
308                }),
309                ..base_expansion
310            }],
311            edge_expansions: vec![],
312            final_limit: None,
313        };
314
315        let sig_no_filter = super::shape_signature(&ast_no_filter);
316        let sig_eq_filter = super::shape_signature(&ast_with_eq_filter);
317        let sig_cmp_filter = super::shape_signature(&ast_with_cmp_filter);
318
319        assert_ne!(
320            sig_no_filter, sig_eq_filter,
321            "no edge_filter and EdgePropertyEq must produce different signatures"
322        );
323        assert_ne!(
324            sig_no_filter, sig_cmp_filter,
325            "no edge_filter and EdgePropertyCompare must produce different signatures"
326        );
327        assert_ne!(
328            sig_eq_filter, sig_cmp_filter,
329            "EdgePropertyEq and EdgePropertyCompare must produce different signatures"
330        );
331    }
332
333    #[test]
334    fn shape_signature_differs_for_different_edge_expansions() {
335        use crate::{EdgeExpansionSlot, Predicate, QueryAst, ScalarValue};
336
337        let base = EdgeExpansionSlot {
338            slot: "cites".to_owned(),
339            direction: TraverseDirection::Out,
340            label: "CITES".to_owned(),
341            max_depth: 1,
342            endpoint_filter: None,
343            edge_filter: None,
344        };
345
346        let ast_no_edge_expansions = QueryAst {
347            root_kind: "Paper".to_owned(),
348            steps: vec![],
349            expansions: vec![],
350            edge_expansions: vec![],
351            final_limit: None,
352        };
353
354        let ast_with_edge_expansion = QueryAst {
355            root_kind: "Paper".to_owned(),
356            steps: vec![],
357            expansions: vec![],
358            edge_expansions: vec![base.clone()],
359            final_limit: None,
360        };
361
362        let ast_with_different_slot_name = QueryAst {
363            root_kind: "Paper".to_owned(),
364            steps: vec![],
365            expansions: vec![],
366            edge_expansions: vec![EdgeExpansionSlot {
367                slot: "references".to_owned(),
368                ..base.clone()
369            }],
370            final_limit: None,
371        };
372
373        let ast_with_edge_filter = QueryAst {
374            root_kind: "Paper".to_owned(),
375            steps: vec![],
376            expansions: vec![],
377            edge_expansions: vec![EdgeExpansionSlot {
378                edge_filter: Some(Predicate::EdgePropertyEq {
379                    path: "$.rel".to_owned(),
380                    value: ScalarValue::Text("primary".to_owned()),
381                }),
382                ..base.clone()
383            }],
384            final_limit: None,
385        };
386
387        let ast_with_endpoint_filter = QueryAst {
388            root_kind: "Paper".to_owned(),
389            steps: vec![],
390            expansions: vec![],
391            edge_expansions: vec![EdgeExpansionSlot {
392                endpoint_filter: Some(Predicate::KindEq("Paper".to_owned())),
393                ..base
394            }],
395            final_limit: None,
396        };
397
398        let sig_none = super::shape_signature(&ast_no_edge_expansions);
399        let sig_basic = super::shape_signature(&ast_with_edge_expansion);
400        let sig_diff_slot = super::shape_signature(&ast_with_different_slot_name);
401        let sig_edge = super::shape_signature(&ast_with_edge_filter);
402        let sig_endpoint = super::shape_signature(&ast_with_endpoint_filter);
403
404        assert_ne!(
405            sig_none, sig_basic,
406            "presence of edge_expansions must change signature"
407        );
408        assert_ne!(
409            sig_basic, sig_diff_slot,
410            "different edge_expansion slot names must change signature"
411        );
412        assert_ne!(
413            sig_basic, sig_edge,
414            "edge_filter presence must change signature"
415        );
416        assert_ne!(
417            sig_basic, sig_endpoint,
418            "endpoint_filter presence must change signature"
419        );
420    }
421}