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::GraphAlgorithm {
407 node_visibility_predicate,
408 ..
409 }
410 | LogicalPlan::GraphSeed {
411 node_visibility_predicate,
412 ..
413 } => node_visibility_predicate
414 .as_ref()
415 .map(|predicate| marker_use_in_predicate(predicate, name))
416 .unwrap_or(MarkerUse::None),
417 _ => MarkerUse::None,
418 }
419}
420
421fn marker_use_in_predicate(predicate: &Predicate, name: &str) -> MarkerUse {
422 match predicate {
423 Predicate::And(predicates) | Predicate::Or(predicates) => predicates
424 .iter()
425 .fold(MarkerUse::None, |marker_use, predicate| {
426 marker_use.combine(marker_use_in_predicate(predicate, name))
427 }),
428 Predicate::Not(predicate) => marker_use_in_predicate(predicate, name),
429 Predicate::PropertyEq { value, .. } | Predicate::PropertyNotEq { value, .. } => {
430 values_marker_use([value], name)
431 }
432 Predicate::PropertyIn { values, .. } => values_marker_use(values, name),
433 Predicate::IdEq { value, .. }
434 | Predicate::IdNotEq { value, .. }
435 | Predicate::IdCompare { value, .. }
436 | Predicate::PropertyCompare { value, .. }
437 | Predicate::PropertyListContains { value, .. } => {
438 if value_contains_marker(value, name) {
439 MarkerUse::Unsafe
440 } else {
441 MarkerUse::None
442 }
443 }
444 Predicate::IdIn { values, .. } => {
445 if values
446 .iter()
447 .any(|value| value_contains_marker(value, name))
448 {
449 MarkerUse::Unsafe
450 } else {
451 MarkerUse::None
452 }
453 }
454 Predicate::ExpressionEq { expression, value }
455 | Predicate::ExpressionNotEq { expression, value }
456 | Predicate::ExpressionContains { expression, value } => {
457 marker_use_in_projection_expression(expression, name, false)
458 .combine(marker_use_in_projection_expression(value, name, false))
459 }
460 Predicate::ExpressionCompare {
461 expression, value, ..
462 } => marker_use_in_projection_expression(expression, name, false)
463 .combine(marker_use_in_projection_expression(value, name, false)),
464 _ => MarkerUse::None,
465 }
466}
467
468fn marker_use_in_projections(projections: &[Projection], name: &str, safe: bool) -> MarkerUse {
469 projections
470 .iter()
471 .fold(MarkerUse::None, |marker_use, projection| {
472 marker_use.combine(marker_use_in_projection_expression(
473 &projection.expression,
474 name,
475 safe,
476 ))
477 })
478}
479
480fn marker_use_in_sort_item(item: &SortItem, name: &str) -> MarkerUse {
481 match &item.key {
482 SortKey::Expression(expression) => {
483 marker_use_in_projection_expression(expression, name, false)
484 }
485 _ => MarkerUse::None,
486 }
487}
488
489fn marker_use_in_projection_expression(
490 expression: &ProjectionExpression,
491 name: &str,
492 safe: bool,
493) -> MarkerUse {
494 let marker_use = match expression {
495 ProjectionExpression::Case { .. }
496 | ProjectionExpression::Binary { .. }
497 | ProjectionExpression::Not(_)
498 | ProjectionExpression::IsNull { .. } => {
499 let mut result = MarkerUse::None;
500 expression.all_children(|child| {
501 result = result.combine(marker_use_in_projection_expression(child, name, safe));
502 true
503 });
504 result
505 }
506
507 ProjectionExpression::Literal(value) => values_marker_use([value], name),
508 ProjectionExpression::Coalesce(expressions) => {
509 expressions
510 .iter()
511 .fold(MarkerUse::None, |marker_use, expression| {
512 marker_use.combine(marker_use_in_projection_expression(expression, name, safe))
513 })
514 }
515 ProjectionExpression::Left { expression, .. } | ProjectionExpression::Lower(expression) => {
516 marker_use_in_projection_expression(expression, name, safe)
517 }
518 ProjectionExpression::DefaultIfNullOrEq { empty, default, .. }
519 | ProjectionExpression::ColumnDefaultIfNullOrEq { empty, default, .. } => {
520 values_marker_use([empty, default], name)
521 }
522 ProjectionExpression::DefaultIfNull { default, .. }
523 | ProjectionExpression::CaseLowerPropertyDefault { default, .. }
524 | ProjectionExpression::ColumnValueDefaultIfNull { default, .. } => {
525 values_marker_use([default], name)
526 }
527 ProjectionExpression::CasePropertyNotNullOrEq {
528 empty,
529 non_empty,
530 null_or_empty,
531 ..
532 }
533 | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq {
534 empty,
535 non_empty,
536 null_or_empty,
537 ..
538 } => values_marker_use([empty, non_empty, null_or_empty], name),
539 ProjectionExpression::CasePropertyEqualsRank {
540 branches, default, ..
541 } => values_marker_use(
542 branches
543 .iter()
544 .flat_map(|(left, right)| [left, right])
545 .chain([default]),
546 name,
547 ),
548 ProjectionExpression::CaseCoalesceDifferenceFloorZero { terms, .. } => {
549 values_marker_use(terms.iter().map(|term| &term.default), name)
550 }
551 ProjectionExpression::CaseEntitySearchRank(expression) => values_marker_use(
552 [
553 &expression.raw_query,
554 &expression.normalized_query,
555 &expression.raw_input,
556 &expression.exact_rank,
557 &expression.alias_rank,
558 &expression.fallback_rank,
559 ],
560 name,
561 ),
562 ProjectionExpression::CaseColumnSearchRank(expression) => values_marker_use(
563 [
564 &expression.raw_query,
565 &expression.normalized_query,
566 &expression.exact_rank,
567 &expression.contains_rank,
568 &expression.fallback_rank,
569 ],
570 name,
571 ),
572 _ => MarkerUse::None,
573 };
574 if !safe && marker_use == MarkerUse::Safe {
575 MarkerUse::Unsafe
576 } else {
577 marker_use
578 }
579}
580
581fn bind_physical_plan(plan: &mut PhysicalPlan, parameters: &BTreeMap<String, Value>) -> Result<()> {
582 match plan {
583 PhysicalPlan::GraphAlgorithm {
584 node_visibility_predicate,
585 ..
586 }
587 | PhysicalPlan::GraphSeedScan {
588 node_visibility_predicate,
589 ..
590 } => bind_optional_predicate(node_visibility_predicate, parameters)?,
591 PhysicalPlan::SourceSegmentScan { predicate, .. } => {
592 bind_predicate(predicate, parameters)?;
593 }
594 PhysicalPlan::FilterExec { predicate, input } => {
595 bind_predicate(predicate, parameters)?;
596 bind_physical_plan(input, parameters)?;
597 }
598 PhysicalPlan::NodeProjectionScanExec {
599 access,
600 predicate,
601 items,
602 ..
603 } => {
604 bind_node_projection_access(access, parameters)?;
605 bind_optional_predicate(predicate, parameters)?;
606 bind_projections(items, parameters)?;
607 }
608 PhysicalPlan::NodeCartesianProductExec { left, right }
609 | PhysicalPlan::HashJoinExec { left, right, .. } => {
610 bind_physical_plan(left, parameters)?;
611 bind_physical_plan(right, parameters)?;
612 }
613 PhysicalPlan::GraphMatchExec { program, input } => {
614 for step in &mut program.steps {
615 let node = match step {
616 GraphMatchStep::Node(node) => node,
617 GraphMatchStep::Expand {
618 target, properties, ..
619 } => {
620 bind_value_map(properties, parameters)?;
621 target
622 }
623 };
624 bind_value_map(&mut node.properties, parameters)?;
625 }
626 bind_optional_predicate(&mut program.predicate, parameters)?;
627 if let Some(input) = input {
628 bind_physical_plan(input, parameters)?;
629 }
630 }
631 PhysicalPlan::NodeColumnLookupExec {
632 input,
633 node_visibility_predicate,
634 ..
635 } => {
636 bind_optional_predicate(node_visibility_predicate, parameters)?;
637 bind_physical_plan(input, parameters)?;
638 }
639 PhysicalPlan::AdjacencyExistsExec { input, .. }
640 | PhysicalPlan::DistinctExec { input }
641 | PhysicalPlan::ScoringRerankExec { input, .. }
642 | PhysicalPlan::ScoringProgramExec { input, .. }
643 | PhysicalPlan::HostScoringExec { input, .. }
644 | PhysicalPlan::LimitExec { input, .. } => bind_physical_plan(input, parameters)?,
645 PhysicalPlan::IndexNodeSeek { value, .. } => bind_value(value, parameters)?,
646 PhysicalPlan::IndexNodeMultiSeek { values, .. } => {
647 bind_values(values, parameters)?;
648 }
649 PhysicalPlan::IndexNodeUnionSeek { branches, .. } => {
650 for branch in branches {
651 bind_values(&mut branch.values, parameters)?;
652 }
653 }
654 PhysicalPlan::IndexNodeCompositeSeek { predicates, .. } => {
655 for (_, value) in predicates {
656 bind_value(value, parameters)?;
657 }
658 }
659 PhysicalPlan::IndexNodeCompositeRangeSeek { seek, .. } => {
660 bind_composite_range_seek(seek, parameters)?;
661 }
662 PhysicalPlan::IndexNodeRangeSeek { lower, upper, .. } => {
663 for (value, _) in lower.iter_mut().chain(upper.iter_mut()) {
664 bind_value(value, parameters)?;
665 }
666 }
667 PhysicalPlan::AdjacencyExpandExec {
668 rel_properties,
669 input,
670 ..
671 } => {
672 bind_value_map(rel_properties, parameters)?;
673 bind_physical_plan(input, parameters)?;
674 }
675 PhysicalPlan::OptionalDegreeExec {
676 rel_properties,
677 target_properties,
678 input,
679 ..
680 } => {
681 bind_value_map(rel_properties, parameters)?;
682 bind_value_map(target_properties, parameters)?;
683 bind_physical_plan(input, parameters)?;
684 }
685 PhysicalPlan::OptionalRelationshipCountSumExec {
686 properties, legs, ..
687 } => {
688 bind_value_map(properties, parameters)?;
689 for leg in legs {
690 if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
691 &mut leg.filter
692 {
693 bind_value(value, parameters)?;
694 }
695 }
696 }
697 PhysicalPlan::ShortestPathExec {
698 source_id,
699 source_visibility_predicate,
700 target_id,
701 target_visibility_predicate,
702 ..
703 } => {
704 bind_value(source_id, parameters)?;
705 bind_optional_predicate(source_visibility_predicate, parameters)?;
706 bind_value(target_id, parameters)?;
707 bind_optional_predicate(target_visibility_predicate, parameters)?;
708 }
709 PhysicalPlan::ProjectExec { items, input } => {
710 bind_projections(items, parameters)?;
711 bind_physical_plan(input, parameters)?;
712 }
713 PhysicalPlan::AggregateExec {
714 group_keys, input, ..
715 } => {
716 bind_projections(group_keys, parameters)?;
717 bind_physical_plan(input, parameters)?;
718 }
719 PhysicalPlan::SortExec { items, input } | PhysicalPlan::TopNExec { items, input, .. } => {
720 bind_sort_items(items, parameters)?;
721 bind_physical_plan(input, parameters)?;
722 }
723 PhysicalPlan::CreateNodeLabel { .. }
724 | PhysicalPlan::CreateRelationshipType { .. }
725 | PhysicalPlan::CreateNodeTable { .. }
726 | PhysicalPlan::CreateRelationshipTable { .. }
727 | PhysicalPlan::CreateProperty { .. }
728 | PhysicalPlan::AlterTableState { .. }
729 | PhysicalPlan::AlterPropertyState { .. }
730 | PhysicalPlan::CreateIndex { .. }
731 | PhysicalPlan::CreateCompositeIndex { .. }
732 | PhysicalPlan::CreateRangeIndex { .. }
733 | PhysicalPlan::CreateFullTextIndex { .. }
734 | PhysicalPlan::CreateUniqueConstraint { .. }
735 | PhysicalPlan::CreateNodePropertyExistsConstraint { .. }
736 | PhysicalPlan::CreateRelationshipUniqueConstraint { .. }
737 | PhysicalPlan::CreateRelationshipPropertyExistsConstraint { .. }
738 | PhysicalPlan::ProjectGraph { .. }
739 | PhysicalPlan::VectorSeedScan { .. }
740 | PhysicalPlan::TextSeedScan { .. }
741 | PhysicalPlan::CreateNode { .. }
742 | PhysicalPlan::UnwindMutation { .. }
743 | PhysicalPlan::MergeNode { .. }
744 | PhysicalPlan::MergeRelationship { .. }
745 | PhysicalPlan::MergeMatchedRelationship { .. }
746 | PhysicalPlan::MergeRelationshipFromMatchedRelationship { .. }
747 | PhysicalPlan::MergeRelationshipToMatchedTarget { .. }
748 | PhysicalPlan::MergeRelationshipFromMatchedTarget { .. }
749 | PhysicalPlan::CreateMatchedRelationship { .. }
750 | PhysicalPlan::SetNodeProperty { .. }
751 | PhysicalPlan::SetNodeProperties { .. }
752 | PhysicalPlan::SetNodePropertiesReturn { .. }
753 | PhysicalPlan::SetRelationshipProperty { .. }
754 | PhysicalPlan::SetRelationshipProperties { .. }
755 | PhysicalPlan::DeleteNode { .. }
756 | PhysicalPlan::DeleteRelationship { .. }
757 | PhysicalPlan::DeleteRelationshipTargetNodes { .. }
758 | PhysicalPlan::CreateRelationship { .. }
759 | PhysicalPlan::EmptyExec
760 | PhysicalPlan::SeqNodeScan { .. }
761 | PhysicalPlan::IndexNodeTextSeek { .. }
762 | PhysicalPlan::NodeCountExec { .. }
763 | PhysicalPlan::RelationshipCountExec { .. }
764 | PhysicalPlan::ThreadRepairStatsExec { .. } => {}
765 }
766 Ok(())
767}
768
769fn bind_node_projection_access(
770 access: &mut hawdb_plan_cypher::NodeProjectionAccess,
771 parameters: &BTreeMap<String, Value>,
772) -> Result<()> {
773 match access {
774 hawdb_plan_cypher::NodeProjectionAccess::LabelScan => Ok(()),
775 hawdb_plan_cypher::NodeProjectionAccess::PropertyValues { values, .. } => {
776 bind_values(values, parameters)
777 }
778 hawdb_plan_cypher::NodeProjectionAccess::PropertyUnion { branches } => {
779 for branch in branches {
780 bind_values(&mut branch.values, parameters)?;
781 }
782 Ok(())
783 }
784 hawdb_plan_cypher::NodeProjectionAccess::CompositeEquality { predicates } => {
785 for (_, value) in predicates {
786 bind_value(value, parameters)?;
787 }
788 Ok(())
789 }
790 hawdb_plan_cypher::NodeProjectionAccess::CompositeRange { seek } => {
791 bind_composite_range_seek(seek, parameters)
792 }
793 hawdb_plan_cypher::NodeProjectionAccess::PropertyRange { lower, upper, .. } => {
794 for (value, _) in lower.iter_mut().chain(upper.iter_mut()) {
795 bind_value(value, parameters)?;
796 }
797 Ok(())
798 }
799 hawdb_plan_cypher::NodeProjectionAccess::FullText { .. } => Ok(()),
800 }
801}
802
803fn bind_composite_range_seek(
804 seek: &mut hawdb_plan_cypher::CompositeRangeSeek,
805 parameters: &BTreeMap<String, Value>,
806) -> Result<()> {
807 for (_, value) in &mut seek.equality_prefix {
808 bind_value(value, parameters)?;
809 }
810 for (value, _) in seek.lower.iter_mut().chain(seek.upper.iter_mut()) {
811 bind_value(value, parameters)?;
812 }
813 Ok(())
814}
815
816fn bind_logical_plan(plan: &mut LogicalPlan, parameters: &BTreeMap<String, Value>) -> Result<()> {
817 match plan {
818 LogicalPlan::GraphAlgorithm {
819 node_visibility_predicate,
820 ..
821 }
822 | LogicalPlan::GraphSeed {
823 node_visibility_predicate,
824 ..
825 } => bind_optional_predicate(node_visibility_predicate, parameters)?,
826 LogicalPlan::NodeCartesianProduct { left, right } => {
827 bind_logical_plan(left, parameters)?;
828 bind_logical_plan(right, parameters)?;
829 }
830 LogicalPlan::NodeColumnLookup {
831 input,
832 node_visibility_predicate,
833 ..
834 } => {
835 bind_optional_predicate(node_visibility_predicate, parameters)?;
836 bind_logical_plan(input, parameters)?;
837 }
838 LogicalPlan::Distinct { input } | LogicalPlan::Limit { input, .. } => {
839 bind_logical_plan(input, parameters)?
840 }
841 LogicalPlan::Expand {
842 rel_properties,
843 input,
844 ..
845 } => {
846 bind_value_map(rel_properties, parameters)?;
847 bind_logical_plan(input, parameters)?;
848 }
849 LogicalPlan::OptionalDegree {
850 rel_properties,
851 target_properties,
852 input,
853 ..
854 } => {
855 bind_value_map(rel_properties, parameters)?;
856 bind_value_map(target_properties, parameters)?;
857 bind_logical_plan(input, parameters)?;
858 }
859 LogicalPlan::OptionalRelationshipCountSum {
860 properties, legs, ..
861 } => {
862 bind_value_map(properties, parameters)?;
863 for leg in legs {
864 if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
865 &mut leg.filter
866 {
867 bind_value(value, parameters)?;
868 }
869 }
870 }
871 LogicalPlan::ShortestPath {
872 source_id,
873 source_visibility_predicate,
874 target_id,
875 target_visibility_predicate,
876 ..
877 } => {
878 bind_value(source_id, parameters)?;
879 bind_optional_predicate(source_visibility_predicate, parameters)?;
880 bind_value(target_id, parameters)?;
881 bind_optional_predicate(target_visibility_predicate, parameters)?;
882 }
883 LogicalPlan::Filter {
884 predicate, input, ..
885 } => {
886 bind_predicate(predicate, parameters)?;
887 bind_logical_plan(input, parameters)?;
888 }
889 LogicalPlan::Project { items, input } => {
890 bind_projections(items, parameters)?;
891 bind_logical_plan(input, parameters)?;
892 }
893 LogicalPlan::Aggregate {
894 group_keys, input, ..
895 } => {
896 bind_projections(group_keys, parameters)?;
897 bind_logical_plan(input, parameters)?;
898 }
899 LogicalPlan::Sort { items, input } => {
900 bind_sort_items(items, parameters)?;
901 bind_logical_plan(input, parameters)?;
902 }
903 _ => {}
904 }
905 Ok(())
906}
907
908fn bind_optional_predicate(
909 predicate: &mut Option<Predicate>,
910 parameters: &BTreeMap<String, Value>,
911) -> Result<()> {
912 if let Some(predicate) = predicate {
913 bind_predicate(predicate, parameters)?;
914 }
915 Ok(())
916}
917
918fn bind_predicate(predicate: &mut Predicate, parameters: &BTreeMap<String, Value>) -> Result<()> {
919 match predicate {
920 Predicate::And(predicates) | Predicate::Or(predicates) => {
921 for predicate in predicates {
922 bind_predicate(predicate, parameters)?;
923 }
924 }
925 Predicate::Not(predicate) => bind_predicate(predicate, parameters)?,
926 Predicate::IdEq { value, .. }
927 | Predicate::IdNotEq { value, .. }
928 | Predicate::IdCompare { value, .. }
929 | Predicate::PropertyEq { value, .. }
930 | Predicate::PropertyNotEq { value, .. }
931 | Predicate::PropertyCompare { value, .. }
932 | Predicate::PropertyListContains { value, .. } => bind_value(value, parameters)?,
933 Predicate::IdIn { values, .. } | Predicate::PropertyIn { values, .. } => {
934 bind_values(values, parameters)?;
935 }
936 Predicate::ExpressionEq { expression, value }
937 | Predicate::ExpressionNotEq { expression, value }
938 | Predicate::ExpressionContains { expression, value } => {
939 bind_projection_expression(expression, parameters)?;
940 bind_projection_expression(value, parameters)?;
941 }
942 Predicate::ExpressionCompare {
943 expression, value, ..
944 } => {
945 bind_projection_expression(expression, parameters)?;
946 bind_projection_expression(value, parameters)?;
947 }
948 _ => {}
949 }
950 Ok(())
951}
952
953fn bind_projections(
954 projections: &mut [Projection],
955 parameters: &BTreeMap<String, Value>,
956) -> Result<()> {
957 for projection in projections {
958 bind_projection_expression(&mut projection.expression, parameters)?;
959 }
960 Ok(())
961}
962
963fn bind_sort_items(items: &mut [SortItem], parameters: &BTreeMap<String, Value>) -> Result<()> {
964 for item in items {
965 if let SortKey::Expression(expression) = &mut item.key {
966 bind_projection_expression(expression, parameters)?;
967 }
968 }
969 Ok(())
970}
971
972fn bind_projection_expression(
973 expression: &mut ProjectionExpression,
974 parameters: &BTreeMap<String, Value>,
975) -> Result<()> {
976 match expression {
977 ProjectionExpression::Case { .. }
978 | ProjectionExpression::Binary { .. }
979 | ProjectionExpression::Not(_)
980 | ProjectionExpression::IsNull { .. } => expression
981 .try_for_each_child_mut(|child| bind_projection_expression(child, parameters))?,
982
983 ProjectionExpression::Literal(value) => bind_value(value, parameters)?,
984 ProjectionExpression::Coalesce(expressions) => {
985 for expression in expressions {
986 bind_projection_expression(expression, parameters)?;
987 }
988 }
989 ProjectionExpression::Left { expression, .. } | ProjectionExpression::Lower(expression) => {
990 bind_projection_expression(expression, parameters)?;
991 }
992 ProjectionExpression::DefaultIfNullOrEq { empty, default, .. }
993 | ProjectionExpression::ColumnDefaultIfNullOrEq { empty, default, .. } => {
994 bind_value(empty, parameters)?;
995 bind_value(default, parameters)?;
996 }
997 ProjectionExpression::DefaultIfNull { default, .. }
998 | ProjectionExpression::CaseLowerPropertyDefault { default, .. }
999 | ProjectionExpression::ColumnValueDefaultIfNull { default, .. } => {
1000 bind_value(default, parameters)?;
1001 }
1002 ProjectionExpression::CasePropertyNotNullOrEq {
1003 empty,
1004 non_empty,
1005 null_or_empty,
1006 ..
1007 }
1008 | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq {
1009 empty,
1010 non_empty,
1011 null_or_empty,
1012 ..
1013 } => {
1014 bind_value(empty, parameters)?;
1015 bind_value(non_empty, parameters)?;
1016 bind_value(null_or_empty, parameters)?;
1017 }
1018 ProjectionExpression::CasePropertyEqualsRank {
1019 branches, default, ..
1020 } => {
1021 for (left, right) in branches {
1022 bind_value(left, parameters)?;
1023 bind_value(right, parameters)?;
1024 }
1025 bind_value(default, parameters)?;
1026 }
1027 ProjectionExpression::CaseCoalesceDifferenceFloorZero { terms, .. } => {
1028 for term in terms {
1029 bind_value(&mut term.default, parameters)?;
1030 }
1031 }
1032 ProjectionExpression::CaseEntitySearchRank(expression) => {
1033 for value in [
1034 &mut expression.raw_query,
1035 &mut expression.normalized_query,
1036 &mut expression.raw_input,
1037 &mut expression.exact_rank,
1038 &mut expression.alias_rank,
1039 &mut expression.fallback_rank,
1040 ] {
1041 bind_value(value, parameters)?;
1042 }
1043 }
1044 ProjectionExpression::CaseColumnSearchRank(expression) => {
1045 for value in [
1046 &mut expression.raw_query,
1047 &mut expression.normalized_query,
1048 &mut expression.exact_rank,
1049 &mut expression.contains_rank,
1050 &mut expression.fallback_rank,
1051 ] {
1052 bind_value(value, parameters)?;
1053 }
1054 }
1055 _ => {}
1056 }
1057 Ok(())
1058}
1059
1060fn bind_value_map(
1061 values: &mut BTreeMap<String, Value>,
1062 parameters: &BTreeMap<String, Value>,
1063) -> Result<()> {
1064 for value in values.values_mut() {
1065 bind_value(value, parameters)?;
1066 }
1067 Ok(())
1068}
1069
1070fn bind_values(values: &mut [Value], parameters: &BTreeMap<String, Value>) -> Result<()> {
1071 for value in values {
1072 bind_value(value, parameters)?;
1073 }
1074 Ok(())
1075}
1076
1077fn resolve_parameter_marker<'a>(
1078 parameters: &'a BTreeMap<String, Value>,
1079 name: &str,
1080 path: Vec<usize>,
1081) -> Result<&'a Value> {
1082 let mut bound = parameters
1083 .get(name)
1084 .ok_or_else(|| HawDBError::Semantic(format!("missing parameter '${name}'")))?;
1085 for index in path {
1086 let Value::List(values) = bound else {
1087 return Err(HawDBError::Semantic(format!(
1088 "parameter '${name}' changed shape while binding a cached plan"
1089 )));
1090 };
1091 bound = values.get(index).ok_or_else(|| {
1092 HawDBError::Semantic(format!(
1093 "parameter '${name}' changed shape while binding a cached plan"
1094 ))
1095 })?;
1096 }
1097 Ok(bound)
1098}
1099
1100fn bind_value(value: &mut Value, parameters: &BTreeMap<String, Value>) -> Result<()> {
1101 if let Some((name, path)) = marker_name_and_path(value) {
1102 *value = resolve_parameter_marker(parameters, name, path)?.clone();
1103 return Ok(());
1104 }
1105 match value {
1106 Value::List(values) => bind_values(values, parameters),
1107 Value::Map(values) => bind_value_map(values, parameters),
1108 _ => Ok(()),
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::{
1115 bind_physical_plan_parameters, marker_use_in_logical_plan, parameter_marker,
1116 parameterize_logical_plan, resolve_plan_template_value, MarkerUse, ParameterCacheValue,
1117 PARAMETER_SLOT_NAME_KEY, PARAMETER_SLOT_PATH_KEY,
1118 };
1119 use hawdb_core::Value;
1120 use hawdb_cypher as cypher;
1121 use hawdb_optimizer_graph::{CascadesOptimizer, OptimizerCatalog};
1122 use hawdb_plan_cypher::{
1123 CompositeRangeSeek, ExactPropertySeekBranch, LogicalPlanRoot, NodeProjectionAccess,
1124 PhysicalPlan,
1125 };
1126 use std::collections::BTreeMap;
1127
1128 #[test]
1129 fn cost_operand_resolution_borrows_values_and_preserves_literal_markers() {
1130 let parameters = BTreeMap::from([("id".into(), Value::String("point".into()))]);
1131 let marker = parameter_marker("id", ¶meters["id"], &[]);
1132 let resolved = resolve_plan_template_value(&marker, ¶meters, true).unwrap();
1133 assert!(std::ptr::eq(resolved, ¶meters["id"]));
1134 assert!(std::ptr::eq(
1135 resolve_plan_template_value(&marker, &BTreeMap::new(), false).unwrap(),
1136 &marker
1137 ));
1138 assert!(resolve_plan_template_value(&marker, &BTreeMap::new(), true).is_err());
1139 let parameters = BTreeMap::from([("ids".into(), Value::List(vec![Value::Int(7)]))]);
1140 let Value::List(markers) = parameter_marker("ids", ¶meters["ids"], &[]) else {
1141 panic!("expected list markers")
1142 };
1143 let resolved = resolve_plan_template_value(&markers[0], ¶meters, true).unwrap();
1144 let Value::List(values) = ¶meters["ids"] else {
1145 panic!("expected list operand")
1146 };
1147 assert!(std::ptr::eq(resolved, &values[0]));
1148 assert!(resolve_plan_template_value(
1149 &markers[0],
1150 &BTreeMap::from([("ids".into(), Value::Int(7))]),
1151 true
1152 )
1153 .is_err());
1154 }
1155
1156 #[test]
1157 fn general_case_preserves_exact_cache_keys_and_rebinds_explicit_template_slots() {
1158 let statement = cypher::parse("MATCH (n:Item) RETURN CASE WHEN n.id = $chosen THEN lower($label) ELSE $fallback END AS result").unwrap();
1159 let parameters = BTreeMap::from([
1160 ("chosen".into(), Value::Int(1)),
1161 ("label".into(), Value::String("FIRST".into())),
1162 ("fallback".into(), Value::String("old".into())),
1163 ]);
1164 let parameterized = parameterize_logical_plan(&statement, ¶meters).unwrap();
1165 assert_eq!(parameterized.slot_count, 0);
1166 assert!(parameterized
1167 .cache_key
1168 .values
1169 .values()
1170 .all(|value| matches!(value, ParameterCacheValue::Exact(_))));
1171 let markers = parameters
1174 .iter()
1175 .map(|(name, value)| (name.clone(), parameter_marker(name, value, &[])))
1176 .collect();
1177 let logical = hawdb_plan_cypher::plan_with_params(&statement, &markers).unwrap();
1178 let root = CascadesOptimizer::default().optimize_root_with_catalog(
1179 &LogicalPlanRoot::new(logical),
1180 &OptimizerCatalog::default(),
1181 );
1182 let (template, _) = root.into_parts();
1183 let parameters = BTreeMap::from([
1184 ("chosen".into(), Value::Int(2)),
1185 ("label".into(), Value::String("SECOND".into())),
1186 ("fallback".into(), Value::String("new".into())),
1187 ]);
1188 let rebound = bind_physical_plan_parameters(&template, ¶meters, true).unwrap();
1189 let text = format!("{rebound:?}");
1190 assert!(text.contains("SECOND") && text.contains("new"), "{text}");
1191 assert!(!text.contains("hawdb_parameter_slot"), "{text}");
1192 }
1193
1194 #[test]
1195 fn clause_match_rebinds_every_step_predicate_and_input() {
1196 let query = "MATCH (a:Item {id: $a}) OPTIONAL MATCH (a)-[r:LINK {code: $code}]->(b:Item {id: $b}) WHERE b.visible = $visible RETURN b";
1197 let slots = ["a", "b", "code", "visible"];
1198 let parameters = slots
1199 .into_iter()
1200 .map(|name| {
1201 (
1202 name.to_string(),
1203 parameter_marker(name, &Value::Int(0), &[]),
1204 )
1205 })
1206 .collect();
1207 let logical = hawdb_plan_cypher::plan_pipeline_query(query, ¶meters).unwrap();
1208 for name in slots {
1209 assert_eq!(
1210 marker_use_in_logical_plan(&logical, name),
1211 MarkerUse::Safe,
1212 "{name}"
1213 );
1214 }
1215 let optimize = |logical| {
1216 CascadesOptimizer::default()
1217 .optimize_root_with_catalog(
1218 &LogicalPlanRoot::new(logical),
1219 &OptimizerCatalog::default(),
1220 )
1221 .into_parts()
1222 .0
1223 };
1224 let template = optimize(logical);
1225 let original = template.instance_fingerprint();
1226 for base in [10, 20] {
1227 let parameters = slots
1228 .into_iter()
1229 .enumerate()
1230 .map(|(index, name)| (name.to_string(), Value::Int(base + index as i64)))
1231 .collect();
1232 let rebound = bind_physical_plan_parameters(&template, ¶meters, true).unwrap();
1233 let fresh =
1234 optimize(hawdb_plan_cypher::plan_pipeline_query(query, ¶meters).unwrap());
1235 assert_eq!(rebound.instance_fingerprint(), fresh.instance_fingerprint());
1236 assert!(!rebound
1237 .instance_fingerprint()
1238 .contains("hawdb_parameter_slot"));
1239 }
1240 assert_eq!(template.instance_fingerprint(), original);
1241 assert!(bind_physical_plan_parameters(&template, &BTreeMap::new(), true).is_err());
1242 }
1243
1244 #[test]
1245 fn equality_parameters_become_rebindable_slots() {
1246 let statement =
1247 cypher::parse("MATCH (m:Memory) WHERE m.id = $id RETURN m.title AS title").unwrap();
1248 let first_parameters =
1249 BTreeMap::from([("id".to_string(), Value::String("first".to_string()))]);
1250 let parameterized = parameterize_logical_plan(&statement, &first_parameters).unwrap();
1251 assert!(matches!(
1252 parameterized.cache_key.values.get("id"),
1253 Some(ParameterCacheValue::Slot(_))
1254 ));
1255
1256 let root = CascadesOptimizer::default().optimize_root_with_catalog(
1257 &LogicalPlanRoot::new(parameterized.logical),
1258 &OptimizerCatalog::default(),
1259 );
1260 let (template, _) = root.into_parts();
1261 let rebound = bind_physical_plan_parameters(
1262 &template,
1263 &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1264 true,
1265 )
1266 .unwrap();
1267
1268 assert!(rebound.explain(0).contains("second"));
1269 assert!(!rebound.explain(0).contains("hawdb_parameter_slot"));
1270 }
1271
1272 #[test]
1273 fn scoring_rerank_rebinds_its_input_and_rejects_missing_parameter_slots() {
1274 let spec = hawdb_core::graph_rag::ScoringSpec::weighted_scores(2.0, 1.0);
1275 let template = PhysicalPlan::ScoringRerankExec {
1276 score_column: "seed_score".into(),
1277 spec: spec.clone(),
1278 limit: 3,
1279 input: Box::new(PhysicalPlan::IndexNodeSeek {
1280 variable: "n".into(),
1281 label: "Item".into(),
1282 property: "scope".into(),
1283 value: parameter_marker("scope", &Value::String("template".into()), &[]),
1284 }),
1285 };
1286 for scope in ["first", "second"] {
1287 let rebound = bind_physical_plan_parameters(
1288 &template,
1289 &BTreeMap::from([("scope".into(), Value::String(scope.into()))]),
1290 true,
1291 )
1292 .unwrap();
1293 let PhysicalPlan::ScoringRerankExec {
1294 score_column,
1295 spec: rebound_spec,
1296 limit,
1297 input,
1298 } = rebound
1299 else {
1300 panic!("scoring operator must be preserved");
1301 };
1302 assert_eq!(score_column, "seed_score");
1303 assert_eq!(rebound_spec, spec);
1304 assert_eq!(limit, 3);
1305 let PhysicalPlan::IndexNodeSeek { value, .. } = *input else {
1306 panic!("scoring input seek must be preserved");
1307 };
1308 assert_eq!(
1309 value,
1310 Value::String(scope.into()),
1311 "scoring input must use current request values"
1312 );
1313 }
1314 assert!(bind_physical_plan_parameters(&template, &BTreeMap::new(), true).is_err());
1315 let PhysicalPlan::ScoringRerankExec { input, .. } = &template else {
1316 unreachable!()
1317 };
1318 let PhysicalPlan::IndexNodeSeek { value, .. } = input.as_ref() else {
1319 unreachable!()
1320 };
1321 assert_eq!(
1322 *value,
1323 parameter_marker("scope", &Value::String("template".into()), &[]),
1324 "binding must not mutate the retained template"
1325 );
1326 }
1327
1328 #[test]
1329 fn adjacency_exists_rebinds_its_input_and_rejects_missing_parameter_slots() {
1330 let marker = parameter_marker("scope", &Value::String("template".into()), &[]);
1331 let template = PhysicalPlan::AdjacencyExistsExec {
1332 source_variable: "n".into(),
1333 rel_type: "REL".into(),
1334 direction: cypher::RelationshipDirection::Outgoing,
1335 target_variable: "s".into(),
1336 input: Box::new(PhysicalPlan::IndexNodeSeek {
1337 variable: "n".into(),
1338 label: "Item".into(),
1339 property: "scope".into(),
1340 value: marker.clone(),
1341 }),
1342 };
1343 let original = template.clone();
1344 for scope in ["first", "second"] {
1345 let rebound = bind_physical_plan_parameters(
1346 &template,
1347 &BTreeMap::from([("scope".into(), Value::String(scope.into()))]),
1348 true,
1349 )
1350 .unwrap();
1351 let PhysicalPlan::AdjacencyExistsExec {
1352 source_variable,
1353 rel_type,
1354 direction,
1355 target_variable,
1356 input,
1357 } = rebound
1358 else {
1359 panic!("EXISTS operator must be preserved");
1360 };
1361 assert_eq!(source_variable, "n");
1362 assert_eq!(rel_type, "REL");
1363 assert_eq!(direction, cypher::RelationshipDirection::Outgoing);
1364 assert_eq!(target_variable, "s");
1365 let PhysicalPlan::IndexNodeSeek { value, .. } = *input else {
1366 panic!("EXISTS input seek must be preserved");
1367 };
1368 assert_eq!(value, Value::String(scope.into()));
1369 }
1370 assert!(bind_physical_plan_parameters(&template, &BTreeMap::new(), true).is_err());
1371 assert_eq!(
1372 template, original,
1373 "binding must preserve the cached template"
1374 );
1375 }
1376
1377 #[test]
1378 fn graph_hash_join_rebinds_both_inputs_without_changing_keys() {
1379 let seek = |variable: &str, parameter: &str| PhysicalPlan::IndexNodeSeek {
1380 variable: variable.into(),
1381 label: "Item".into(),
1382 property: "scope".into(),
1383 value: parameter_marker(parameter, &Value::Int(0), &[]),
1384 };
1385 let template = PhysicalPlan::HashJoinExec {
1386 left_key: hawdb_plan_cypher::HashJoinKey {
1387 variable: "a".into(),
1388 property: "key".into(),
1389 },
1390 right_key: hawdb_plan_cypher::HashJoinKey {
1391 variable: "b".into(),
1392 property: "key".into(),
1393 },
1394 left: Box::new(seek("a", "left_scope")),
1395 right: Box::new(seek("b", "right_scope")),
1396 };
1397 for (a, b) in [(1, 2), (3, 4)] {
1398 let bound = bind_physical_plan_parameters(
1399 &template,
1400 &BTreeMap::from([
1401 ("left_scope".into(), Value::Int(a)),
1402 ("right_scope".into(), Value::Int(b)),
1403 ]),
1404 true,
1405 )
1406 .unwrap();
1407 let PhysicalPlan::HashJoinExec {
1408 left_key,
1409 right_key,
1410 left,
1411 right,
1412 } = bound
1413 else {
1414 unreachable!()
1415 };
1416 assert_eq!(left_key.variable, "a");
1417 assert_eq!(right_key.variable, "b");
1418 assert!(
1419 matches!(*left, PhysicalPlan::IndexNodeSeek { value: Value::Int(value), .. } if value == a)
1420 );
1421 assert!(
1422 matches!(*right, PhysicalPlan::IndexNodeSeek { value: Value::Int(value), .. } if value == b)
1423 );
1424 }
1425 }
1426
1427 #[test]
1428 fn projected_index_access_rebinds_parameter_slots() {
1429 let template = PhysicalPlan::NodeProjectionScanExec {
1430 variable: "m".to_string(),
1431 label: "Memory".to_string(),
1432 access: NodeProjectionAccess::PropertyValues {
1433 property: "stable_id".to_string(),
1434 values: vec![parameter_marker(
1435 "id",
1436 &Value::String("first".to_string()),
1437 &[],
1438 )],
1439 },
1440 required_properties: vec!["stable_id".to_string()],
1441 predicate: None,
1442 items: Vec::new(),
1443 };
1444
1445 let rebound = bind_physical_plan_parameters(
1446 &template,
1447 &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1448 true,
1449 )
1450 .unwrap();
1451
1452 assert!(matches!(
1453 rebound,
1454 PhysicalPlan::NodeProjectionScanExec {
1455 access: NodeProjectionAccess::PropertyValues { values, .. },
1456 ..
1457 } if values == vec![Value::String("second".to_string())]
1458 ));
1459 }
1460
1461 #[test]
1462 fn projected_union_access_rebinds_every_branch() {
1463 let marker = || parameter_marker("id", &Value::String("first".to_string()), &[]);
1464 let template = PhysicalPlan::NodeProjectionScanExec {
1465 variable: "m".to_string(),
1466 label: "Memory".to_string(),
1467 access: NodeProjectionAccess::PropertyUnion {
1468 branches: vec![
1469 ExactPropertySeekBranch {
1470 property: "id".to_string(),
1471 values: vec![marker()],
1472 },
1473 ExactPropertySeekBranch {
1474 property: "external_id".to_string(),
1475 values: vec![marker()],
1476 },
1477 ],
1478 },
1479 required_properties: vec!["id".to_string(), "external_id".to_string()],
1480 predicate: None,
1481 items: Vec::new(),
1482 };
1483
1484 let rebound = bind_physical_plan_parameters(
1485 &template,
1486 &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1487 true,
1488 )
1489 .unwrap();
1490
1491 let PhysicalPlan::NodeProjectionScanExec {
1492 access: NodeProjectionAccess::PropertyUnion { branches },
1493 ..
1494 } = rebound
1495 else {
1496 panic!("expected projected union access");
1497 };
1498 assert!(branches
1499 .iter()
1500 .all(|branch| { branch.values == vec![Value::String("second".to_string())] }));
1501 }
1502
1503 #[test]
1504 fn projected_composite_range_rebinds_prefix_and_bounds() {
1505 let template = PhysicalPlan::NodeProjectionScanExec {
1506 variable: "m".to_string(),
1507 label: "Memory".to_string(),
1508 access: NodeProjectionAccess::CompositeRange {
1509 seek: CompositeRangeSeek {
1510 index_properties: vec!["space_id".to_string(), "created_at".to_string()],
1511 equality_prefix: vec![(
1512 "space_id".to_string(),
1513 parameter_marker(
1514 "space_id",
1515 &Value::String("space:first".to_string()),
1516 &[],
1517 ),
1518 )],
1519 range_property: "created_at".to_string(),
1520 lower: Some((parameter_marker("lower", &Value::Int(10), &[]), true)),
1521 upper: Some((parameter_marker("upper", &Value::Int(20), &[]), false)),
1522 },
1523 },
1524 required_properties: vec!["space_id".to_string(), "created_at".to_string()],
1525 predicate: None,
1526 items: Vec::new(),
1527 };
1528
1529 let rebound = bind_physical_plan_parameters(
1530 &template,
1531 &BTreeMap::from([
1532 (
1533 "space_id".to_string(),
1534 Value::String("space:second".to_string()),
1535 ),
1536 ("lower".to_string(), Value::Int(30)),
1537 ("upper".to_string(), Value::Int(40)),
1538 ]),
1539 true,
1540 )
1541 .unwrap();
1542
1543 assert!(matches!(
1544 rebound,
1545 PhysicalPlan::NodeProjectionScanExec {
1546 access: NodeProjectionAccess::CompositeRange { seek },
1547 ..
1548 } if seek.equality_prefix
1549 == vec![(
1550 "space_id".to_string(),
1551 Value::String("space:second".to_string()),
1552 )]
1553 && seek.lower == Some((Value::Int(30), true))
1554 && seek.upper == Some((Value::Int(40), false))
1555 ));
1556 }
1557
1558 #[test]
1559 fn pagination_parameters_remain_exact_variants() {
1560 let statement =
1561 cypher::parse("MATCH (m:Memory) RETURN m.id AS id ORDER BY m.id LIMIT $limit").unwrap();
1562 let parameterized = parameterize_logical_plan(
1563 &statement,
1564 &BTreeMap::from([("limit".to_string(), Value::Int(10))]),
1565 )
1566 .unwrap();
1567
1568 assert_eq!(
1569 parameterized.cache_key.values.get("limit"),
1570 Some(&ParameterCacheValue::Exact(Value::Int(10)))
1571 );
1572 }
1573
1574 #[test]
1575 fn parameters_that_change_logical_simplification_remain_exact_variants() {
1576 let statement = cypher::parse(
1577 "MATCH (t:Thread) WHERE $source IS NULL OR t.source = $source RETURN t.id AS id",
1578 )
1579 .unwrap();
1580 let parameterized = parameterize_logical_plan(
1581 &statement,
1582 &BTreeMap::from([("source".to_string(), Value::Null)]),
1583 )
1584 .unwrap();
1585
1586 assert_eq!(
1587 parameterized.cache_key.values.get("source"),
1588 Some(&ParameterCacheValue::Exact(Value::Null))
1589 );
1590 }
1591
1592 #[test]
1593 fn multiple_equality_parameters_share_one_rebindable_template() {
1594 let statement =
1595 cypher::parse("MATCH (m:Memory) WHERE m.id = $id AND m.kind = $kind RETURN m.id AS id")
1596 .unwrap();
1597 let parameterized = parameterize_logical_plan(
1598 &statement,
1599 &BTreeMap::from([
1600 ("id".to_string(), Value::String("memory-1".to_string())),
1601 ("kind".to_string(), Value::String("note".to_string())),
1602 ]),
1603 )
1604 .unwrap();
1605
1606 assert_eq!(parameterized.slot_count, 2);
1607 assert!(parameterized
1608 .cache_key
1609 .values
1610 .values()
1611 .all(|cached| matches!(cached, ParameterCacheValue::Slot(_))));
1612 }
1613
1614 #[test]
1615 fn marker_shaped_parameter_values_disable_slots() {
1616 let statement =
1617 cypher::parse("MATCH (m:Memory) WHERE m.metadata = $metadata RETURN m.id AS id")
1618 .unwrap();
1619 let marker_shaped = Value::Map(BTreeMap::from([
1620 (
1621 PARAMETER_SLOT_NAME_KEY.to_string(),
1622 Value::String("other".to_string()),
1623 ),
1624 (PARAMETER_SLOT_PATH_KEY.to_string(), Value::List(Vec::new())),
1625 ]));
1626 let parameterized = parameterize_logical_plan(
1627 &statement,
1628 &BTreeMap::from([("metadata".to_string(), marker_shaped.clone())]),
1629 )
1630 .unwrap();
1631
1632 assert_eq!(parameterized.slot_count, 0);
1633 assert_eq!(
1634 parameterized.cache_key.values.get("metadata"),
1635 Some(&ParameterCacheValue::Exact(marker_shaped))
1636 );
1637 }
1638}