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