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 { input, .. }
314        | LogicalPlan::Distinct { input }
315        | LogicalPlan::Limit { input, .. } => marker_use_in_logical_plan(input, name),
316        LogicalPlan::Expand {
317            rel_properties,
318            input,
319            ..
320        } => values_marker_use(rel_properties.values(), name)
321            .combine(marker_use_in_logical_plan(input, name)),
322        LogicalPlan::OptionalDegree {
323            rel_properties,
324            target_properties,
325            input,
326            ..
327        } => values_marker_use(rel_properties.values(), name)
328            .combine(values_marker_use(target_properties.values(), name))
329            .combine(marker_use_in_logical_plan(input, name)),
330        LogicalPlan::OptionalRelationshipCountSum {
331            properties, legs, ..
332        } => {
333            let mut marker_use = values_marker_use(properties.values(), name);
334            for leg in legs {
335                if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
336                    &leg.filter
337                {
338                    marker_use = marker_use.combine(if value_contains_marker(value, name) {
339                        MarkerUse::Safe
340                    } else {
341                        MarkerUse::None
342                    });
343                }
344            }
345            marker_use
346        }
347        LogicalPlan::Filter {
348            predicate, input, ..
349        } => marker_use_in_predicate(predicate, name)
350            .combine(marker_use_in_logical_plan(input, name)),
351        LogicalPlan::Project { items, input } => marker_use_in_projections(items, name, false)
352            .combine(marker_use_in_logical_plan(input, name)),
353        LogicalPlan::Aggregate {
354            group_keys, input, ..
355        } => marker_use_in_projections(group_keys, name, false)
356            .combine(marker_use_in_logical_plan(input, name)),
357        LogicalPlan::Sort { items, input } => {
358            let marker_use = items.iter().fold(MarkerUse::None, |marker_use, item| {
359                marker_use.combine(marker_use_in_sort_item(item, name))
360            });
361            marker_use.combine(marker_use_in_logical_plan(input, name))
362        }
363        LogicalPlan::ShortestPath {
364            source_id,
365            source_visibility_predicate,
366            target_id,
367            target_visibility_predicate,
368            ..
369        } => {
370            let mut marker_use = values_marker_use([source_id, target_id], name);
371            if marker_use == MarkerUse::Safe {
372                marker_use = MarkerUse::Unsafe;
373            }
374            for predicate in [source_visibility_predicate, target_visibility_predicate]
375                .into_iter()
376                .flatten()
377            {
378                marker_use = marker_use.combine(marker_use_in_predicate(predicate, name));
379            }
380            marker_use
381        }
382        LogicalPlan::GraphAlgorithm {
383            node_visibility_predicate,
384            ..
385        } => node_visibility_predicate
386            .as_ref()
387            .map(|predicate| marker_use_in_predicate(predicate, name))
388            .unwrap_or(MarkerUse::None),
389        _ => MarkerUse::None,
390    }
391}
392
393fn marker_use_in_predicate(predicate: &Predicate, name: &str) -> MarkerUse {
394    match predicate {
395        Predicate::And(predicates) | Predicate::Or(predicates) => predicates
396            .iter()
397            .fold(MarkerUse::None, |marker_use, predicate| {
398                marker_use.combine(marker_use_in_predicate(predicate, name))
399            }),
400        Predicate::Not(predicate) => marker_use_in_predicate(predicate, name),
401        Predicate::PropertyEq { value, .. } | Predicate::PropertyNotEq { value, .. } => {
402            values_marker_use([value], name)
403        }
404        Predicate::PropertyIn { values, .. } => values_marker_use(values, name),
405        Predicate::IdEq { value, .. }
406        | Predicate::IdNotEq { value, .. }
407        | Predicate::IdCompare { value, .. }
408        | Predicate::PropertyCompare { value, .. }
409        | Predicate::PropertyListContains { value, .. } => {
410            if value_contains_marker(value, name) {
411                MarkerUse::Unsafe
412            } else {
413                MarkerUse::None
414            }
415        }
416        Predicate::IdIn { values, .. } => {
417            if values
418                .iter()
419                .any(|value| value_contains_marker(value, name))
420            {
421                MarkerUse::Unsafe
422            } else {
423                MarkerUse::None
424            }
425        }
426        Predicate::ExpressionEq { expression, value }
427        | Predicate::ExpressionNotEq { expression, value }
428        | Predicate::ExpressionContains { expression, value } => {
429            marker_use_in_projection_expression(expression, name, false)
430                .combine(marker_use_in_projection_expression(value, name, false))
431        }
432        Predicate::ExpressionCompare {
433            expression, value, ..
434        } => marker_use_in_projection_expression(expression, name, false)
435            .combine(marker_use_in_projection_expression(value, name, false)),
436        _ => MarkerUse::None,
437    }
438}
439
440fn marker_use_in_projections(projections: &[Projection], name: &str, safe: bool) -> MarkerUse {
441    projections
442        .iter()
443        .fold(MarkerUse::None, |marker_use, projection| {
444            marker_use.combine(marker_use_in_projection_expression(
445                &projection.expression,
446                name,
447                safe,
448            ))
449        })
450}
451
452fn marker_use_in_sort_item(item: &SortItem, name: &str) -> MarkerUse {
453    match &item.key {
454        SortKey::Expression(expression) => {
455            marker_use_in_projection_expression(expression, name, false)
456        }
457        _ => MarkerUse::None,
458    }
459}
460
461fn marker_use_in_projection_expression(
462    expression: &ProjectionExpression,
463    name: &str,
464    safe: bool,
465) -> MarkerUse {
466    let marker_use = match expression {
467        ProjectionExpression::Case { .. }
468        | ProjectionExpression::Binary { .. }
469        | ProjectionExpression::Not(_)
470        | ProjectionExpression::IsNull { .. } => {
471            let mut result = MarkerUse::None;
472            expression.all_children(|child| {
473                result = result.combine(marker_use_in_projection_expression(child, name, safe));
474                true
475            });
476            result
477        }
478
479        ProjectionExpression::Literal(value) => values_marker_use([value], name),
480        ProjectionExpression::Coalesce(expressions) => {
481            expressions
482                .iter()
483                .fold(MarkerUse::None, |marker_use, expression| {
484                    marker_use.combine(marker_use_in_projection_expression(expression, name, safe))
485                })
486        }
487        ProjectionExpression::Left { expression, .. } | ProjectionExpression::Lower(expression) => {
488            marker_use_in_projection_expression(expression, name, safe)
489        }
490        ProjectionExpression::DefaultIfNullOrEq { empty, default, .. }
491        | ProjectionExpression::ColumnDefaultIfNullOrEq { empty, default, .. } => {
492            values_marker_use([empty, default], name)
493        }
494        ProjectionExpression::DefaultIfNull { default, .. }
495        | ProjectionExpression::CaseLowerPropertyDefault { default, .. }
496        | ProjectionExpression::ColumnValueDefaultIfNull { default, .. } => {
497            values_marker_use([default], name)
498        }
499        ProjectionExpression::CasePropertyNotNullOrEq {
500            empty,
501            non_empty,
502            null_or_empty,
503            ..
504        }
505        | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq {
506            empty,
507            non_empty,
508            null_or_empty,
509            ..
510        } => values_marker_use([empty, non_empty, null_or_empty], name),
511        ProjectionExpression::CasePropertyEqualsRank {
512            branches, default, ..
513        } => values_marker_use(
514            branches
515                .iter()
516                .flat_map(|(left, right)| [left, right])
517                .chain([default]),
518            name,
519        ),
520        ProjectionExpression::CaseCoalesceDifferenceFloorZero { terms, .. } => {
521            values_marker_use(terms.iter().map(|term| &term.default), name)
522        }
523        ProjectionExpression::CaseEntitySearchRank(expression) => values_marker_use(
524            [
525                &expression.raw_query,
526                &expression.normalized_query,
527                &expression.raw_input,
528                &expression.exact_rank,
529                &expression.alias_rank,
530                &expression.fallback_rank,
531            ],
532            name,
533        ),
534        ProjectionExpression::CaseColumnSearchRank(expression) => values_marker_use(
535            [
536                &expression.raw_query,
537                &expression.normalized_query,
538                &expression.exact_rank,
539                &expression.contains_rank,
540                &expression.fallback_rank,
541            ],
542            name,
543        ),
544        _ => MarkerUse::None,
545    };
546    if !safe && marker_use == MarkerUse::Safe {
547        MarkerUse::Unsafe
548    } else {
549        marker_use
550    }
551}
552
553fn bind_physical_plan(plan: &mut PhysicalPlan, parameters: &BTreeMap<String, Value>) -> Result<()> {
554    match plan {
555        PhysicalPlan::GraphAlgorithm {
556            node_visibility_predicate,
557            ..
558        } => bind_optional_predicate(node_visibility_predicate, parameters)?,
559        PhysicalPlan::SourceSegmentScan { predicate, .. }
560        | PhysicalPlan::FilterExec { predicate, .. } => {
561            bind_predicate(predicate, parameters)?;
562        }
563        PhysicalPlan::NodeProjectionScanExec {
564            access,
565            predicate,
566            items,
567            ..
568        } => {
569            bind_node_projection_access(access, parameters)?;
570            bind_optional_predicate(predicate, parameters)?;
571            bind_projections(items, parameters)?;
572        }
573        PhysicalPlan::NodeCartesianProductExec { left, right }
574        | PhysicalPlan::HashJoinExec { left, right, .. } => {
575            bind_physical_plan(left, parameters)?;
576            bind_physical_plan(right, parameters)?;
577        }
578        PhysicalPlan::GraphMatchExec { program, input } => {
579            for step in &mut program.steps {
580                let node = match step {
581                    GraphMatchStep::Node(node) => node,
582                    GraphMatchStep::Expand {
583                        target, properties, ..
584                    } => {
585                        bind_value_map(properties, parameters)?;
586                        target
587                    }
588                };
589                bind_value_map(&mut node.properties, parameters)?;
590            }
591            bind_optional_predicate(&mut program.predicate, parameters)?;
592            if let Some(input) = input {
593                bind_physical_plan(input, parameters)?;
594            }
595        }
596        PhysicalPlan::NodeColumnLookupExec { input, .. }
597        | PhysicalPlan::DistinctExec { input }
598        | PhysicalPlan::LimitExec { input, .. } => bind_physical_plan(input, parameters)?,
599        PhysicalPlan::IndexNodeSeek { value, .. } => bind_value(value, parameters)?,
600        PhysicalPlan::IndexNodeMultiSeek { values, .. } => {
601            bind_values(values, parameters)?;
602        }
603        PhysicalPlan::IndexNodeUnionSeek { branches, .. } => {
604            for branch in branches {
605                bind_values(&mut branch.values, parameters)?;
606            }
607        }
608        PhysicalPlan::IndexNodeCompositeSeek { predicates, .. } => {
609            for (_, value) in predicates {
610                bind_value(value, parameters)?;
611            }
612        }
613        PhysicalPlan::IndexNodeCompositeRangeSeek { seek, .. } => {
614            bind_composite_range_seek(seek, parameters)?;
615        }
616        PhysicalPlan::IndexNodeRangeSeek { lower, upper, .. } => {
617            for (value, _) in lower.iter_mut().chain(upper.iter_mut()) {
618                bind_value(value, parameters)?;
619            }
620        }
621        PhysicalPlan::AdjacencyExpandExec {
622            rel_properties,
623            input,
624            ..
625        } => {
626            bind_value_map(rel_properties, parameters)?;
627            bind_physical_plan(input, parameters)?;
628        }
629        PhysicalPlan::OptionalDegreeExec {
630            rel_properties,
631            target_properties,
632            input,
633            ..
634        } => {
635            bind_value_map(rel_properties, parameters)?;
636            bind_value_map(target_properties, parameters)?;
637            bind_physical_plan(input, parameters)?;
638        }
639        PhysicalPlan::OptionalRelationshipCountSumExec {
640            properties, legs, ..
641        } => {
642            bind_value_map(properties, parameters)?;
643            for leg in legs {
644                if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
645                    &mut leg.filter
646                {
647                    bind_value(value, parameters)?;
648                }
649            }
650        }
651        PhysicalPlan::ShortestPathExec {
652            source_id,
653            source_visibility_predicate,
654            target_id,
655            target_visibility_predicate,
656            ..
657        } => {
658            bind_value(source_id, parameters)?;
659            bind_optional_predicate(source_visibility_predicate, parameters)?;
660            bind_value(target_id, parameters)?;
661            bind_optional_predicate(target_visibility_predicate, parameters)?;
662        }
663        PhysicalPlan::ProjectExec { items, input } => {
664            bind_projections(items, parameters)?;
665            bind_physical_plan(input, parameters)?;
666        }
667        PhysicalPlan::AggregateExec {
668            group_keys, input, ..
669        } => {
670            bind_projections(group_keys, parameters)?;
671            bind_physical_plan(input, parameters)?;
672        }
673        PhysicalPlan::SortExec { items, input } | PhysicalPlan::TopNExec { items, input, .. } => {
674            bind_sort_items(items, parameters)?;
675            bind_physical_plan(input, parameters)?;
676        }
677        _ => {}
678    }
679    if let PhysicalPlan::SourceSegmentScan { .. } | PhysicalPlan::FilterExec { .. } = plan {
680        match plan {
681            PhysicalPlan::FilterExec { input, .. } => bind_physical_plan(input, parameters)?,
682            PhysicalPlan::SourceSegmentScan { .. } => {}
683            _ => unreachable!(),
684        }
685    }
686    Ok(())
687}
688
689fn bind_node_projection_access(
690    access: &mut hawdb_plan_cypher::NodeProjectionAccess,
691    parameters: &BTreeMap<String, Value>,
692) -> Result<()> {
693    match access {
694        hawdb_plan_cypher::NodeProjectionAccess::LabelScan => Ok(()),
695        hawdb_plan_cypher::NodeProjectionAccess::PropertyValues { values, .. } => {
696            bind_values(values, parameters)
697        }
698        hawdb_plan_cypher::NodeProjectionAccess::PropertyUnion { branches } => {
699            for branch in branches {
700                bind_values(&mut branch.values, parameters)?;
701            }
702            Ok(())
703        }
704        hawdb_plan_cypher::NodeProjectionAccess::CompositeEquality { predicates } => {
705            for (_, value) in predicates {
706                bind_value(value, parameters)?;
707            }
708            Ok(())
709        }
710        hawdb_plan_cypher::NodeProjectionAccess::CompositeRange { seek } => {
711            bind_composite_range_seek(seek, parameters)
712        }
713        hawdb_plan_cypher::NodeProjectionAccess::PropertyRange { lower, upper, .. } => {
714            for (value, _) in lower.iter_mut().chain(upper.iter_mut()) {
715                bind_value(value, parameters)?;
716            }
717            Ok(())
718        }
719        hawdb_plan_cypher::NodeProjectionAccess::FullText { .. } => Ok(()),
720    }
721}
722
723fn bind_composite_range_seek(
724    seek: &mut hawdb_plan_cypher::CompositeRangeSeek,
725    parameters: &BTreeMap<String, Value>,
726) -> Result<()> {
727    for (_, value) in &mut seek.equality_prefix {
728        bind_value(value, parameters)?;
729    }
730    for (value, _) in seek.lower.iter_mut().chain(seek.upper.iter_mut()) {
731        bind_value(value, parameters)?;
732    }
733    Ok(())
734}
735
736fn bind_logical_plan(plan: &mut LogicalPlan, parameters: &BTreeMap<String, Value>) -> Result<()> {
737    match plan {
738        LogicalPlan::GraphAlgorithm {
739            node_visibility_predicate,
740            ..
741        } => bind_optional_predicate(node_visibility_predicate, parameters)?,
742        LogicalPlan::NodeCartesianProduct { left, right } => {
743            bind_logical_plan(left, parameters)?;
744            bind_logical_plan(right, parameters)?;
745        }
746        LogicalPlan::NodeColumnLookup { input, .. }
747        | LogicalPlan::Distinct { input }
748        | LogicalPlan::Limit { input, .. } => bind_logical_plan(input, parameters)?,
749        LogicalPlan::Expand {
750            rel_properties,
751            input,
752            ..
753        } => {
754            bind_value_map(rel_properties, parameters)?;
755            bind_logical_plan(input, parameters)?;
756        }
757        LogicalPlan::OptionalDegree {
758            rel_properties,
759            target_properties,
760            input,
761            ..
762        } => {
763            bind_value_map(rel_properties, parameters)?;
764            bind_value_map(target_properties, parameters)?;
765            bind_logical_plan(input, parameters)?;
766        }
767        LogicalPlan::OptionalRelationshipCountSum {
768            properties, legs, ..
769        } => {
770            bind_value_map(properties, parameters)?;
771            for leg in legs {
772                if let Some(RelationshipCountFilter::PropertyNotEqOrEmpty { value, .. }) =
773                    &mut leg.filter
774                {
775                    bind_value(value, parameters)?;
776                }
777            }
778        }
779        LogicalPlan::ShortestPath {
780            source_id,
781            source_visibility_predicate,
782            target_id,
783            target_visibility_predicate,
784            ..
785        } => {
786            bind_value(source_id, parameters)?;
787            bind_optional_predicate(source_visibility_predicate, parameters)?;
788            bind_value(target_id, parameters)?;
789            bind_optional_predicate(target_visibility_predicate, parameters)?;
790        }
791        LogicalPlan::Filter {
792            predicate, input, ..
793        } => {
794            bind_predicate(predicate, parameters)?;
795            bind_logical_plan(input, parameters)?;
796        }
797        LogicalPlan::Project { items, input } => {
798            bind_projections(items, parameters)?;
799            bind_logical_plan(input, parameters)?;
800        }
801        LogicalPlan::Aggregate {
802            group_keys, input, ..
803        } => {
804            bind_projections(group_keys, parameters)?;
805            bind_logical_plan(input, parameters)?;
806        }
807        LogicalPlan::Sort { items, input } => {
808            bind_sort_items(items, parameters)?;
809            bind_logical_plan(input, parameters)?;
810        }
811        _ => {}
812    }
813    Ok(())
814}
815
816fn bind_optional_predicate(
817    predicate: &mut Option<Predicate>,
818    parameters: &BTreeMap<String, Value>,
819) -> Result<()> {
820    if let Some(predicate) = predicate {
821        bind_predicate(predicate, parameters)?;
822    }
823    Ok(())
824}
825
826fn bind_predicate(predicate: &mut Predicate, parameters: &BTreeMap<String, Value>) -> Result<()> {
827    match predicate {
828        Predicate::And(predicates) | Predicate::Or(predicates) => {
829            for predicate in predicates {
830                bind_predicate(predicate, parameters)?;
831            }
832        }
833        Predicate::Not(predicate) => bind_predicate(predicate, parameters)?,
834        Predicate::IdEq { value, .. }
835        | Predicate::IdNotEq { value, .. }
836        | Predicate::IdCompare { value, .. }
837        | Predicate::PropertyEq { value, .. }
838        | Predicate::PropertyNotEq { value, .. }
839        | Predicate::PropertyCompare { value, .. }
840        | Predicate::PropertyListContains { value, .. } => bind_value(value, parameters)?,
841        Predicate::IdIn { values, .. } | Predicate::PropertyIn { values, .. } => {
842            bind_values(values, parameters)?;
843        }
844        Predicate::ExpressionEq { expression, value }
845        | Predicate::ExpressionNotEq { expression, value }
846        | Predicate::ExpressionContains { expression, value } => {
847            bind_projection_expression(expression, parameters)?;
848            bind_projection_expression(value, parameters)?;
849        }
850        Predicate::ExpressionCompare {
851            expression, value, ..
852        } => {
853            bind_projection_expression(expression, parameters)?;
854            bind_projection_expression(value, parameters)?;
855        }
856        _ => {}
857    }
858    Ok(())
859}
860
861fn bind_projections(
862    projections: &mut [Projection],
863    parameters: &BTreeMap<String, Value>,
864) -> Result<()> {
865    for projection in projections {
866        bind_projection_expression(&mut projection.expression, parameters)?;
867    }
868    Ok(())
869}
870
871fn bind_sort_items(items: &mut [SortItem], parameters: &BTreeMap<String, Value>) -> Result<()> {
872    for item in items {
873        if let SortKey::Expression(expression) = &mut item.key {
874            bind_projection_expression(expression, parameters)?;
875        }
876    }
877    Ok(())
878}
879
880fn bind_projection_expression(
881    expression: &mut ProjectionExpression,
882    parameters: &BTreeMap<String, Value>,
883) -> Result<()> {
884    match expression {
885        ProjectionExpression::Case { .. }
886        | ProjectionExpression::Binary { .. }
887        | ProjectionExpression::Not(_)
888        | ProjectionExpression::IsNull { .. } => expression
889            .try_for_each_child_mut(|child| bind_projection_expression(child, parameters))?,
890
891        ProjectionExpression::Literal(value) => bind_value(value, parameters)?,
892        ProjectionExpression::Coalesce(expressions) => {
893            for expression in expressions {
894                bind_projection_expression(expression, parameters)?;
895            }
896        }
897        ProjectionExpression::Left { expression, .. } | ProjectionExpression::Lower(expression) => {
898            bind_projection_expression(expression, parameters)?;
899        }
900        ProjectionExpression::DefaultIfNullOrEq { empty, default, .. }
901        | ProjectionExpression::ColumnDefaultIfNullOrEq { empty, default, .. } => {
902            bind_value(empty, parameters)?;
903            bind_value(default, parameters)?;
904        }
905        ProjectionExpression::DefaultIfNull { default, .. }
906        | ProjectionExpression::CaseLowerPropertyDefault { default, .. }
907        | ProjectionExpression::ColumnValueDefaultIfNull { default, .. } => {
908            bind_value(default, parameters)?;
909        }
910        ProjectionExpression::CasePropertyNotNullOrEq {
911            empty,
912            non_empty,
913            null_or_empty,
914            ..
915        }
916        | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq {
917            empty,
918            non_empty,
919            null_or_empty,
920            ..
921        } => {
922            bind_value(empty, parameters)?;
923            bind_value(non_empty, parameters)?;
924            bind_value(null_or_empty, parameters)?;
925        }
926        ProjectionExpression::CasePropertyEqualsRank {
927            branches, default, ..
928        } => {
929            for (left, right) in branches {
930                bind_value(left, parameters)?;
931                bind_value(right, parameters)?;
932            }
933            bind_value(default, parameters)?;
934        }
935        ProjectionExpression::CaseCoalesceDifferenceFloorZero { terms, .. } => {
936            for term in terms {
937                bind_value(&mut term.default, parameters)?;
938            }
939        }
940        ProjectionExpression::CaseEntitySearchRank(expression) => {
941            for value in [
942                &mut expression.raw_query,
943                &mut expression.normalized_query,
944                &mut expression.raw_input,
945                &mut expression.exact_rank,
946                &mut expression.alias_rank,
947                &mut expression.fallback_rank,
948            ] {
949                bind_value(value, parameters)?;
950            }
951        }
952        ProjectionExpression::CaseColumnSearchRank(expression) => {
953            for value in [
954                &mut expression.raw_query,
955                &mut expression.normalized_query,
956                &mut expression.exact_rank,
957                &mut expression.contains_rank,
958                &mut expression.fallback_rank,
959            ] {
960                bind_value(value, parameters)?;
961            }
962        }
963        _ => {}
964    }
965    Ok(())
966}
967
968fn bind_value_map(
969    values: &mut BTreeMap<String, Value>,
970    parameters: &BTreeMap<String, Value>,
971) -> Result<()> {
972    for value in values.values_mut() {
973        bind_value(value, parameters)?;
974    }
975    Ok(())
976}
977
978fn bind_values(values: &mut [Value], parameters: &BTreeMap<String, Value>) -> Result<()> {
979    for value in values {
980        bind_value(value, parameters)?;
981    }
982    Ok(())
983}
984
985fn bind_value(value: &mut Value, parameters: &BTreeMap<String, Value>) -> Result<()> {
986    if let Some((name, path)) = marker_name_and_path(value) {
987        let mut bound = parameters
988            .get(name)
989            .ok_or_else(|| HawDBError::Semantic(format!("missing parameter '${name}'")))?;
990        for index in path {
991            let Value::List(values) = bound else {
992                return Err(HawDBError::Semantic(format!(
993                    "parameter '${name}' changed shape while binding a cached plan"
994                )));
995            };
996            bound = values.get(index).ok_or_else(|| {
997                HawDBError::Semantic(format!(
998                    "parameter '${name}' changed shape while binding a cached plan"
999                ))
1000            })?;
1001        }
1002        *value = bound.clone();
1003        return Ok(());
1004    }
1005    match value {
1006        Value::List(values) => bind_values(values, parameters),
1007        Value::Map(values) => bind_value_map(values, parameters),
1008        _ => Ok(()),
1009    }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::{
1015        bind_physical_plan_parameters, marker_use_in_logical_plan, parameter_marker,
1016        parameterize_logical_plan, MarkerUse, ParameterCacheValue, PARAMETER_SLOT_NAME_KEY,
1017        PARAMETER_SLOT_PATH_KEY,
1018    };
1019    use hawdb_core::Value;
1020    use hawdb_cypher as cypher;
1021    use hawdb_optimizer_graph::{CascadesOptimizer, OptimizerCatalog};
1022    use hawdb_plan_cypher::{
1023        CompositeRangeSeek, ExactPropertySeekBranch, LogicalPlanRoot, NodeProjectionAccess,
1024        PhysicalPlan,
1025    };
1026    use std::collections::BTreeMap;
1027
1028    #[test]
1029    fn general_case_preserves_exact_cache_keys_and_rebinds_explicit_template_slots() {
1030        let statement = cypher::parse("MATCH (n:Item) RETURN CASE WHEN n.id = $chosen THEN lower($label) ELSE $fallback END AS result").unwrap();
1031        let parameters = BTreeMap::from([
1032            ("chosen".into(), Value::Int(1)),
1033            ("label".into(), Value::String("FIRST".into())),
1034            ("fallback".into(), Value::String("old".into())),
1035        ]);
1036        let parameterized = parameterize_logical_plan(&statement, &parameters).unwrap();
1037        assert_eq!(parameterized.slot_count, 0);
1038        assert!(parameterized
1039            .cache_key
1040            .values
1041            .values()
1042            .all(|value| matches!(value, ParameterCacheValue::Exact(_))));
1043        // Projection parameters keep their existing conservative cache policy.
1044        // Explicit templates must nevertheless rebind all scalar descendants.
1045        let markers = parameters
1046            .iter()
1047            .map(|(name, value)| (name.clone(), parameter_marker(name, value, &[])))
1048            .collect();
1049        let logical = hawdb_plan_cypher::plan_with_params(&statement, &markers).unwrap();
1050        let root = CascadesOptimizer::default().optimize_root_with_catalog(
1051            &LogicalPlanRoot::new(logical),
1052            &OptimizerCatalog::default(),
1053        );
1054        let (template, _) = root.into_parts();
1055        let parameters = BTreeMap::from([
1056            ("chosen".into(), Value::Int(2)),
1057            ("label".into(), Value::String("SECOND".into())),
1058            ("fallback".into(), Value::String("new".into())),
1059        ]);
1060        let rebound = bind_physical_plan_parameters(&template, &parameters, true).unwrap();
1061        let text = format!("{rebound:?}");
1062        assert!(text.contains("SECOND") && text.contains("new"), "{text}");
1063        assert!(!text.contains("hawdb_parameter_slot"), "{text}");
1064    }
1065
1066    #[test]
1067    fn clause_match_rebinds_every_step_predicate_and_input() {
1068        let query = "MATCH (a:Item {id: $a}) OPTIONAL MATCH (a)-[r:LINK {code: $code}]->(b:Item {id: $b}) WHERE b.visible = $visible RETURN b";
1069        let slots = ["a", "b", "code", "visible"];
1070        let parameters = slots
1071            .into_iter()
1072            .map(|name| {
1073                (
1074                    name.to_string(),
1075                    parameter_marker(name, &Value::Int(0), &[]),
1076                )
1077            })
1078            .collect();
1079        let logical = hawdb_plan_cypher::plan_pipeline_query(query, &parameters).unwrap();
1080        for name in slots {
1081            assert_eq!(
1082                marker_use_in_logical_plan(&logical, name),
1083                MarkerUse::Safe,
1084                "{name}"
1085            );
1086        }
1087        let optimize = |logical| {
1088            CascadesOptimizer::default()
1089                .optimize_root_with_catalog(
1090                    &LogicalPlanRoot::new(logical),
1091                    &OptimizerCatalog::default(),
1092                )
1093                .into_parts()
1094                .0
1095        };
1096        let template = optimize(logical);
1097        let original = template.instance_fingerprint();
1098        for base in [10, 20] {
1099            let parameters = slots
1100                .into_iter()
1101                .enumerate()
1102                .map(|(index, name)| (name.to_string(), Value::Int(base + index as i64)))
1103                .collect();
1104            let rebound = bind_physical_plan_parameters(&template, &parameters, true).unwrap();
1105            let fresh =
1106                optimize(hawdb_plan_cypher::plan_pipeline_query(query, &parameters).unwrap());
1107            assert_eq!(rebound.instance_fingerprint(), fresh.instance_fingerprint());
1108            assert!(!rebound
1109                .instance_fingerprint()
1110                .contains("hawdb_parameter_slot"));
1111        }
1112        assert_eq!(template.instance_fingerprint(), original);
1113        assert!(bind_physical_plan_parameters(&template, &BTreeMap::new(), true).is_err());
1114    }
1115
1116    #[test]
1117    fn equality_parameters_become_rebindable_slots() {
1118        let statement =
1119            cypher::parse("MATCH (m:Memory) WHERE m.id = $id RETURN m.title AS title").unwrap();
1120        let first_parameters =
1121            BTreeMap::from([("id".to_string(), Value::String("first".to_string()))]);
1122        let parameterized = parameterize_logical_plan(&statement, &first_parameters).unwrap();
1123        assert!(matches!(
1124            parameterized.cache_key.values.get("id"),
1125            Some(ParameterCacheValue::Slot(_))
1126        ));
1127
1128        let root = CascadesOptimizer::default().optimize_root_with_catalog(
1129            &LogicalPlanRoot::new(parameterized.logical),
1130            &OptimizerCatalog::default(),
1131        );
1132        let (template, _) = root.into_parts();
1133        let rebound = bind_physical_plan_parameters(
1134            &template,
1135            &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1136            true,
1137        )
1138        .unwrap();
1139
1140        assert!(rebound.explain(0).contains("second"));
1141        assert!(!rebound.explain(0).contains("hawdb_parameter_slot"));
1142    }
1143
1144    #[test]
1145    fn graph_hash_join_rebinds_both_inputs_without_changing_keys() {
1146        let seek = |variable: &str, parameter: &str| PhysicalPlan::IndexNodeSeek {
1147            variable: variable.into(),
1148            label: "Item".into(),
1149            property: "scope".into(),
1150            value: parameter_marker(parameter, &Value::Int(0), &[]),
1151        };
1152        let template = PhysicalPlan::HashJoinExec {
1153            left_key: hawdb_plan_cypher::HashJoinKey {
1154                variable: "a".into(),
1155                property: "key".into(),
1156            },
1157            right_key: hawdb_plan_cypher::HashJoinKey {
1158                variable: "b".into(),
1159                property: "key".into(),
1160            },
1161            left: Box::new(seek("a", "left_scope")),
1162            right: Box::new(seek("b", "right_scope")),
1163        };
1164        for (a, b) in [(1, 2), (3, 4)] {
1165            let bound = bind_physical_plan_parameters(
1166                &template,
1167                &BTreeMap::from([
1168                    ("left_scope".into(), Value::Int(a)),
1169                    ("right_scope".into(), Value::Int(b)),
1170                ]),
1171                true,
1172            )
1173            .unwrap();
1174            let PhysicalPlan::HashJoinExec {
1175                left_key,
1176                right_key,
1177                left,
1178                right,
1179            } = bound
1180            else {
1181                unreachable!()
1182            };
1183            assert_eq!(left_key.variable, "a");
1184            assert_eq!(right_key.variable, "b");
1185            assert!(
1186                matches!(*left, PhysicalPlan::IndexNodeSeek { value: Value::Int(value), .. } if value == a)
1187            );
1188            assert!(
1189                matches!(*right, PhysicalPlan::IndexNodeSeek { value: Value::Int(value), .. } if value == b)
1190            );
1191        }
1192    }
1193
1194    #[test]
1195    fn projected_index_access_rebinds_parameter_slots() {
1196        let template = PhysicalPlan::NodeProjectionScanExec {
1197            variable: "m".to_string(),
1198            label: "Memory".to_string(),
1199            access: NodeProjectionAccess::PropertyValues {
1200                property: "stable_id".to_string(),
1201                values: vec![parameter_marker(
1202                    "id",
1203                    &Value::String("first".to_string()),
1204                    &[],
1205                )],
1206            },
1207            required_properties: vec!["stable_id".to_string()],
1208            predicate: None,
1209            items: Vec::new(),
1210        };
1211
1212        let rebound = bind_physical_plan_parameters(
1213            &template,
1214            &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1215            true,
1216        )
1217        .unwrap();
1218
1219        assert!(matches!(
1220            rebound,
1221            PhysicalPlan::NodeProjectionScanExec {
1222                access: NodeProjectionAccess::PropertyValues { values, .. },
1223                ..
1224            } if values == vec![Value::String("second".to_string())]
1225        ));
1226    }
1227
1228    #[test]
1229    fn projected_union_access_rebinds_every_branch() {
1230        let marker = || parameter_marker("id", &Value::String("first".to_string()), &[]);
1231        let template = PhysicalPlan::NodeProjectionScanExec {
1232            variable: "m".to_string(),
1233            label: "Memory".to_string(),
1234            access: NodeProjectionAccess::PropertyUnion {
1235                branches: vec![
1236                    ExactPropertySeekBranch {
1237                        property: "id".to_string(),
1238                        values: vec![marker()],
1239                    },
1240                    ExactPropertySeekBranch {
1241                        property: "external_id".to_string(),
1242                        values: vec![marker()],
1243                    },
1244                ],
1245            },
1246            required_properties: vec!["id".to_string(), "external_id".to_string()],
1247            predicate: None,
1248            items: Vec::new(),
1249        };
1250
1251        let rebound = bind_physical_plan_parameters(
1252            &template,
1253            &BTreeMap::from([("id".to_string(), Value::String("second".to_string()))]),
1254            true,
1255        )
1256        .unwrap();
1257
1258        let PhysicalPlan::NodeProjectionScanExec {
1259            access: NodeProjectionAccess::PropertyUnion { branches },
1260            ..
1261        } = rebound
1262        else {
1263            panic!("expected projected union access");
1264        };
1265        assert!(branches
1266            .iter()
1267            .all(|branch| { branch.values == vec![Value::String("second".to_string())] }));
1268    }
1269
1270    #[test]
1271    fn projected_composite_range_rebinds_prefix_and_bounds() {
1272        let template = PhysicalPlan::NodeProjectionScanExec {
1273            variable: "m".to_string(),
1274            label: "Memory".to_string(),
1275            access: NodeProjectionAccess::CompositeRange {
1276                seek: CompositeRangeSeek {
1277                    index_properties: vec!["space_id".to_string(), "created_at".to_string()],
1278                    equality_prefix: vec![(
1279                        "space_id".to_string(),
1280                        parameter_marker(
1281                            "space_id",
1282                            &Value::String("space:first".to_string()),
1283                            &[],
1284                        ),
1285                    )],
1286                    range_property: "created_at".to_string(),
1287                    lower: Some((parameter_marker("lower", &Value::Int(10), &[]), true)),
1288                    upper: Some((parameter_marker("upper", &Value::Int(20), &[]), false)),
1289                },
1290            },
1291            required_properties: vec!["space_id".to_string(), "created_at".to_string()],
1292            predicate: None,
1293            items: Vec::new(),
1294        };
1295
1296        let rebound = bind_physical_plan_parameters(
1297            &template,
1298            &BTreeMap::from([
1299                (
1300                    "space_id".to_string(),
1301                    Value::String("space:second".to_string()),
1302                ),
1303                ("lower".to_string(), Value::Int(30)),
1304                ("upper".to_string(), Value::Int(40)),
1305            ]),
1306            true,
1307        )
1308        .unwrap();
1309
1310        assert!(matches!(
1311            rebound,
1312            PhysicalPlan::NodeProjectionScanExec {
1313                access: NodeProjectionAccess::CompositeRange { seek },
1314                ..
1315            } if seek.equality_prefix
1316                == vec![(
1317                    "space_id".to_string(),
1318                    Value::String("space:second".to_string()),
1319                )]
1320                && seek.lower == Some((Value::Int(30), true))
1321                && seek.upper == Some((Value::Int(40), false))
1322        ));
1323    }
1324
1325    #[test]
1326    fn pagination_parameters_remain_exact_variants() {
1327        let statement =
1328            cypher::parse("MATCH (m:Memory) RETURN m.id AS id ORDER BY m.id LIMIT $limit").unwrap();
1329        let parameterized = parameterize_logical_plan(
1330            &statement,
1331            &BTreeMap::from([("limit".to_string(), Value::Int(10))]),
1332        )
1333        .unwrap();
1334
1335        assert_eq!(
1336            parameterized.cache_key.values.get("limit"),
1337            Some(&ParameterCacheValue::Exact(Value::Int(10)))
1338        );
1339    }
1340
1341    #[test]
1342    fn parameters_that_change_logical_simplification_remain_exact_variants() {
1343        let statement = cypher::parse(
1344            "MATCH (t:Thread) WHERE $source IS NULL OR t.source = $source RETURN t.id AS id",
1345        )
1346        .unwrap();
1347        let parameterized = parameterize_logical_plan(
1348            &statement,
1349            &BTreeMap::from([("source".to_string(), Value::Null)]),
1350        )
1351        .unwrap();
1352
1353        assert_eq!(
1354            parameterized.cache_key.values.get("source"),
1355            Some(&ParameterCacheValue::Exact(Value::Null))
1356        );
1357    }
1358
1359    #[test]
1360    fn multiple_equality_parameters_share_one_rebindable_template() {
1361        let statement =
1362            cypher::parse("MATCH (m:Memory) WHERE m.id = $id AND m.kind = $kind RETURN m.id AS id")
1363                .unwrap();
1364        let parameterized = parameterize_logical_plan(
1365            &statement,
1366            &BTreeMap::from([
1367                ("id".to_string(), Value::String("memory-1".to_string())),
1368                ("kind".to_string(), Value::String("note".to_string())),
1369            ]),
1370        )
1371        .unwrap();
1372
1373        assert_eq!(parameterized.slot_count, 2);
1374        assert!(parameterized
1375            .cache_key
1376            .values
1377            .values()
1378            .all(|cached| matches!(cached, ParameterCacheValue::Slot(_))));
1379    }
1380
1381    #[test]
1382    fn marker_shaped_parameter_values_disable_slots() {
1383        let statement =
1384            cypher::parse("MATCH (m:Memory) WHERE m.metadata = $metadata RETURN m.id AS id")
1385                .unwrap();
1386        let marker_shaped = Value::Map(BTreeMap::from([
1387            (
1388                PARAMETER_SLOT_NAME_KEY.to_string(),
1389                Value::String("other".to_string()),
1390            ),
1391            (PARAMETER_SLOT_PATH_KEY.to_string(), Value::List(Vec::new())),
1392        ]));
1393        let parameterized = parameterize_logical_plan(
1394            &statement,
1395            &BTreeMap::from([("metadata".to_string(), marker_shaped.clone())]),
1396        )
1397        .unwrap();
1398
1399        assert_eq!(parameterized.slot_count, 0);
1400        assert_eq!(
1401            parameterized.cache_key.values.get("metadata"),
1402            Some(&ParameterCacheValue::Exact(marker_shaped))
1403        );
1404    }
1405}