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