1use hawdb_core::{HawDBError, Result, Value};
2use hawdb_cypher as cypher;
3use hawdb_plan_cypher::{
4 self as planner, GraphMatchStep, LogicalPlan, PhysicalPlan, Predicate, Projection,
5 ProjectionExpression, RelationshipCountFilter, SortItem, SortKey,
6};
7use std::collections::BTreeMap;
8
9const PARAMETER_SLOT_NAME_KEY: &str = "\0skein_parameter_slot";
10const PARAMETER_SLOT_PATH_KEY: &str = "\0skein_parameter_path";
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13enum ParameterCacheValue {
14 Slot(ParameterValueShape),
15 Exact(Value),
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
19enum ParameterValueShape {
20 Null,
21 Bool,
22 Int,
23 Float,
24 String,
25 Binary,
26 Vector(hawdb_core::VectorDimension),
27 Uuid,
28 List(Vec<ParameterValueShape>),
29 Map(BTreeMap<String, ParameterValueShape>),
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct ParameterizedLogicalPlan {
34 logical: LogicalPlan,
35 cache_key: PlanParameterCacheKey,
36 slot_count: usize,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub struct PlanParameterCacheKey {
41 values: BTreeMap<String, ParameterCacheValue>,
42}
43
44impl ParameterizedLogicalPlan {
45 pub fn logical(&self) -> &LogicalPlan {
46 &self.logical
47 }
48
49 pub fn cache_key(&self) -> &PlanParameterCacheKey {
50 &self.cache_key
51 }
52
53 pub fn slot_count(&self) -> usize {
54 self.slot_count
55 }
56
57 pub fn exact_variant_count(&self) -> usize {
58 self.cache_key.values.len() - self.slot_count
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum MarkerUse {
64 None,
65 Safe,
66 Unsafe,
67}
68
69impl MarkerUse {
70 fn combine(self, other: Self) -> Self {
71 match (self, other) {
72 (Self::Unsafe, _) | (_, Self::Unsafe) => Self::Unsafe,
73 (Self::Safe, _) | (_, Self::Safe) => Self::Safe,
74 (Self::None, Self::None) => Self::None,
75 }
76 }
77}
78
79pub fn parameterize_logical_plan(
80 statement: &cypher::Statement,
81 parameters: &BTreeMap<String, Value>,
82) -> Result<ParameterizedLogicalPlan> {
83 let actual = planner::plan_with_params(statement, parameters)?;
84 if parameters.values().any(value_contains_any_marker) {
85 return Ok(ParameterizedLogicalPlan {
86 logical: actual,
87 cache_key: PlanParameterCacheKey {
88 values: parameters
89 .iter()
90 .map(|(name, value)| (name.clone(), ParameterCacheValue::Exact(value.clone())))
91 .collect(),
92 },
93 slot_count: 0,
94 });
95 }
96 let mut template_parameters = parameters.clone();
97 let mut cache_values = BTreeMap::new();
98 let mut slot_count = 0;
99
100 for (name, value) in parameters {
101 let marker = parameter_marker(name, value, &[]);
102 let mut candidate_parameters = parameters.clone();
103 candidate_parameters.insert(name.clone(), marker.clone());
104 let candidate = planner::plan_with_params(statement, &candidate_parameters).ok();
105 if candidate.as_ref().is_some_and(|candidate| {
106 marker_use_in_logical_plan(candidate, name) == MarkerUse::None && candidate == &actual
107 }) {
108 template_parameters.remove(name);
111 continue;
112 }
113 let is_safe = candidate.is_some_and(|mut candidate| {
114 marker_use_in_logical_plan(&candidate, name) == MarkerUse::Safe
115 && bind_logical_plan(&mut candidate, parameters).is_ok()
116 && candidate == actual
117 });
118 if is_safe {
119 template_parameters.insert(name.clone(), marker);
120 cache_values.insert(
121 name.clone(),
122 ParameterCacheValue::Slot(parameter_value_shape(value)),
123 );
124 slot_count += 1;
125 } else {
126 cache_values.insert(name.clone(), ParameterCacheValue::Exact(value.clone()));
127 }
128 }
129
130 let logical = if slot_count == 0 && template_parameters.len() == parameters.len() {
131 actual.clone()
132 } else {
133 let candidate = planner::plan_with_params(statement, &template_parameters)
134 .ok()
135 .filter(|candidate| {
136 let all_slots_are_safe = cache_values.iter().all(|(name, cached)| {
137 !matches!(cached, ParameterCacheValue::Slot(_))
138 || marker_use_in_logical_plan(candidate, name) == MarkerUse::Safe
139 });
140 let mut rebound = candidate.clone();
141 all_slots_are_safe
142 && bind_logical_plan(&mut rebound, parameters).is_ok()
143 && rebound == actual
144 });
145 if let Some(candidate) = candidate {
146 candidate
147 } else {
148 cache_values = parameters
149 .iter()
150 .map(|(name, value)| (name.clone(), ParameterCacheValue::Exact(value.clone())))
151 .collect();
152 slot_count = 0;
153 actual.clone()
154 }
155 };
156 Ok(ParameterizedLogicalPlan {
157 logical,
158 cache_key: PlanParameterCacheKey {
159 values: cache_values,
160 },
161 slot_count,
162 })
163}
164
165pub fn parameterize_value_list(name: &str, values: &[Value]) -> Vec<Value> {
171 match parameter_marker(name, &Value::List(values.to_vec()), &[]) {
172 Value::List(markers) => markers,
173 _ => unreachable!("list parameterization always produces a list"),
174 }
175}
176
177pub fn bind_physical_plan_parameters(
178 template: &PhysicalPlan,
179 parameters: &BTreeMap<String, Value>,
180 has_slots: bool,
181) -> Result<PhysicalPlan> {
182 let mut plan = template.clone();
183 if has_slots {
184 bind_physical_plan(&mut plan, parameters)?;
185 }
186 Ok(plan)
187}
188
189#[doc(hidden)]
192pub fn resolve_plan_template_value<'a>(
193 template: &'a Value,
194 parameters: &'a BTreeMap<String, Value>,
195 has_slots: bool,
196) -> Result<&'a Value> {
197 if has_slots && let Some((name, path)) = marker_name_and_path(template) {
198 return resolve_parameter_marker(parameters, name, path);
199 }
200 Ok(template)
201}
202
203fn parameter_value_shape(value: &Value) -> ParameterValueShape {
204 match value {
205 Value::Null => ParameterValueShape::Null,
206 Value::Bool(_) => ParameterValueShape::Bool,
207 Value::Int(_) => ParameterValueShape::Int,
208 Value::Float(_) => ParameterValueShape::Float,
209 Value::String(_) => ParameterValueShape::String,
210 Value::Binary(_) => ParameterValueShape::Binary,
211 Value::Vector(value) => ParameterValueShape::Vector(value.dimension()),
212 Value::Uuid(_) => ParameterValueShape::Uuid,
213 Value::List(values) => {
214 ParameterValueShape::List(values.iter().map(parameter_value_shape).collect())
215 }
216 Value::Map(values) => ParameterValueShape::Map(
217 values
218 .iter()
219 .map(|(key, value)| (key.clone(), parameter_value_shape(value)))
220 .collect(),
221 ),
222 }
223}
224
225fn parameter_marker(name: &str, value: &Value, path: &[usize]) -> Value {
226 match value {
227 Value::List(values) => Value::List(
228 values
229 .iter()
230 .enumerate()
231 .map(|(index, value)| {
232 let mut nested_path = path.to_vec();
233 nested_path.push(index);
234 parameter_marker(name, value, &nested_path)
235 })
236 .collect(),
237 ),
238 _ => Value::Map(BTreeMap::from([
239 (
240 PARAMETER_SLOT_NAME_KEY.to_string(),
241 Value::String(name.to_string()),
242 ),
243 (
244 PARAMETER_SLOT_PATH_KEY.to_string(),
245 Value::List(path.iter().map(|index| Value::Int(*index as i64)).collect()),
246 ),
247 ])),
248 }
249}
250
251fn marker_name_and_path(value: &Value) -> Option<(&str, Vec<usize>)> {
252 let Value::Map(values) = value else {
253 return None;
254 };
255 if values.len() != 2 {
256 return None;
257 }
258 let Value::String(name) = values.get(PARAMETER_SLOT_NAME_KEY)? else {
259 return None;
260 };
261 let Value::List(path) = values.get(PARAMETER_SLOT_PATH_KEY)? else {
262 return None;
263 };
264 let path = path
265 .iter()
266 .map(|index| match index {
267 Value::Int(index) if *index >= 0 => usize::try_from(*index).ok(),
268 _ => None,
269 })
270 .collect::<Option<Vec<_>>>()?;
271 Some((name, path))
272}
273
274fn value_contains_marker(value: &Value, name: &str) -> bool {
275 if marker_name_and_path(value).is_some_and(|(marker_name, _)| marker_name == name) {
276 return true;
277 }
278 match value {
279 Value::List(values) => values
280 .iter()
281 .any(|value| value_contains_marker(value, name)),
282 Value::Map(values) => values
283 .values()
284 .any(|value| value_contains_marker(value, name)),
285 _ => false,
286 }
287}
288
289fn value_contains_any_marker(value: &Value) -> bool {
290 if marker_name_and_path(value).is_some() {
291 return true;
292 }
293 match value {
294 Value::List(values) => values.iter().any(value_contains_any_marker),
295 Value::Map(values) => values.values().any(value_contains_any_marker),
296 _ => false,
297 }
298}
299
300fn values_marker_use<'a>(values: impl IntoIterator<Item = &'a Value>, name: &str) -> MarkerUse {
301 if values
302 .into_iter()
303 .any(|value| value_contains_marker(value, name))
304 {
305 MarkerUse::Safe
306 } else {
307 MarkerUse::None
308 }
309}
310
311fn marker_use_in_logical_plan(plan: &LogicalPlan, name: &str) -> MarkerUse {
312 match plan {
313 LogicalPlan::NodeScan { .. } | LogicalPlan::ThreadRepairStats { .. } => MarkerUse::None,
314 LogicalPlan::NodeCartesianProduct { left, right } => {
315 marker_use_in_logical_plan(left, name).combine(marker_use_in_logical_plan(right, name))
316 }
317 LogicalPlan::GraphMatch { program, input } => {
318 let mut marker_use = input.as_ref().map_or(MarkerUse::None, |input| {
319 marker_use_in_logical_plan(input, name)
320 });
321 for step in &program.steps {
322 let node = match step {
323 GraphMatchStep::Node(node) => node,
324 GraphMatchStep::Expand {
325 target, properties, ..
326 } => {
327 marker_use =
328 marker_use.combine(values_marker_use(properties.values(), name));
329 target
330 }
331 };
332 marker_use = marker_use.combine(values_marker_use(node.properties.values(), name));
333 }
334 if let Some(predicate) = &program.predicate {
335 marker_use = marker_use.combine(marker_use_in_predicate(predicate, name));
336 }
337 marker_use
338 }
339 LogicalPlan::NodeColumnLookup {
340 input,
341 node_visibility_predicate,
342 ..
343 } => {
344 let policy = node_visibility_predicate
345 .as_ref()
346 .map_or(MarkerUse::None, |p| marker_use_in_predicate(p, name));
347 policy.combine(marker_use_in_logical_plan(input, name))
348 }
349 LogicalPlan::Distinct { input } | LogicalPlan::Limit { input, .. } => {
350 marker_use_in_logical_plan(input, name)
351 }
352 LogicalPlan::Expand {
353 rel_properties,
354 input,
355 ..
356 } => values_marker_use(rel_properties.values(), name)
357 .combine(marker_use_in_logical_plan(input, name)),
358 LogicalPlan::OptionalDegree {
359 rel_properties,
360 target_properties,
361 input,
362 ..
363 } => values_marker_use(rel_properties.values(), name)
364 .combine(values_marker_use(target_properties.values(), name))
365 .combine(marker_use_in_logical_plan(input, name)),
366 LogicalPlan::OptionalRelationshipCountSum {
367 properties, legs, ..
368 } => {
369 let mut marker_use = values_marker_use(properties.values(), name);
370 for leg in legs {
371 if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
372 &leg.filter
373 {
374 marker_use = marker_use.combine(if value_contains_marker(value, name) {
375 MarkerUse::Safe
376 } else {
377 MarkerUse::None
378 });
379 }
380 }
381 marker_use
382 }
383 LogicalPlan::Filter {
384 predicate, input, ..
385 } => marker_use_in_predicate(predicate, name)
386 .combine(marker_use_in_logical_plan(input, name)),
387 LogicalPlan::Project { items, input } => marker_use_in_projections(items, name, false)
388 .combine(marker_use_in_logical_plan(input, name)),
389 LogicalPlan::Aggregate {
390 group_keys, input, ..
391 } => marker_use_in_projections(group_keys, name, false)
392 .combine(marker_use_in_logical_plan(input, name)),
393 LogicalPlan::Sort { items, input } => {
394 let marker_use = items.iter().fold(MarkerUse::None, |marker_use, item| {
395 marker_use.combine(marker_use_in_sort_item(item, name))
396 });
397 marker_use.combine(marker_use_in_logical_plan(input, name))
398 }
399 LogicalPlan::ShortestPath {
400 source_id,
401 source_visibility_predicate,
402 target_id,
403 target_visibility_predicate,
404 ..
405 } => {
406 let mut marker_use = values_marker_use([source_id, target_id], name);
407 if marker_use == MarkerUse::Safe {
408 marker_use = MarkerUse::Unsafe;
409 }
410 for predicate in [source_visibility_predicate, target_visibility_predicate]
411 .into_iter()
412 .flatten()
413 {
414 marker_use = marker_use.combine(marker_use_in_predicate(predicate, name));
415 }
416 marker_use
417 }
418 LogicalPlan::VectorIndexSearch(scan) => scan
419 .node_visibility_predicate
420 .as_ref()
421 .map_or(MarkerUse::None, |predicate| {
422 marker_use_in_predicate(predicate, name)
423 }),
424 LogicalPlan::GraphAlgorithmOutput(output) => output
425 .node_visibility_predicate
426 .as_ref()
427 .map(|predicate| marker_use_in_predicate(predicate, name))
428 .unwrap_or(MarkerUse::None),
429 LogicalPlan::GraphAlgorithm {
430 node_visibility_predicate,
431 ..
432 }
433 | LogicalPlan::GraphSeed {
434 node_visibility_predicate,
435 ..
436 } => node_visibility_predicate
437 .as_ref()
438 .map(|predicate| marker_use_in_predicate(predicate, name))
439 .unwrap_or(MarkerUse::None),
440 _ => MarkerUse::None,
441 }
442}
443
444fn marker_use_in_predicate(predicate: &Predicate, name: &str) -> MarkerUse {
445 match predicate {
446 Predicate::And(predicates) | Predicate::Or(predicates) => predicates
447 .iter()
448 .fold(MarkerUse::None, |marker_use, predicate| {
449 marker_use.combine(marker_use_in_predicate(predicate, name))
450 }),
451 Predicate::Not(predicate) => marker_use_in_predicate(predicate, name),
452 Predicate::PropertyEq { value, .. } | Predicate::PropertyNotEq { value, .. } => {
453 values_marker_use([value], name)
454 }
455 Predicate::PropertyIn { values, .. } => values_marker_use(values, name),
456 Predicate::IdEq { value, .. }
457 | Predicate::IdNotEq { value, .. }
458 | Predicate::IdCompare { value, .. }
459 | Predicate::PropertyCompare { value, .. }
460 | Predicate::PropertyListContains { value, .. } => {
461 if value_contains_marker(value, name) {
462 MarkerUse::Unsafe
463 } else {
464 MarkerUse::None
465 }
466 }
467 Predicate::IdIn { values, .. } => {
468 if values
469 .iter()
470 .any(|value| value_contains_marker(value, name))
471 {
472 MarkerUse::Unsafe
473 } else {
474 MarkerUse::None
475 }
476 }
477 Predicate::ExpressionEq { expression, value }
478 | Predicate::ExpressionNotEq { expression, value }
479 | Predicate::ExpressionContains { expression, value } => {
480 marker_use_in_projection_expression(expression, name, false)
481 .combine(marker_use_in_projection_expression(value, name, false))
482 }
483 Predicate::ExpressionIn { expression, values } => {
484 marker_use_in_projection_expression(expression, name, false)
485 .combine(values_marker_use(values, name))
486 }
487 Predicate::ExpressionCompare {
488 expression, value, ..
489 } => marker_use_in_projection_expression(expression, name, false)
490 .combine(marker_use_in_projection_expression(value, name, false)),
491 _ => MarkerUse::None,
492 }
493}
494
495fn marker_use_in_projections(projections: &[Projection], name: &str, safe: bool) -> MarkerUse {
496 projections
497 .iter()
498 .fold(MarkerUse::None, |marker_use, projection| {
499 marker_use.combine(marker_use_in_projection_expression(
500 &projection.expression,
501 name,
502 safe,
503 ))
504 })
505}
506
507fn marker_use_in_sort_item(item: &SortItem, name: &str) -> MarkerUse {
508 match &item.key {
509 SortKey::Expression(expression) => {
510 marker_use_in_projection_expression(expression, name, false)
511 }
512 _ => MarkerUse::None,
513 }
514}
515
516fn marker_use_in_projection_expression(
517 expression: &ProjectionExpression,
518 name: &str,
519 safe: bool,
520) -> MarkerUse {
521 let marker_use = match expression {
522 ProjectionExpression::Case { .. }
523 | ProjectionExpression::VectorDistance { .. }
524 | ProjectionExpression::Binary { .. }
525 | ProjectionExpression::Not(_)
526 | ProjectionExpression::IsNull { .. } => {
527 let mut result = MarkerUse::None;
528 expression.all_children(|child| {
529 result = result.combine(marker_use_in_projection_expression(child, name, safe));
530 true
531 });
532 result
533 }
534
535 ProjectionExpression::Literal(value) => values_marker_use([value], name),
536 ProjectionExpression::Coalesce(expressions) => {
537 expressions
538 .iter()
539 .fold(MarkerUse::None, |marker_use, expression| {
540 marker_use.combine(marker_use_in_projection_expression(expression, name, safe))
541 })
542 }
543 ProjectionExpression::Left { expression, .. }
544 | ProjectionExpression::Lower(expression)
545 | ProjectionExpression::JsonValue { expression, .. } => {
546 marker_use_in_projection_expression(expression, name, safe)
547 }
548 ProjectionExpression::DefaultIfNullOrEq { empty, default, .. }
549 | ProjectionExpression::ColumnDefaultIfNullOrEq { empty, default, .. } => {
550 values_marker_use([empty, default], name)
551 }
552 ProjectionExpression::DefaultIfNull { default, .. }
553 | ProjectionExpression::CaseLowerPropertyDefault { default, .. }
554 | ProjectionExpression::ColumnValueDefaultIfNull { default, .. } => {
555 values_marker_use([default], name)
556 }
557 ProjectionExpression::CasePropertyNotNullOrEq {
558 empty,
559 non_empty,
560 null_or_empty,
561 ..
562 }
563 | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq {
564 empty,
565 non_empty,
566 null_or_empty,
567 ..
568 } => values_marker_use([empty, non_empty, null_or_empty], name),
569 ProjectionExpression::CasePropertyEqualsRank {
570 branches, default, ..
571 } => values_marker_use(
572 branches
573 .iter()
574 .flat_map(|(left, right)| [left, right])
575 .chain([default]),
576 name,
577 ),
578 ProjectionExpression::CaseCoalesceDifferenceFloorZero { terms, .. } => {
579 values_marker_use(terms.iter().map(|term| &term.default), name)
580 }
581 ProjectionExpression::CaseEntitySearchRank(expression) => values_marker_use(
582 [
583 &expression.raw_query,
584 &expression.normalized_query,
585 &expression.raw_input,
586 &expression.exact_rank,
587 &expression.alias_rank,
588 &expression.fallback_rank,
589 ],
590 name,
591 ),
592 ProjectionExpression::CaseColumnSearchRank(expression) => values_marker_use(
593 [
594 &expression.raw_query,
595 &expression.normalized_query,
596 &expression.exact_rank,
597 &expression.contains_rank,
598 &expression.fallback_rank,
599 ],
600 name,
601 ),
602 _ => MarkerUse::None,
603 };
604 if !safe && marker_use == MarkerUse::Safe {
605 MarkerUse::Unsafe
606 } else {
607 marker_use
608 }
609}
610
611fn bind_physical_plan(plan: &mut PhysicalPlan, parameters: &BTreeMap<String, Value>) -> Result<()> {
612 match plan {
613 PhysicalPlan::VectorIndexScan(scan) => {
614 bind_optional_predicate(&mut scan.node_visibility_predicate, parameters)?;
615 }
616 PhysicalPlan::GraphAlgorithmOutput(output) => {
617 bind_optional_predicate(&mut output.node_visibility_predicate, parameters)?
618 }
619 PhysicalPlan::GraphAlgorithm {
620 node_visibility_predicate,
621 ..
622 }
623 | PhysicalPlan::GraphSeedScan {
624 node_visibility_predicate,
625 ..
626 } => bind_optional_predicate(node_visibility_predicate, parameters)?,
627 PhysicalPlan::SourceSegmentScan { predicate, .. } => {
628 bind_predicate(predicate, parameters)?;
629 }
630 PhysicalPlan::FilterExec { predicate, input } => {
631 bind_predicate(predicate, parameters)?;
632 bind_physical_plan(input, parameters)?;
633 }
634 PhysicalPlan::NodeProjectionScanExec {
635 access,
636 predicate,
637 items,
638 ..
639 } => {
640 bind_node_projection_access(access, parameters)?;
641 bind_optional_predicate(predicate, parameters)?;
642 bind_projections(items, parameters)?;
643 }
644 PhysicalPlan::NodeCartesianProductExec { left, right }
645 | PhysicalPlan::HashJoinExec { left, right, .. } => {
646 bind_physical_plan(left, parameters)?;
647 bind_physical_plan(right, parameters)?;
648 }
649 PhysicalPlan::GraphMatchExec { program, input } => {
650 for step in &mut program.steps {
651 let node = match step {
652 GraphMatchStep::Node(node) => node,
653 GraphMatchStep::Expand {
654 target, properties, ..
655 } => {
656 bind_value_map(properties, parameters)?;
657 target
658 }
659 };
660 bind_value_map(&mut node.properties, parameters)?;
661 }
662 bind_optional_predicate(&mut program.predicate, parameters)?;
663 if let Some(input) = input {
664 bind_physical_plan(input, parameters)?;
665 }
666 }
667 PhysicalPlan::NodeColumnLookupExec {
668 input,
669 node_visibility_predicate,
670 ..
671 } => {
672 bind_optional_predicate(node_visibility_predicate, parameters)?;
673 bind_physical_plan(input, parameters)?;
674 }
675 PhysicalPlan::AdjacencyExistsExec { input, .. }
676 | PhysicalPlan::DistinctExec { input }
677 | PhysicalPlan::ScoringRerankExec { input, .. }
678 | PhysicalPlan::ScoringProgramExec { input, .. }
679 | PhysicalPlan::HostScoringExec { input, .. }
680 | PhysicalPlan::LimitExec { input, .. } => bind_physical_plan(input, parameters)?,
681 PhysicalPlan::IndexNodeSeek { value, .. } => bind_value(value, parameters)?,
682 PhysicalPlan::IndexNodeMultiSeek { values, .. } => {
683 bind_values(values, parameters)?;
684 }
685 PhysicalPlan::IndexNodeUnionSeek { branches, .. } => {
686 for branch in branches {
687 bind_values(&mut branch.values, parameters)?;
688 }
689 }
690 PhysicalPlan::IndexNodeCompositeSeek { predicates, .. } => {
691 for (_, value) in predicates {
692 bind_value(value, parameters)?;
693 }
694 }
695 PhysicalPlan::IndexNodeCompositeRangeSeek { seek, .. } => {
696 bind_composite_range_seek(seek, parameters)?;
697 }
698 PhysicalPlan::IndexNodeRangeSeek { lower, upper, .. } => {
699 for (value, _) in lower.iter_mut().chain(upper.iter_mut()) {
700 bind_value(value, parameters)?;
701 }
702 }
703 PhysicalPlan::AdjacencyExpandExec {
704 rel_properties,
705 input,
706 ..
707 } => {
708 bind_value_map(rel_properties, parameters)?;
709 bind_physical_plan(input, parameters)?;
710 }
711 PhysicalPlan::OptionalDegreeExec {
712 rel_properties,
713 target_properties,
714 input,
715 ..
716 } => {
717 bind_value_map(rel_properties, parameters)?;
718 bind_value_map(target_properties, parameters)?;
719 bind_physical_plan(input, parameters)?;
720 }
721 PhysicalPlan::OptionalRelationshipCountSumExec {
722 properties, legs, ..
723 } => {
724 bind_value_map(properties, parameters)?;
725 for leg in legs {
726 if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
727 &mut leg.filter
728 {
729 bind_value(value, parameters)?;
730 }
731 }
732 }
733 PhysicalPlan::ShortestPathExec {
734 source_id,
735 source_visibility_predicate,
736 target_id,
737 target_visibility_predicate,
738 ..
739 } => {
740 bind_value(source_id, parameters)?;
741 bind_optional_predicate(source_visibility_predicate, parameters)?;
742 bind_value(target_id, parameters)?;
743 bind_optional_predicate(target_visibility_predicate, parameters)?;
744 }
745 PhysicalPlan::ProjectExec { items, input } => {
746 bind_projections(items, parameters)?;
747 bind_physical_plan(input, parameters)?;
748 }
749 PhysicalPlan::AggregateExec {
750 group_keys, input, ..
751 } => {
752 bind_projections(group_keys, parameters)?;
753 bind_physical_plan(input, parameters)?;
754 }
755 PhysicalPlan::SortExec { items, input } | PhysicalPlan::TopNExec { items, input, .. } => {
756 bind_sort_items(items, parameters)?;
757 bind_physical_plan(input, parameters)?;
758 }
759 PhysicalPlan::CreateNodeLabel { .. }
760 | PhysicalPlan::CreateRelationshipType { .. }
761 | PhysicalPlan::CreateNodeTable { .. }
762 | PhysicalPlan::CreateRelationshipTable { .. }
763 | PhysicalPlan::CreateProperty { .. }
764 | PhysicalPlan::AlterTableState { .. }
765 | PhysicalPlan::AlterPropertyState { .. }
766 | PhysicalPlan::CreateIndex { .. }
767 | PhysicalPlan::CreateCompositeIndex { .. }
768 | PhysicalPlan::CreateRangeIndex { .. }
769 | PhysicalPlan::CreateFullTextIndex { .. }
770 | PhysicalPlan::CreateUniqueConstraint { .. }
771 | PhysicalPlan::CreateNodePropertyExistsConstraint { .. }
772 | PhysicalPlan::CreateRelationshipUniqueConstraint { .. }
773 | PhysicalPlan::CreateRelationshipPropertyExistsConstraint { .. }
774 | PhysicalPlan::ProjectGraph { .. }
775 | PhysicalPlan::VectorSeedScan { .. }
776 | PhysicalPlan::TextSeedScan { .. }
777 | PhysicalPlan::CreateNode { .. }
778 | PhysicalPlan::UnwindMutation { .. }
779 | PhysicalPlan::MergeNode { .. }
780 | PhysicalPlan::MergeRelationship { .. }
781 | PhysicalPlan::MergeMatchedRelationship { .. }
782 | PhysicalPlan::MergeRelationshipFromMatchedRelationship { .. }
783 | PhysicalPlan::MergeRelationshipToMatchedTarget { .. }
784 | PhysicalPlan::MergeRelationshipFromMatchedTarget { .. }
785 | PhysicalPlan::CreateMatchedRelationship { .. }
786 | PhysicalPlan::SetNodeProperty { .. }
787 | PhysicalPlan::SetNodeProperties { .. }
788 | PhysicalPlan::SetNodePropertiesReturn { .. }
789 | PhysicalPlan::SetRelationshipProperty { .. }
790 | PhysicalPlan::SetRelationshipProperties { .. }
791 | PhysicalPlan::DeleteNode { .. }
792 | PhysicalPlan::DeleteRelationship { .. }
793 | PhysicalPlan::DeleteRelationshipTargetNodes { .. }
794 | PhysicalPlan::CreateRelationship { .. }
795 | PhysicalPlan::EmptyExec
796 | PhysicalPlan::SeqNodeScan { .. }
797 | PhysicalPlan::IndexNodeTextSeek { .. }
798 | PhysicalPlan::NodeCountExec { .. }
799 | PhysicalPlan::RelationshipCountExec { .. }
800 | PhysicalPlan::ThreadRepairStatsExec { .. } => {}
801 }
802 Ok(())
803}
804
805fn bind_node_projection_access(
806 access: &mut hawdb_plan_cypher::NodeProjectionAccess,
807 parameters: &BTreeMap<String, Value>,
808) -> Result<()> {
809 match access {
810 hawdb_plan_cypher::NodeProjectionAccess::LabelScan => Ok(()),
811 hawdb_plan_cypher::NodeProjectionAccess::PropertyValues { values, .. } => {
812 bind_values(values, parameters)
813 }
814 hawdb_plan_cypher::NodeProjectionAccess::PropertyUnion { branches } => {
815 for branch in branches {
816 bind_values(&mut branch.values, parameters)?;
817 }
818 Ok(())
819 }
820 hawdb_plan_cypher::NodeProjectionAccess::CompositeEquality { predicates } => {
821 for (_, value) in predicates {
822 bind_value(value, parameters)?;
823 }
824 Ok(())
825 }
826 hawdb_plan_cypher::NodeProjectionAccess::CompositeRange { seek } => {
827 bind_composite_range_seek(seek, parameters)
828 }
829 hawdb_plan_cypher::NodeProjectionAccess::PropertyRange { lower, upper, .. } => {
830 for (value, _) in lower.iter_mut().chain(upper.iter_mut()) {
831 bind_value(value, parameters)?;
832 }
833 Ok(())
834 }
835 hawdb_plan_cypher::NodeProjectionAccess::FullText { .. } => Ok(()),
836 }
837}
838
839fn bind_composite_range_seek(
840 seek: &mut hawdb_plan_cypher::CompositeRangeSeek,
841 parameters: &BTreeMap<String, Value>,
842) -> Result<()> {
843 for (_, value) in &mut seek.equality_prefix {
844 bind_value(value, parameters)?;
845 }
846 for (value, _) in seek.lower.iter_mut().chain(seek.upper.iter_mut()) {
847 bind_value(value, parameters)?;
848 }
849 Ok(())
850}
851
852fn bind_logical_plan(plan: &mut LogicalPlan, parameters: &BTreeMap<String, Value>) -> Result<()> {
853 match plan {
854 LogicalPlan::VectorIndexSearch(scan) => {
855 bind_optional_predicate(&mut scan.node_visibility_predicate, parameters)?
856 }
857 LogicalPlan::GraphAlgorithmOutput(output) => {
858 bind_optional_predicate(&mut output.node_visibility_predicate, parameters)?
859 }
860 LogicalPlan::GraphAlgorithm {
861 node_visibility_predicate,
862 ..
863 }
864 | LogicalPlan::GraphSeed {
865 node_visibility_predicate,
866 ..
867 } => bind_optional_predicate(node_visibility_predicate, parameters)?,
868 LogicalPlan::NodeCartesianProduct { left, right } => {
869 bind_logical_plan(left, parameters)?;
870 bind_logical_plan(right, parameters)?;
871 }
872 LogicalPlan::NodeColumnLookup {
873 input,
874 node_visibility_predicate,
875 ..
876 } => {
877 bind_optional_predicate(node_visibility_predicate, parameters)?;
878 bind_logical_plan(input, parameters)?;
879 }
880 LogicalPlan::Distinct { input } | LogicalPlan::Limit { input, .. } => {
881 bind_logical_plan(input, parameters)?
882 }
883 LogicalPlan::Expand {
884 rel_properties,
885 input,
886 ..
887 } => {
888 bind_value_map(rel_properties, parameters)?;
889 bind_logical_plan(input, parameters)?;
890 }
891 LogicalPlan::OptionalDegree {
892 rel_properties,
893 target_properties,
894 input,
895 ..
896 } => {
897 bind_value_map(rel_properties, parameters)?;
898 bind_value_map(target_properties, parameters)?;
899 bind_logical_plan(input, parameters)?;
900 }
901 LogicalPlan::OptionalRelationshipCountSum {
902 properties, legs, ..
903 } => {
904 bind_value_map(properties, parameters)?;
905 for leg in legs {
906 if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
907 &mut leg.filter
908 {
909 bind_value(value, parameters)?;
910 }
911 }
912 }
913 LogicalPlan::ShortestPath {
914 source_id,
915 source_visibility_predicate,
916 target_id,
917 target_visibility_predicate,
918 ..
919 } => {
920 bind_value(source_id, parameters)?;
921 bind_optional_predicate(source_visibility_predicate, parameters)?;
922 bind_value(target_id, parameters)?;
923 bind_optional_predicate(target_visibility_predicate, parameters)?;
924 }
925 LogicalPlan::Filter {
926 predicate, input, ..
927 } => {
928 bind_predicate(predicate, parameters)?;
929 bind_logical_plan(input, parameters)?;
930 }
931 LogicalPlan::Project { items, input } => {
932 bind_projections(items, parameters)?;
933 bind_logical_plan(input, parameters)?;
934 }
935 LogicalPlan::Aggregate {
936 group_keys, input, ..
937 } => {
938 bind_projections(group_keys, parameters)?;
939 bind_logical_plan(input, parameters)?;
940 }
941 LogicalPlan::Sort { items, input } => {
942 bind_sort_items(items, parameters)?;
943 bind_logical_plan(input, parameters)?;
944 }
945 _ => {}
946 }
947 Ok(())
948}
949
950fn bind_optional_predicate(
951 predicate: &mut Option<Predicate>,
952 parameters: &BTreeMap<String, Value>,
953) -> Result<()> {
954 if let Some(predicate) = predicate {
955 bind_predicate(predicate, parameters)?;
956 }
957 Ok(())
958}
959
960fn bind_predicate(predicate: &mut Predicate, parameters: &BTreeMap<String, Value>) -> Result<()> {
961 match predicate {
962 Predicate::And(predicates) | Predicate::Or(predicates) => {
963 for predicate in predicates {
964 bind_predicate(predicate, parameters)?;
965 }
966 }
967 Predicate::Not(predicate) => bind_predicate(predicate, parameters)?,
968 Predicate::IdEq { value, .. }
969 | Predicate::IdNotEq { value, .. }
970 | Predicate::IdCompare { value, .. }
971 | Predicate::PropertyEq { value, .. }
972 | Predicate::PropertyNotEq { value, .. }
973 | Predicate::PropertyCompare { value, .. }
974 | Predicate::PropertyListContains { value, .. } => bind_value(value, parameters)?,
975 Predicate::IdIn { values, .. } | Predicate::PropertyIn { values, .. } => {
976 bind_values(values, parameters)?;
977 }
978 Predicate::ExpressionIn { expression, values } => {
979 bind_projection_expression(expression, parameters)?;
980 bind_values(values, parameters)?;
981 }
982 Predicate::ExpressionEq { expression, value }
983 | Predicate::ExpressionNotEq { expression, value }
984 | Predicate::ExpressionContains { expression, value } => {
985 bind_projection_expression(expression, parameters)?;
986 bind_projection_expression(value, parameters)?;
987 }
988 Predicate::ExpressionCompare {
989 expression, value, ..
990 } => {
991 bind_projection_expression(expression, parameters)?;
992 bind_projection_expression(value, parameters)?;
993 }
994 _ => {}
995 }
996 Ok(())
997}
998
999fn bind_projections(
1000 projections: &mut [Projection],
1001 parameters: &BTreeMap<String, Value>,
1002) -> Result<()> {
1003 for projection in projections {
1004 bind_projection_expression(&mut projection.expression, parameters)?;
1005 }
1006 Ok(())
1007}
1008
1009fn bind_sort_items(items: &mut [SortItem], parameters: &BTreeMap<String, Value>) -> Result<()> {
1010 for item in items {
1011 if let SortKey::Expression(expression) = &mut item.key {
1012 bind_projection_expression(expression, parameters)?;
1013 }
1014 }
1015 Ok(())
1016}
1017
1018fn bind_projection_expression(
1019 expression: &mut ProjectionExpression,
1020 parameters: &BTreeMap<String, Value>,
1021) -> Result<()> {
1022 match expression {
1023 ProjectionExpression::Case { .. }
1024 | ProjectionExpression::VectorDistance { .. }
1025 | ProjectionExpression::Binary { .. }
1026 | ProjectionExpression::Not(_)
1027 | ProjectionExpression::IsNull { .. } => expression
1028 .try_for_each_child_mut(|child| bind_projection_expression(child, parameters))?,
1029
1030 ProjectionExpression::Literal(value) => bind_value(value, parameters)?,
1031 ProjectionExpression::Coalesce(expressions) => {
1032 for expression in expressions {
1033 bind_projection_expression(expression, parameters)?;
1034 }
1035 }
1036 ProjectionExpression::Left { expression, .. }
1037 | ProjectionExpression::Lower(expression)
1038 | ProjectionExpression::JsonValue { expression, .. } => {
1039 bind_projection_expression(expression, parameters)?;
1040 }
1041 ProjectionExpression::DefaultIfNullOrEq { empty, default, .. }
1042 | ProjectionExpression::ColumnDefaultIfNullOrEq { empty, default, .. } => {
1043 bind_value(empty, parameters)?;
1044 bind_value(default, parameters)?;
1045 }
1046 ProjectionExpression::DefaultIfNull { default, .. }
1047 | ProjectionExpression::CaseLowerPropertyDefault { default, .. }
1048 | ProjectionExpression::ColumnValueDefaultIfNull { default, .. } => {
1049 bind_value(default, parameters)?;
1050 }
1051 ProjectionExpression::CasePropertyNotNullOrEq {
1052 empty,
1053 non_empty,
1054 null_or_empty,
1055 ..
1056 }
1057 | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq {
1058 empty,
1059 non_empty,
1060 null_or_empty,
1061 ..
1062 } => {
1063 bind_value(empty, parameters)?;
1064 bind_value(non_empty, parameters)?;
1065 bind_value(null_or_empty, parameters)?;
1066 }
1067 ProjectionExpression::CasePropertyEqualsRank {
1068 branches, default, ..
1069 } => {
1070 for (left, right) in branches {
1071 bind_value(left, parameters)?;
1072 bind_value(right, parameters)?;
1073 }
1074 bind_value(default, parameters)?;
1075 }
1076 ProjectionExpression::CaseCoalesceDifferenceFloorZero { terms, .. } => {
1077 for term in terms {
1078 bind_value(&mut term.default, parameters)?;
1079 }
1080 }
1081 ProjectionExpression::CaseEntitySearchRank(expression) => {
1082 for value in [
1083 &mut expression.raw_query,
1084 &mut expression.normalized_query,
1085 &mut expression.raw_input,
1086 &mut expression.exact_rank,
1087 &mut expression.alias_rank,
1088 &mut expression.fallback_rank,
1089 ] {
1090 bind_value(value, parameters)?;
1091 }
1092 }
1093 ProjectionExpression::CaseColumnSearchRank(expression) => {
1094 for value in [
1095 &mut expression.raw_query,
1096 &mut expression.normalized_query,
1097 &mut expression.exact_rank,
1098 &mut expression.contains_rank,
1099 &mut expression.fallback_rank,
1100 ] {
1101 bind_value(value, parameters)?;
1102 }
1103 }
1104 _ => {}
1105 }
1106 Ok(())
1107}
1108
1109fn bind_value_map(
1110 values: &mut BTreeMap<String, Value>,
1111 parameters: &BTreeMap<String, Value>,
1112) -> Result<()> {
1113 for value in values.values_mut() {
1114 bind_value(value, parameters)?;
1115 }
1116 Ok(())
1117}
1118
1119fn bind_values(values: &mut [Value], parameters: &BTreeMap<String, Value>) -> Result<()> {
1120 for value in values {
1121 bind_value(value, parameters)?;
1122 }
1123 Ok(())
1124}
1125
1126fn resolve_parameter_marker<'a>(
1127 parameters: &'a BTreeMap<String, Value>,
1128 name: &str,
1129 path: Vec<usize>,
1130) -> Result<&'a Value> {
1131 let mut bound = parameters
1132 .get(name)
1133 .ok_or_else(|| HawDBError::Semantic(format!("missing parameter '${name}'")))?;
1134 for index in path {
1135 let Value::List(values) = bound else {
1136 return Err(HawDBError::Semantic(format!(
1137 "parameter '${name}' changed shape while binding a cached plan"
1138 )));
1139 };
1140 bound = values.get(index).ok_or_else(|| {
1141 HawDBError::Semantic(format!(
1142 "parameter '${name}' changed shape while binding a cached plan"
1143 ))
1144 })?;
1145 }
1146 Ok(bound)
1147}
1148
1149fn bind_value(value: &mut Value, parameters: &BTreeMap<String, Value>) -> Result<()> {
1150 if let Some((name, path)) = marker_name_and_path(value) {
1151 *value = resolve_parameter_marker(parameters, name, path)?.clone();
1152 return Ok(());
1153 }
1154 match value {
1155 Value::List(values) => bind_values(values, parameters),
1156 Value::Map(values) => bind_value_map(values, parameters),
1157 _ => Ok(()),
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::{
1164 bind_physical_plan_parameters, marker_use_in_logical_plan, parameter_marker,
1165 parameterize_logical_plan, resolve_plan_template_value, MarkerUse, ParameterCacheValue,
1166 PARAMETER_SLOT_NAME_KEY, PARAMETER_SLOT_PATH_KEY,
1167 };
1168 use hawdb_core::Value;
1169 use hawdb_cypher as cypher;
1170 use hawdb_optimizer_graph::{CascadesOptimizer, OptimizerCatalog};
1171 use hawdb_plan_cypher::{
1172 CompositeRangeSeek, ExactPropertySeekBranch, LogicalPlanRoot, NodeProjectionAccess,
1173 PhysicalPlan,
1174 };
1175 use std::collections::BTreeMap;
1176
1177 #[test]
1178 fn cost_operand_resolution_borrows_values_and_preserves_literal_markers() {
1179 let parameters = BTreeMap::from([("id".into(), Value::String("point".into()))]);
1180 let marker = parameter_marker("id", ¶meters["id"], &[]);
1181 let resolved = resolve_plan_template_value(&marker, ¶meters, true).unwrap();
1182 assert!(std::ptr::eq(resolved, ¶meters["id"]));
1183 assert!(std::ptr::eq(
1184 resolve_plan_template_value(&marker, &BTreeMap::new(), false).unwrap(),
1185 &marker
1186 ));
1187 assert!(resolve_plan_template_value(&marker, &BTreeMap::new(), true).is_err());
1188 let parameters = BTreeMap::from([("ids".into(), Value::List(vec![Value::Int(7)]))]);
1189 let Value::List(markers) = parameter_marker("ids", ¶meters["ids"], &[]) else {
1190 panic!("expected list markers")
1191 };
1192 let resolved = resolve_plan_template_value(&markers[0], ¶meters, true).unwrap();
1193 let Value::List(values) = ¶meters["ids"] else {
1194 panic!("expected list operand")
1195 };
1196 assert!(std::ptr::eq(resolved, &values[0]));
1197 assert!(resolve_plan_template_value(
1198 &markers[0],
1199 &BTreeMap::from([("ids".into(), Value::Int(7))]),
1200 true
1201 )
1202 .is_err());
1203 }
1204
1205 #[test]
1206 fn unused_bindings_share_a_template_without_hiding_exact_parameters() {
1207 let statement =
1208 cypher::parse("MATCH (n:Item) WHERE n.id = $id RETURN n.id AS id LIMIT $limit")
1209 .unwrap();
1210 let first = BTreeMap::from([
1211 ("id".into(), Value::Int(1)),
1212 ("limit".into(), Value::Int(3)),
1213 ("score".into(), Value::String("unused".into())),
1214 ]);
1215 let mut second = first.clone();
1216 second.insert("id".into(), Value::Int(2));
1217 second.insert("score".into(), Value::List(vec![Value::Int(9)]));
1218 let first_plan = parameterize_logical_plan(&statement, &first).unwrap();
1219 let second_plan = parameterize_logical_plan(&statement, &second).unwrap();
1220 assert_eq!(first_plan.cache_key(), second_plan.cache_key());
1221 assert!(!first_plan.cache_key.values.contains_key("score"));
1222 assert_eq!(first_plan.exact_variant_count(), 1);
1223 assert_eq!(first_plan.slot_count(), 1);
1224 second.remove("score");
1225 assert_eq!(
1226 first_plan.cache_key(),
1227 parameterize_logical_plan(&statement, &second)
1228 .unwrap()
1229 .cache_key()
1230 );
1231 second.insert("limit".into(), Value::Int(4));
1232 assert_ne!(
1233 first_plan.cache_key(),
1234 parameterize_logical_plan(&statement, &second)
1235 .unwrap()
1236 .cache_key()
1237 );
1238 second.remove("id");
1239 assert!(parameterize_logical_plan(&statement, &second).is_err());
1240 }
1241
1242 #[test]
1243 fn unused_only_bindings_leave_no_slots_and_preserve_literal_values() {
1244 let statement = cypher::parse("MATCH (n:Item) WHERE n.id = 1 RETURN n.id").unwrap();
1245 let empty = parameterize_logical_plan(&statement, &BTreeMap::new()).unwrap();
1246 let extra = parameterize_logical_plan(
1247 &statement,
1248 &BTreeMap::from([("unused".into(), Value::String("payload".into()))]),
1249 )
1250 .unwrap();
1251 assert_eq!(empty, extra);
1252 assert_eq!(extra.slot_count(), 0);
1253 assert_eq!(extra.exact_variant_count(), 0);
1254 }
1255
1256 #[test]
1257 fn general_case_preserves_exact_cache_keys_and_rebinds_explicit_template_slots() {
1258 let statement = cypher::parse("MATCH (n:Item) RETURN CASE WHEN n.id = $chosen THEN lower($label) ELSE $fallback END AS result").unwrap();
1259 let parameters = BTreeMap::from([
1260 ("chosen".into(), Value::Int(1)),
1261 ("label".into(), Value::String("FIRST".into())),
1262 ("fallback".into(), Value::String("old".into())),
1263 ]);
1264 let parameterized = parameterize_logical_plan(&statement, ¶meters).unwrap();
1265 assert_eq!(parameterized.slot_count, 0);
1266 assert!(parameterized
1267 .cache_key
1268 .values
1269 .values()
1270 .all(|value| matches!(value, ParameterCacheValue::Exact(_))));
1271 let markers = parameters
1274 .iter()
1275 .map(|(name, value)| (name.clone(), parameter_marker(name, value, &[])))
1276 .collect();
1277 let logical = hawdb_plan_cypher::plan_with_params(&statement, &markers).unwrap();
1278 let root = CascadesOptimizer::default().optimize_root_with_catalog(
1279 &LogicalPlanRoot::new(logical),
1280 &OptimizerCatalog::default(),
1281 );
1282 let (template, _) = root.into_parts();
1283 let parameters = BTreeMap::from([
1284 ("chosen".into(), Value::Int(2)),
1285 ("label".into(), Value::String("SECOND".into())),
1286 ("fallback".into(), Value::String("new".into())),
1287 ]);
1288 let rebound = bind_physical_plan_parameters(&template, ¶meters, true).unwrap();
1289 let text = format!("{rebound:?}");
1290 assert!(text.contains("SECOND") && text.contains("new"), "{text}");
1291 assert!(!text.contains("hawdb_parameter_slot"), "{text}");
1292 }
1293
1294 #[test]
1295 fn clause_match_rebinds_every_step_predicate_and_input() {
1296 let query = "MATCH (a:Item {id: $a}) OPTIONAL MATCH (a)-[r:LINK {code: $code}]->(b:Item {id: $b}) WHERE b.visible = $visible RETURN b";
1297 let slots = ["a", "b", "code", "visible"];
1298 let parameters = slots
1299 .into_iter()
1300 .map(|name| {
1301 (
1302 name.to_string(),
1303 parameter_marker(name, &Value::Int(0), &[]),
1304 )
1305 })
1306 .collect();
1307 let logical = hawdb_plan_cypher::plan_pipeline_query(query, ¶meters).unwrap();
1308 for name in slots {
1309 assert_eq!(
1310 marker_use_in_logical_plan(&logical, name),
1311 MarkerUse::Safe,
1312 "{name}"
1313 );
1314 }
1315 let optimize = |logical| {
1316 CascadesOptimizer::default()
1317 .optimize_root_with_catalog(
1318 &LogicalPlanRoot::new(logical),
1319 &OptimizerCatalog::default(),
1320 )
1321 .into_parts()
1322 .0
1323 };
1324 let template = optimize(logical);
1325 let original = template.instance_fingerprint();
1326 for base in [10, 20] {
1327 let parameters = slots
1328 .into_iter()
1329 .enumerate()
1330 .map(|(index, name)| (name.to_string(), Value::Int(base + index as i64)))
1331 .collect();
1332 let rebound = bind_physical_plan_parameters(&template, ¶meters, true).unwrap();
1333 let fresh =
1334 optimize(hawdb_plan_cypher::plan_pipeline_query(query, ¶meters).unwrap());
1335 assert_eq!(rebound.instance_fingerprint(), fresh.instance_fingerprint());
1336 assert!(!rebound
1337 .instance_fingerprint()
1338 .contains("hawdb_parameter_slot"));
1339 }
1340 assert_eq!(template.instance_fingerprint(), original);
1341 assert!(bind_physical_plan_parameters(&template, &BTreeMap::new(), true).is_err());
1342 }
1343
1344 #[test]
1345 fn equality_parameters_become_rebindable_slots() {
1346 let statement =
1347 cypher::parse("MATCH (m:Memory) WHERE m.id = $id RETURN m.title AS title").unwrap();
1348 let first_parameters =
1349 BTreeMap::from([("id".to_string(), Value::String("first".to_string()))]);
1350 let parameterized = parameterize_logical_plan(&statement, &first_parameters).unwrap();
1351 assert!(matches!(
1352 parameterized.cache_key.values.get("id"),
1353 Some(ParameterCacheValue::Slot(_))
1354 ));
1355
1356 let root = CascadesOptimizer::default().optimize_root_with_catalog(
1357 &LogicalPlanRoot::new(parameterized.logical),
1358 &OptimizerCatalog::default(),
1359 );
1360 let (template, _) = root.into_parts();
1361 let rebound = bind_physical_plan_parameters(
1362 &template,
1363 &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1364 true,
1365 )
1366 .unwrap();
1367
1368 assert!(rebound.explain(0).contains("second"));
1369 assert!(!rebound.explain(0).contains("hawdb_parameter_slot"));
1370 }
1371
1372 #[test]
1373 fn scoring_rerank_rebinds_its_input_and_rejects_missing_parameter_slots() {
1374 let spec = hawdb_core::graph_rag::ScoringSpec::weighted_scores(2.0, 1.0);
1375 let template = PhysicalPlan::ScoringRerankExec {
1376 score_column: "seed_score".into(),
1377 spec: spec.clone(),
1378 limit: 3,
1379 input: Box::new(PhysicalPlan::IndexNodeSeek {
1380 variable: "n".into(),
1381 label: "Item".into(),
1382 property: "scope".into(),
1383 value: parameter_marker("scope", &Value::String("template".into()), &[]),
1384 }),
1385 };
1386 for scope in ["first", "second"] {
1387 let rebound = bind_physical_plan_parameters(
1388 &template,
1389 &BTreeMap::from([("scope".into(), Value::String(scope.into()))]),
1390 true,
1391 )
1392 .unwrap();
1393 let PhysicalPlan::ScoringRerankExec {
1394 score_column,
1395 spec: rebound_spec,
1396 limit,
1397 input,
1398 } = rebound
1399 else {
1400 panic!("scoring operator must be preserved");
1401 };
1402 assert_eq!(score_column, "seed_score");
1403 assert_eq!(rebound_spec, spec);
1404 assert_eq!(limit, 3);
1405 let PhysicalPlan::IndexNodeSeek { value, .. } = *input else {
1406 panic!("scoring input seek must be preserved");
1407 };
1408 assert_eq!(
1409 value,
1410 Value::String(scope.into()),
1411 "scoring input must use current request values"
1412 );
1413 }
1414 assert!(bind_physical_plan_parameters(&template, &BTreeMap::new(), true).is_err());
1415 let PhysicalPlan::ScoringRerankExec { input, .. } = &template else {
1416 unreachable!()
1417 };
1418 let PhysicalPlan::IndexNodeSeek { value, .. } = input.as_ref() else {
1419 unreachable!()
1420 };
1421 assert_eq!(
1422 *value,
1423 parameter_marker("scope", &Value::String("template".into()), &[]),
1424 "binding must not mutate the retained template"
1425 );
1426 }
1427
1428 #[test]
1429 fn adjacency_exists_rebinds_its_input_and_rejects_missing_parameter_slots() {
1430 let marker = parameter_marker("scope", &Value::String("template".into()), &[]);
1431 let template = PhysicalPlan::AdjacencyExistsExec {
1432 source_variable: "n".into(),
1433 rel_type: "REL".into(),
1434 direction: cypher::RelationshipDirection::Outgoing,
1435 target_variable: "s".into(),
1436 input: Box::new(PhysicalPlan::IndexNodeSeek {
1437 variable: "n".into(),
1438 label: "Item".into(),
1439 property: "scope".into(),
1440 value: marker.clone(),
1441 }),
1442 };
1443 let original = template.clone();
1444 for scope in ["first", "second"] {
1445 let rebound = bind_physical_plan_parameters(
1446 &template,
1447 &BTreeMap::from([("scope".into(), Value::String(scope.into()))]),
1448 true,
1449 )
1450 .unwrap();
1451 let PhysicalPlan::AdjacencyExistsExec {
1452 source_variable,
1453 rel_type,
1454 direction,
1455 target_variable,
1456 input,
1457 } = rebound
1458 else {
1459 panic!("EXISTS operator must be preserved");
1460 };
1461 assert_eq!(source_variable, "n");
1462 assert_eq!(rel_type, "REL");
1463 assert_eq!(direction, cypher::RelationshipDirection::Outgoing);
1464 assert_eq!(target_variable, "s");
1465 let PhysicalPlan::IndexNodeSeek { value, .. } = *input else {
1466 panic!("EXISTS input seek must be preserved");
1467 };
1468 assert_eq!(value, Value::String(scope.into()));
1469 }
1470 assert!(bind_physical_plan_parameters(&template, &BTreeMap::new(), true).is_err());
1471 assert_eq!(
1472 template, original,
1473 "binding must preserve the cached template"
1474 );
1475 }
1476
1477 #[test]
1478 fn graph_hash_join_rebinds_both_inputs_without_changing_keys() {
1479 let seek = |variable: &str, parameter: &str| PhysicalPlan::IndexNodeSeek {
1480 variable: variable.into(),
1481 label: "Item".into(),
1482 property: "scope".into(),
1483 value: parameter_marker(parameter, &Value::Int(0), &[]),
1484 };
1485 let template = PhysicalPlan::HashJoinExec {
1486 left_key: hawdb_plan_cypher::HashJoinKey {
1487 variable: "a".into(),
1488 property: "key".into(),
1489 },
1490 right_key: hawdb_plan_cypher::HashJoinKey {
1491 variable: "b".into(),
1492 property: "key".into(),
1493 },
1494 left: Box::new(seek("a", "left_scope")),
1495 right: Box::new(seek("b", "right_scope")),
1496 };
1497 for (a, b) in [(1, 2), (3, 4)] {
1498 let bound = bind_physical_plan_parameters(
1499 &template,
1500 &BTreeMap::from([
1501 ("left_scope".into(), Value::Int(a)),
1502 ("right_scope".into(), Value::Int(b)),
1503 ]),
1504 true,
1505 )
1506 .unwrap();
1507 let PhysicalPlan::HashJoinExec {
1508 left_key,
1509 right_key,
1510 left,
1511 right,
1512 } = bound
1513 else {
1514 unreachable!()
1515 };
1516 assert_eq!(left_key.variable, "a");
1517 assert_eq!(right_key.variable, "b");
1518 assert!(
1519 matches!(*left, PhysicalPlan::IndexNodeSeek { value: Value::Int(value), .. } if value == a)
1520 );
1521 assert!(
1522 matches!(*right, PhysicalPlan::IndexNodeSeek { value: Value::Int(value), .. } if value == b)
1523 );
1524 }
1525 }
1526
1527 #[test]
1528 fn projected_index_access_rebinds_parameter_slots() {
1529 let template = PhysicalPlan::NodeProjectionScanExec {
1530 variable: "m".to_string(),
1531 label: "Memory".to_string(),
1532 access: NodeProjectionAccess::PropertyValues {
1533 property: "stable_id".to_string(),
1534 values: vec![parameter_marker(
1535 "id",
1536 &Value::String("first".to_string()),
1537 &[],
1538 )],
1539 },
1540 required_properties: vec!["stable_id".to_string()],
1541 predicate: None,
1542 items: Vec::new(),
1543 };
1544
1545 let rebound = bind_physical_plan_parameters(
1546 &template,
1547 &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1548 true,
1549 )
1550 .unwrap();
1551
1552 assert!(matches!(
1553 rebound,
1554 PhysicalPlan::NodeProjectionScanExec {
1555 access: NodeProjectionAccess::PropertyValues { values, .. },
1556 ..
1557 } if values == vec![Value::String("second".to_string())]
1558 ));
1559 }
1560
1561 #[test]
1562 fn projected_union_access_rebinds_every_branch() {
1563 let marker = || parameter_marker("id", &Value::String("first".to_string()), &[]);
1564 let template = PhysicalPlan::NodeProjectionScanExec {
1565 variable: "m".to_string(),
1566 label: "Memory".to_string(),
1567 access: NodeProjectionAccess::PropertyUnion {
1568 branches: vec![
1569 ExactPropertySeekBranch {
1570 property: "id".to_string(),
1571 values: vec![marker()],
1572 },
1573 ExactPropertySeekBranch {
1574 property: "external_id".to_string(),
1575 values: vec![marker()],
1576 },
1577 ],
1578 },
1579 required_properties: vec!["id".to_string(), "external_id".to_string()],
1580 predicate: None,
1581 items: Vec::new(),
1582 };
1583
1584 let rebound = bind_physical_plan_parameters(
1585 &template,
1586 &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1587 true,
1588 )
1589 .unwrap();
1590
1591 let PhysicalPlan::NodeProjectionScanExec {
1592 access: NodeProjectionAccess::PropertyUnion { branches },
1593 ..
1594 } = rebound
1595 else {
1596 panic!("expected projected union access");
1597 };
1598 assert!(branches
1599 .iter()
1600 .all(|branch| { branch.values == vec![Value::String("second".to_string())] }));
1601 }
1602
1603 #[test]
1604 fn projected_composite_range_rebinds_prefix_and_bounds() {
1605 let template = PhysicalPlan::NodeProjectionScanExec {
1606 variable: "m".to_string(),
1607 label: "Memory".to_string(),
1608 access: NodeProjectionAccess::CompositeRange {
1609 seek: CompositeRangeSeek {
1610 index_properties: vec!["space_id".to_string(), "created_at".to_string()],
1611 equality_prefix: vec![(
1612 "space_id".to_string(),
1613 parameter_marker(
1614 "space_id",
1615 &Value::String("space:first".to_string()),
1616 &[],
1617 ),
1618 )],
1619 range_property: "created_at".to_string(),
1620 lower: Some((parameter_marker("lower", &Value::Int(10), &[]), true)),
1621 upper: Some((parameter_marker("upper", &Value::Int(20), &[]), false)),
1622 },
1623 },
1624 required_properties: vec!["space_id".to_string(), "created_at".to_string()],
1625 predicate: None,
1626 items: Vec::new(),
1627 };
1628
1629 let rebound = bind_physical_plan_parameters(
1630 &template,
1631 &BTreeMap::from([
1632 (
1633 "space_id".to_string(),
1634 Value::String("space:second".to_string()),
1635 ),
1636 ("lower".to_string(), Value::Int(30)),
1637 ("upper".to_string(), Value::Int(40)),
1638 ]),
1639 true,
1640 )
1641 .unwrap();
1642
1643 assert!(matches!(
1644 rebound,
1645 PhysicalPlan::NodeProjectionScanExec {
1646 access: NodeProjectionAccess::CompositeRange { seek },
1647 ..
1648 } if seek.equality_prefix
1649 == vec![(
1650 "space_id".to_string(),
1651 Value::String("space:second".to_string()),
1652 )]
1653 && seek.lower == Some((Value::Int(30), true))
1654 && seek.upper == Some((Value::Int(40), false))
1655 ));
1656 }
1657
1658 #[test]
1659 fn pagination_parameters_remain_exact_variants() {
1660 let statement =
1661 cypher::parse("MATCH (m:Memory) RETURN m.id AS id ORDER BY m.id LIMIT $limit").unwrap();
1662 let parameterized = parameterize_logical_plan(
1663 &statement,
1664 &BTreeMap::from([("limit".to_string(), Value::Int(10))]),
1665 )
1666 .unwrap();
1667
1668 assert_eq!(
1669 parameterized.cache_key.values.get("limit"),
1670 Some(&ParameterCacheValue::Exact(Value::Int(10)))
1671 );
1672 }
1673
1674 #[test]
1675 fn parameters_that_change_logical_simplification_remain_exact_variants() {
1676 let statement = cypher::parse(
1677 "MATCH (t:Thread) WHERE $source IS NULL OR t.source = $source RETURN t.id AS id",
1678 )
1679 .unwrap();
1680 let parameterized = parameterize_logical_plan(
1681 &statement,
1682 &BTreeMap::from([("source".to_string(), Value::Null)]),
1683 )
1684 .unwrap();
1685
1686 assert_eq!(
1687 parameterized.cache_key.values.get("source"),
1688 Some(&ParameterCacheValue::Exact(Value::Null))
1689 );
1690 }
1691
1692 #[test]
1693 fn multiple_equality_parameters_share_one_rebindable_template() {
1694 let statement =
1695 cypher::parse("MATCH (m:Memory) WHERE m.id = $id AND m.kind = $kind RETURN m.id AS id")
1696 .unwrap();
1697 let parameterized = parameterize_logical_plan(
1698 &statement,
1699 &BTreeMap::from([
1700 ("id".to_string(), Value::String("memory-1".to_string())),
1701 ("kind".to_string(), Value::String("note".to_string())),
1702 ]),
1703 )
1704 .unwrap();
1705
1706 assert_eq!(parameterized.slot_count, 2);
1707 assert!(parameterized
1708 .cache_key
1709 .values
1710 .values()
1711 .all(|cached| matches!(cached, ParameterCacheValue::Slot(_))));
1712 }
1713
1714 #[test]
1715 fn marker_shaped_parameter_values_disable_slots() {
1716 let statement =
1717 cypher::parse("MATCH (m:Memory) WHERE m.metadata = $metadata RETURN m.id AS id")
1718 .unwrap();
1719 let marker_shaped = Value::Map(BTreeMap::from([
1720 (
1721 PARAMETER_SLOT_NAME_KEY.to_string(),
1722 Value::String("other".to_string()),
1723 ),
1724 (PARAMETER_SLOT_PATH_KEY.to_string(), Value::List(Vec::new())),
1725 ]));
1726 let parameterized = parameterize_logical_plan(
1727 &statement,
1728 &BTreeMap::from([("metadata".to_string(), marker_shaped.clone())]),
1729 )
1730 .unwrap();
1731
1732 assert_eq!(parameterized.slot_count, 0);
1733 assert_eq!(
1734 parameterized.cache_key.values.get("metadata"),
1735 Some(&ParameterCacheValue::Exact(marker_shaped))
1736 );
1737 }
1738}