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