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