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