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