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