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