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