Skip to main content

hawdb_plan_cache/
template.rs

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