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