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 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}