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