Skip to main content

powdb_query/
plan_cache.rs

1//! Plan cache — Mission D9.
2//!
3//! Two queries that differ only in literal values share the same parsed +
4//! planned tree. Re-running the lexer + parser + planner on every call is
5//! pure overhead — easily 1-3μs per query, which is the entire budget on
6//! sub-microsecond workloads like `update_by_pk`. SQLite gets around this
7//! with `prepare_cached`; we get around it with this cache.
8//!
9//! ## How it works
10//!
11//! 1. [`crate::canonicalize::canonicalize`] lexes the input and produces
12//!    `(canonical_hash, literals)`. The hash collapses literal *values*
13//!    into placeholders, so `User filter .id = 1` and `User filter .id = 2`
14//!    have the same hash.
15//! 2. On the first call, [`PlanCache::insert`] stores the planned tree
16//!    keyed by the canonical hash. The plan still has the *first call's*
17//!    literal values baked into its `Expr::Literal` nodes — that's fine,
18//!    we'll overwrite them on subsequent hits.
19//! 3. On a subsequent call, [`PlanCache::get_with_substitution`] clones
20//!    the cached plan and walks it depth-first, replacing each
21//!    `Expr::Literal` it finds (in source order) with the corresponding
22//!    literal from the new query.
23//!
24//! The walk order is **deterministic and matches the source order of the
25//! literal list collected by `canonicalize`** — see the per-PlanNode
26//! comments below for the exact traversal contract.
27
28use crate::ast::{Assignment, Expr, Literal};
29use crate::plan::PlanNode;
30use rustc_hash::FxHashMap;
31
32/// LRU-ish plan cache keyed by canonical query hash.
33///
34/// Mission F: uses FxHashMap. The keys are u64 hashes (already pre-hashed
35/// by `canonicalize`), so SipHash is pure overhead — Fx is much cheaper for
36/// the integer-keyed lookup.
37pub struct PlanCache {
38    cache: FxHashMap<u64, PlanNode>,
39    capacity: usize,
40    pub hits: u64,
41    pub misses: u64,
42}
43
44impl PlanCache {
45    pub fn new(capacity: usize) -> Self {
46        PlanCache {
47            cache: FxHashMap::default(),
48            capacity,
49            hits: 0,
50            misses: 0,
51        }
52    }
53
54    /// Store a planned query under its canonical hash. The plan can have
55    /// any literal values inside it — those will be overwritten on hit.
56    ///
57    /// `source_literal_count` is the number of literals
58    /// [`crate::canonicalize::canonicalize`] collected from the query text.
59    /// The cache only works because, on a hit, every collected literal maps
60    /// 1:1 to a substitutable slot the `substitute_plan` walk can reach.
61    /// If those counts disagree, the plan has literals the walk cannot
62    /// re-bind — the one case today is a subquery (`in (<subquery>)` /
63    /// `exists (...)`), whose inner literals `canonicalize` collects at the
64    /// token level but which live in an un-walked `QueryExpr` AST inside the
65    /// predicate. Caching such a plan would serve the *first* call's inner
66    /// literal to every later same-shape call: silent wrong rows in release,
67    /// a substitution-count assert in debug (issue #137). We refuse to cache
68    /// it; the engine then plans from source on every call, which is always
69    /// correct. This check runs only on the populating miss, so the hot
70    /// hit-path pays nothing.
71    pub fn insert(&mut self, hash: u64, plan: PlanNode, source_literal_count: usize) {
72        // GROUP BY planning lifts aggregate arguments out of their source
73        // projection positions, and the parser accepts HAVING both before and
74        // after that projection. The physical plan does not retain which
75        // clause order the source used, so a literal-slot walk cannot safely
76        // reconstruct source order for grouped HAVING queries. Refuse to
77        // cache that shape until plans carry explicit source-slot ordinals.
78        // Replanning is cheaper than ever serving silently rebound literals.
79        if contains_grouped_having(&plan) {
80            return;
81        }
82        // Nested-projection plans are cacheable: their residual/limit/offset
83        // literal slots are walked in source order by
84        // `substitute_nested_projection`. The one form that walk cannot
85        // rebind is a nested block that wrote `offset` before `limit`
86        // (source literal order [offset, limit], walk order [limit, offset]);
87        // refuse it and replan from source on every call instead.
88        if nested_projection_defeats_cache(&plan) {
89            return;
90        }
91        if count_literal_slots(&plan) != source_literal_count {
92            return;
93        }
94        if self.cache.len() >= self.capacity && !self.cache.contains_key(&hash) {
95            // Crude eviction: when full, drop everything. Plan cache is
96            // small (capacity ~256) and bench loops only ever fill a
97            // handful of slots, so this is acceptable for now. A real LRU
98            // would matter once we have hundreds of distinct query shapes.
99            self.cache.clear();
100        }
101        self.cache.insert(hash, plan);
102    }
103
104    /// Look up a plan by canonical hash and return a clone with the new
105    /// literals substituted into every `Expr::Literal` slot in source
106    /// order.
107    ///
108    /// Returns `Some(plan)` on a hit and bumps `self.hits`. Returns `None`
109    /// on a miss and bumps `self.misses`. Returning `None` instead of
110    /// reaching for the planner here keeps this module dependency-free
111    /// from `planner` — the engine handles the miss path.
112    ///
113    /// The substitution is done on a **clone** of the cached plan, not the
114    /// stored copy. The cached plan stays pristine for the next call.
115    pub fn get_with_substitution(&mut self, hash: u64, literals: &[Literal]) -> Option<PlanNode> {
116        match self.cache.get(&hash) {
117            Some(template) => {
118                self.hits += 1;
119                let mut plan = template.clone();
120                let mut idx = 0usize;
121                substitute_plan(&mut plan, literals, &mut idx);
122                debug_assert_eq!(
123                    idx,
124                    literals.len(),
125                    "plan substitution consumed {idx} literals but query had {}",
126                    literals.len(),
127                );
128                Some(plan)
129            }
130            None => {
131                self.misses += 1;
132                None
133            }
134        }
135    }
136
137    pub fn len(&self) -> usize {
138        self.cache.len()
139    }
140
141    pub fn is_empty(&self) -> bool {
142        self.cache.is_empty()
143    }
144
145    pub fn clear(&mut self) {
146        self.cache.clear();
147    }
148}
149
150/// True when a nested projection anywhere in the plan wrote `offset` before
151/// `limit` in its source block. The literal-slot walk visits limit before
152/// offset, so that form's source literal order cannot be safely rebound;
153/// both the cache (see `insert`) and `Engine::prepare` refuse it.
154pub(crate) fn nested_projection_defeats_cache(plan: &PlanNode) -> bool {
155    fn nested_defeats(nested: &crate::plan::NestedProjection) -> bool {
156        nested.offset_before_limit
157            || nested.fields.iter().any(|field| match field {
158                crate::plan::NestedField::Nested(inner) => nested_defeats(inner),
159                crate::plan::NestedField::Scalar { .. } => false,
160            })
161    }
162    fn walk(plan: &PlanNode) -> bool {
163        match plan {
164            PlanNode::NestedProject { input, fields } => {
165                walk(input)
166                    || fields.iter().any(|field| match field {
167                        crate::plan::NestedProjectField::Nested(nested) => nested_defeats(nested),
168                        crate::plan::NestedProjectField::Plain(_) => false,
169                    })
170            }
171            PlanNode::Filter { input, .. }
172            | PlanNode::Project { input, .. }
173            | PlanNode::Sort { input, .. }
174            | PlanNode::Limit { input, .. }
175            | PlanNode::Offset { input, .. }
176            | PlanNode::Aggregate { input, .. }
177            | PlanNode::Distinct { input }
178            | PlanNode::GroupBy { input, .. }
179            | PlanNode::Update { input, .. }
180            | PlanNode::Delete { input, .. }
181            | PlanNode::Window { input, .. }
182            | PlanNode::Explain { input } => walk(input),
183            PlanNode::NestedLoopJoin { left, right, .. } | PlanNode::Union { left, right, .. } => {
184                walk(left) || walk(right)
185            }
186            _ => false,
187        }
188    }
189    walk(plan)
190}
191
192/// Walk one nested projection's literal slots in source order: residual
193/// filter (the correlation predicate and order keys carry no literals),
194/// then limit, then offset. `limit`-then-`offset` matches source order
195/// because plans whose source wrote `offset` before `limit` are refused at
196/// insert (`offset_before_limit`). Shared shape with
197/// [`count_nested_projection`]; both must stay in lockstep.
198fn substitute_nested_projection(
199    nested: &mut crate::plan::NestedProjection,
200    literals: &[Literal],
201    idx: &mut usize,
202) {
203    if let Some(residual) = &mut nested.residual {
204        substitute_expr(residual, literals, idx);
205    }
206    if let Some(limit) = &mut nested.limit {
207        substitute_expr(limit, literals, idx);
208    }
209    if let Some(offset) = &mut nested.offset {
210        substitute_expr(offset, literals, idx);
211    }
212    // The `{ ... }` block comes last in source; deeper nested blocks
213    // contribute their slots in field order.
214    for field in &mut nested.fields {
215        if let crate::plan::NestedField::Nested(inner) = field {
216            substitute_nested_projection(inner, literals, idx);
217        }
218    }
219}
220
221/// Count one nested projection's literal slots; mirrors
222/// [`substitute_nested_projection`].
223fn count_nested_projection(nested: &crate::plan::NestedProjection, n: &mut usize) {
224    if let Some(residual) = &nested.residual {
225        count_expr(residual, n);
226    }
227    if let Some(limit) = &nested.limit {
228        count_expr(limit, n);
229    }
230    if let Some(offset) = &nested.offset {
231        count_expr(offset, n);
232    }
233    for field in &nested.fields {
234        if let crate::plan::NestedField::Nested(inner) = field {
235            count_nested_projection(inner, n);
236        }
237    }
238}
239
240fn contains_grouped_having(plan: &PlanNode) -> bool {
241    match plan {
242        PlanNode::GroupBy {
243            having: Some(_), ..
244        } => true,
245        PlanNode::Filter { input, .. }
246        | PlanNode::Project { input, .. }
247        | PlanNode::Sort { input, .. }
248        | PlanNode::Limit { input, .. }
249        | PlanNode::Offset { input, .. }
250        | PlanNode::Aggregate { input, .. }
251        | PlanNode::Distinct { input }
252        | PlanNode::GroupBy { input, .. }
253        | PlanNode::Update { input, .. }
254        | PlanNode::Delete { input, .. }
255        | PlanNode::Window { input, .. }
256        | PlanNode::NestedProject { input, .. }
257        | PlanNode::Explain { input } => contains_grouped_having(input),
258        PlanNode::NestedLoopJoin { left, right, .. } | PlanNode::Union { left, right, .. } => {
259            contains_grouped_having(left) || contains_grouped_having(right)
260        }
261        PlanNode::SeqScan { .. }
262        | PlanNode::AliasScan { .. }
263        | PlanNode::IndexScan { .. }
264        | PlanNode::RangeScan { .. }
265        | PlanNode::ExprIndexScan { .. }
266        | PlanNode::ExprRangeScan { .. }
267        | PlanNode::OrderedExprIndexScan { .. }
268        | PlanNode::AlterTable { .. }
269        | PlanNode::DropTable { .. }
270        | PlanNode::Insert { .. }
271        | PlanNode::Upsert { .. }
272        | PlanNode::CreateTable { .. }
273        | PlanNode::ListTypes
274        | PlanNode::Describe { .. }
275        | PlanNode::CreateView { .. }
276        | PlanNode::RefreshView { .. }
277        | PlanNode::DropView { .. }
278        | PlanNode::Begin
279        | PlanNode::Commit
280        | PlanNode::Rollback => false,
281    }
282}
283
284/// Walk a plan tree depth-first, replacing every `Expr::Literal` with the
285/// next literal from `literals` (consumed by index). The traversal order
286/// is deterministic and matches the source order produced by
287/// [`crate::canonicalize::canonicalize`].
288///
289/// **Walk contract** — the cache only works because both ends agree on
290/// this order:
291///   - Children of recursive nodes (`Filter`, `Project`, `Sort`, `Limit`,
292///     `Offset`, `Aggregate`, `Update`, `Delete`) are visited *before*
293///     the local expressions, because the source `User filter ... { ... }`
294///     reads the table → predicate → projection in that order, and the
295///     planner wraps `SeqScan → Filter → Project` accordingly.
296///   - For `Update`, the input plan is visited first (which holds the
297///     filter literal), then assignments in declaration order — same
298///     order as the source `User filter .id = 42 update { age := 31 }`.
299///
300/// `pub(crate)` so the executor's prepared-statement API can reuse the
301/// exact same walk — same order as canonicalise, same as the cache.
302pub(crate) fn substitute_plan(plan: &mut PlanNode, literals: &[Literal], idx: &mut usize) {
303    match plan {
304        PlanNode::SeqScan { .. } => {}
305        PlanNode::AliasScan { .. } => {}
306        PlanNode::IndexScan { key, .. } => {
307            substitute_expr(key, literals, idx);
308        }
309        PlanNode::RangeScan { start, end, .. } => {
310            if let Some((expr, _)) = start {
311                substitute_expr(expr, literals, idx);
312            }
313            if let Some((expr, _)) = end {
314                substitute_expr(expr, literals, idx);
315            }
316        }
317        PlanNode::ExprIndexScan { key, .. } => substitute_expr(key, literals, idx),
318        PlanNode::ExprRangeScan { start, end, .. } => {
319            if let Some((expr, _)) = start {
320                substitute_expr(expr, literals, idx);
321            }
322            if let Some((expr, _)) = end {
323                substitute_expr(expr, literals, idx);
324            }
325        }
326        PlanNode::OrderedExprIndexScan { limit, offset, .. } => {
327            substitute_expr(limit, literals, idx);
328            if let Some(offset) = offset {
329                substitute_expr(offset, literals, idx);
330            }
331        }
332        PlanNode::Filter { input, predicate } => {
333            substitute_plan(input, literals, idx);
334            substitute_expr(predicate, literals, idx);
335        }
336        PlanNode::Project { input, fields } => {
337            if let PlanNode::GroupBy {
338                input: group_input,
339                keys,
340                aggregates,
341                having: None,
342            } = input.as_mut()
343            {
344                // GROUP BY lifts aggregate arguments out of their projection
345                // positions. Walk the grouped input/keys first, then replay
346                // each lifted argument at the exact projection position where
347                // it appeared in source, preserving literal ordinals.
348                substitute_plan(group_input, literals, idx);
349                for key in keys {
350                    substitute_expr(&mut key.expr, literals, idx);
351                }
352                let mut visited = std::collections::HashSet::new();
353                for field in fields {
354                    substitute_group_projection_expr(
355                        &mut field.expr,
356                        aggregates,
357                        &mut visited,
358                        literals,
359                        idx,
360                    );
361                }
362            } else {
363                substitute_plan(input, literals, idx);
364                for f in fields {
365                    substitute_expr(&mut f.expr, literals, idx);
366                }
367            }
368        }
369        PlanNode::NestedProject { input, fields } => {
370            // Source order: parent pipeline first, then projection fields
371            // left to right. Within a nested field the block reads
372            // `filter <correlation + residuals> { ... }`; the correlation
373            // predicate carries no literals, so walking the residual covers
374            // the block's literal slots in source order.
375            substitute_plan(input, literals, idx);
376            for field in fields {
377                match field {
378                    crate::plan::NestedProjectField::Plain(f) => {
379                        substitute_expr(&mut f.expr, literals, idx);
380                    }
381                    crate::plan::NestedProjectField::Nested(nested) => {
382                        substitute_nested_projection(nested, literals, idx);
383                    }
384                }
385            }
386        }
387        PlanNode::Sort { input, keys } => {
388            substitute_plan(input, literals, idx);
389            for key in keys {
390                substitute_expr(&mut key.expr, literals, idx);
391            }
392        }
393        PlanNode::AlterTable { .. } => {}
394        PlanNode::DropTable { .. } => {}
395        PlanNode::Limit { input, count } => {
396            // Source order for `filter ... limit N offset M` is
397            // [filter literals, N, M]. The planner now builds
398            // Limit(Offset(...)) so that execution skips M rows *before*
399            // taking N. Naively walking "input then count" would yield
400            // [filter, M, N] — wrong. Special-case `Limit(Offset(...))`
401            // to descend into Offset's own input (which holds the filter
402            // literals), then visit Limit.count, then Offset.count, so
403            // the literal stream stays in source order.
404            if let PlanNode::Offset {
405                input: inner,
406                count: off_count,
407            } = input.as_mut()
408            {
409                substitute_plan(inner, literals, idx);
410                substitute_expr(count, literals, idx);
411                substitute_expr(off_count, literals, idx);
412            } else {
413                substitute_plan(input, literals, idx);
414                substitute_expr(count, literals, idx);
415            }
416        }
417        PlanNode::Offset { input, count } => {
418            // Bare Offset (no wrapping Limit) — source order is
419            // [..., offset literal] so descend first then visit count.
420            substitute_plan(input, literals, idx);
421            substitute_expr(count, literals, idx);
422        }
423        PlanNode::Aggregate {
424            input, argument, ..
425        } => {
426            substitute_plan(input, literals, idx);
427            if let Some(argument) = argument {
428                substitute_expr(argument, literals, idx);
429            }
430        }
431        PlanNode::NestedLoopJoin {
432            left, right, on, ..
433        } => {
434            // Walk order: left subtree → right subtree → on predicate.
435            // Matches canonicalise's source-order literal collection for
436            // joined queries: left source tokens come first, then right
437            // source tokens, then the `on` expression's literals (if any).
438            substitute_plan(left, literals, idx);
439            substitute_plan(right, literals, idx);
440            if let Some(pred) = on {
441                substitute_expr(pred, literals, idx);
442            }
443        }
444        PlanNode::Distinct { input } => {
445            substitute_plan(input, literals, idx);
446        }
447        PlanNode::GroupBy {
448            input,
449            keys,
450            aggregates,
451            having,
452        } => {
453            substitute_plan(input, literals, idx);
454            for key in keys {
455                substitute_expr(&mut key.expr, literals, idx);
456            }
457            for aggregate in aggregates {
458                substitute_expr(&mut aggregate.argument, literals, idx);
459            }
460            if let Some(pred) = having {
461                substitute_expr(pred, literals, idx);
462            }
463        }
464        PlanNode::Insert { rows, .. } => {
465            for assignments in rows {
466                substitute_assignments(assignments, literals, idx);
467            }
468        }
469        PlanNode::Upsert {
470            assignments,
471            on_conflict,
472            ..
473        } => {
474            substitute_assignments(assignments, literals, idx);
475            substitute_assignments(on_conflict, literals, idx);
476        }
477        PlanNode::Update {
478            input, assignments, ..
479        } => {
480            substitute_plan(input, literals, idx);
481            substitute_assignments(assignments, literals, idx);
482        }
483        PlanNode::Delete { input, .. } => {
484            substitute_plan(input, literals, idx);
485        }
486        PlanNode::CreateTable { .. } => {}
487        PlanNode::CreateView { .. } => {}
488        PlanNode::RefreshView { .. } => {}
489        PlanNode::DropView { .. } => {}
490        PlanNode::Window { input, windows } => {
491            substitute_plan(input, literals, idx);
492            for w in windows {
493                for arg in &mut w.args {
494                    substitute_expr(arg, literals, idx);
495                }
496                for expr in &mut w.partition_by {
497                    substitute_expr(expr, literals, idx);
498                }
499                for key in &mut w.order_by {
500                    substitute_expr(&mut key.expr, literals, idx);
501                }
502            }
503        }
504        PlanNode::Union { left, right, .. } => {
505            substitute_plan(left, literals, idx);
506            substitute_plan(right, literals, idx);
507        }
508        PlanNode::Explain { input } => {
509            substitute_plan(input, literals, idx);
510        }
511        PlanNode::ListTypes | PlanNode::Describe { .. } => {}
512        PlanNode::Begin | PlanNode::Commit | PlanNode::Rollback => {}
513    }
514}
515
516fn substitute_assignments(assignments: &mut [Assignment], literals: &[Literal], idx: &mut usize) {
517    for a in assignments {
518        substitute_expr(&mut a.value, literals, idx);
519    }
520}
521
522fn substitute_group_projection_expr(
523    expr: &mut Expr,
524    aggregates: &mut [crate::plan::GroupAgg],
525    visited: &mut std::collections::HashSet<String>,
526    literals: &[Literal],
527    idx: &mut usize,
528) {
529    if let Expr::Field(name) = expr {
530        if visited.insert(name.clone()) {
531            if let Some(aggregate) = aggregates.iter_mut().find(|agg| agg.output_name == *name) {
532                substitute_expr(&mut aggregate.argument, literals, idx);
533                return;
534            }
535        }
536    }
537    match expr {
538        Expr::BinaryOp(left, _, right) | Expr::Coalesce(left, right) => {
539            substitute_group_projection_expr(left, aggregates, visited, literals, idx);
540            substitute_group_projection_expr(right, aggregates, visited, literals, idx);
541        }
542        Expr::UnaryOp(_, inner) | Expr::Cast(inner, _) => {
543            substitute_group_projection_expr(inner, aggregates, visited, literals, idx);
544        }
545        Expr::ScalarFunc(_, args) => {
546            for arg in args {
547                substitute_group_projection_expr(arg, aggregates, visited, literals, idx);
548            }
549        }
550        Expr::InList { expr, list, .. } => {
551            substitute_group_projection_expr(expr, aggregates, visited, literals, idx);
552            for item in list {
553                substitute_group_projection_expr(item, aggregates, visited, literals, idx);
554            }
555        }
556        Expr::Case { whens, else_expr } => {
557            for (condition, result) in whens {
558                substitute_group_projection_expr(condition, aggregates, visited, literals, idx);
559                substitute_group_projection_expr(result, aggregates, visited, literals, idx);
560            }
561            if let Some(expr) = else_expr {
562                substitute_group_projection_expr(expr, aggregates, visited, literals, idx);
563            }
564        }
565        _ => substitute_expr(expr, literals, idx),
566    }
567}
568
569/// Count every `Expr::Literal` slot reachable from `plan` using the same
570/// walk order as [`substitute_plan`]. Used by `Engine::prepare` to validate
571/// that calls to `execute_prepared` pass the right number of literals, and
572/// to fail early if a caller prepares a query with zero literals (which
573/// would be a no-op for the prepared API — better to catch that up front).
574pub(crate) fn count_literal_slots(plan: &PlanNode) -> usize {
575    let mut n = 0usize;
576    count_plan(plan, &mut n);
577    n
578}
579
580fn count_plan(plan: &PlanNode, n: &mut usize) {
581    match plan {
582        PlanNode::SeqScan { .. } => {}
583        PlanNode::AliasScan { .. } => {}
584        PlanNode::IndexScan { key, .. } => count_expr(key, n),
585        PlanNode::RangeScan { start, end, .. } => {
586            if let Some((expr, _)) = start {
587                count_expr(expr, n);
588            }
589            if let Some((expr, _)) = end {
590                count_expr(expr, n);
591            }
592        }
593        PlanNode::ExprIndexScan { key, .. } => count_expr(key, n),
594        PlanNode::ExprRangeScan { start, end, .. } => {
595            if let Some((expr, _)) = start {
596                count_expr(expr, n);
597            }
598            if let Some((expr, _)) = end {
599                count_expr(expr, n);
600            }
601        }
602        PlanNode::OrderedExprIndexScan { limit, offset, .. } => {
603            count_expr(limit, n);
604            if let Some(offset) = offset {
605                count_expr(offset, n);
606            }
607        }
608        PlanNode::Filter { input, predicate } => {
609            count_plan(input, n);
610            count_expr(predicate, n);
611        }
612        PlanNode::Project { input, fields } => {
613            if let PlanNode::GroupBy {
614                input: group_input,
615                keys,
616                aggregates,
617                having: None,
618            } = input.as_ref()
619            {
620                count_plan(group_input, n);
621                for key in keys {
622                    count_expr(&key.expr, n);
623                }
624                let mut visited = std::collections::HashSet::new();
625                for field in fields {
626                    count_group_projection_expr(&field.expr, aggregates, &mut visited, n);
627                }
628            } else {
629                count_plan(input, n);
630                for f in fields {
631                    count_expr(&f.expr, n);
632                }
633            }
634        }
635        PlanNode::NestedProject { input, fields } => {
636            // Mirrors the substitute walk: parent pipeline, then fields left
637            // to right, with nested blocks contributing their residual slots.
638            count_plan(input, n);
639            for field in fields {
640                match field {
641                    crate::plan::NestedProjectField::Plain(f) => count_expr(&f.expr, n),
642                    crate::plan::NestedProjectField::Nested(nested) => {
643                        count_nested_projection(nested, n);
644                    }
645                }
646            }
647        }
648        PlanNode::Sort { input, keys } => {
649            count_plan(input, n);
650            for key in keys {
651                count_expr(&key.expr, n);
652            }
653        }
654        PlanNode::Limit { input, count } => {
655            // Mirror the substitute walk: `Limit(Offset(...))` descends
656            // into the offset's child first, then counts Limit.count,
657            // then Offset.count. Source order is
658            // [..., limit literal, offset literal].
659            if let PlanNode::Offset {
660                input: inner,
661                count: off_count,
662            } = input.as_ref()
663            {
664                count_plan(inner, n);
665                count_expr(count, n);
666                count_expr(off_count, n);
667            } else {
668                count_plan(input, n);
669                count_expr(count, n);
670            }
671        }
672        PlanNode::Offset { input, count } => {
673            count_plan(input, n);
674            count_expr(count, n);
675        }
676        PlanNode::Aggregate {
677            input, argument, ..
678        } => {
679            count_plan(input, n);
680            if let Some(argument) = argument {
681                count_expr(argument, n);
682            }
683        }
684        PlanNode::NestedLoopJoin {
685            left, right, on, ..
686        } => {
687            count_plan(left, n);
688            count_plan(right, n);
689            if let Some(pred) = on {
690                count_expr(pred, n);
691            }
692        }
693        PlanNode::Distinct { input } => count_plan(input, n),
694        PlanNode::GroupBy {
695            input,
696            keys,
697            aggregates,
698            having,
699        } => {
700            count_plan(input, n);
701            for key in keys {
702                count_expr(&key.expr, n);
703            }
704            for aggregate in aggregates {
705                count_expr(&aggregate.argument, n);
706            }
707            if let Some(pred) = having {
708                count_expr(pred, n);
709            }
710        }
711        PlanNode::Insert { rows, .. } => {
712            for assignments in rows {
713                for a in assignments {
714                    count_expr(&a.value, n);
715                }
716            }
717        }
718        PlanNode::Upsert {
719            assignments,
720            on_conflict,
721            ..
722        } => {
723            for a in assignments {
724                count_expr(&a.value, n);
725            }
726            for a in on_conflict {
727                count_expr(&a.value, n);
728            }
729        }
730        PlanNode::Update {
731            input, assignments, ..
732        } => {
733            count_plan(input, n);
734            for a in assignments {
735                count_expr(&a.value, n);
736            }
737        }
738        PlanNode::Delete { input, .. } => count_plan(input, n),
739        PlanNode::CreateTable { .. } => {}
740        PlanNode::AlterTable { .. } => {}
741        PlanNode::DropTable { .. } => {}
742        PlanNode::CreateView { .. } => {}
743        PlanNode::RefreshView { .. } => {}
744        PlanNode::DropView { .. } => {}
745        PlanNode::Window { input, windows } => {
746            count_plan(input, n);
747            for w in windows {
748                for arg in &w.args {
749                    count_expr(arg, n);
750                }
751                for expr in &w.partition_by {
752                    count_expr(expr, n);
753                }
754                for key in &w.order_by {
755                    count_expr(&key.expr, n);
756                }
757            }
758        }
759        PlanNode::Union { left, right, .. } => {
760            count_plan(left, n);
761            count_plan(right, n);
762        }
763        PlanNode::Explain { input } => {
764            count_plan(input, n);
765        }
766        PlanNode::ListTypes | PlanNode::Describe { .. } => {}
767        PlanNode::Begin | PlanNode::Commit | PlanNode::Rollback => {}
768    }
769}
770
771fn count_group_projection_expr(
772    expr: &Expr,
773    aggregates: &[crate::plan::GroupAgg],
774    visited: &mut std::collections::HashSet<String>,
775    n: &mut usize,
776) {
777    if let Expr::Field(name) = expr {
778        if visited.insert(name.clone()) {
779            if let Some(aggregate) = aggregates.iter().find(|agg| agg.output_name == *name) {
780                count_expr(&aggregate.argument, n);
781                return;
782            }
783        }
784    }
785    match expr {
786        Expr::BinaryOp(left, _, right) | Expr::Coalesce(left, right) => {
787            count_group_projection_expr(left, aggregates, visited, n);
788            count_group_projection_expr(right, aggregates, visited, n);
789        }
790        Expr::UnaryOp(_, inner) | Expr::Cast(inner, _) => {
791            count_group_projection_expr(inner, aggregates, visited, n);
792        }
793        Expr::ScalarFunc(_, args) => {
794            for arg in args {
795                count_group_projection_expr(arg, aggregates, visited, n);
796            }
797        }
798        Expr::InList { expr, list, .. } => {
799            count_group_projection_expr(expr, aggregates, visited, n);
800            for item in list {
801                count_group_projection_expr(item, aggregates, visited, n);
802            }
803        }
804        Expr::Case { whens, else_expr } => {
805            for (condition, result) in whens {
806                count_group_projection_expr(condition, aggregates, visited, n);
807                count_group_projection_expr(result, aggregates, visited, n);
808            }
809            if let Some(expr) = else_expr {
810                count_group_projection_expr(expr, aggregates, visited, n);
811            }
812        }
813        _ => count_expr(expr, n),
814    }
815}
816
817fn count_expr(expr: &Expr, n: &mut usize) {
818    match expr {
819        Expr::Literal(_) => *n += 1,
820        Expr::Field(_) | Expr::QualifiedField { .. } | Expr::Param(_) => {}
821        Expr::BinaryOp(l, _, r) => {
822            count_expr(l, n);
823            count_expr(r, n);
824        }
825        Expr::UnaryOp(_, inner) => count_expr(inner, n),
826        Expr::FunctionCall(_, inner, _) => count_expr(inner, n),
827        Expr::Coalesce(l, r) => {
828            count_expr(l, n);
829            count_expr(r, n);
830        }
831        Expr::InList { expr, list, .. } => {
832            count_expr(expr, n);
833            for item in list {
834                count_expr(item, n);
835            }
836        }
837        Expr::ScalarFunc(_, args) => {
838            for a in args {
839                count_expr(a, n);
840            }
841        }
842        Expr::Cast(inner, _) => count_expr(inner, n),
843        Expr::Case { whens, else_expr } => {
844            for (cond, result) in whens {
845                count_expr(cond, n);
846                count_expr(result, n);
847            }
848            if let Some(e) = else_expr {
849                count_expr(e, n);
850            }
851        }
852        Expr::InSubquery { expr, .. } => {
853            count_expr(expr, n);
854            // Subquery literals are not counted — the subquery is
855            // re-planned/executed separately.
856        }
857        Expr::ExistsSubquery { .. } => {
858            // Subquery literals are not counted — the subquery is
859            // re-planned/executed separately.
860        }
861        Expr::Window {
862            args,
863            partition_by,
864            order_by,
865            ..
866        } => {
867            for a in args {
868                count_expr(a, n);
869            }
870            for expr in partition_by {
871                count_expr(expr, n);
872            }
873            for key in order_by {
874                count_expr(&key.expr, n);
875            }
876        }
877        // JSON path segments are STRUCTURAL, never literal slots (#137): only
878        // the base can carry literals (it can't today — it is a Field — but
879        // recursing keeps this correct if the base grammar ever widens).
880        Expr::JsonPath { base, .. } => count_expr(base, n),
881        // Runtime-only literal (correlated/subquery substitution); never
882        // reaches the plan cache, and it occupies no source literal slot.
883        Expr::ValueLit(_) => {}
884        Expr::Null => {}
885        // Plans carrying nested projections are never inserted into the
886        // cache (see `plan_contains_nested_project`), so this node's inner
887        // literal slots are never walked.
888        Expr::NestedQuery(_) => {}
889    }
890}
891
892fn substitute_expr(expr: &mut Expr, literals: &[Literal], idx: &mut usize) {
893    match expr {
894        Expr::Literal(_) => {
895            // The cached plan held the *first* call's literal at this
896            // slot; replace with the new call's value at the matching
897            // source position.
898            *expr = Expr::Literal(literals[*idx].clone());
899            *idx += 1;
900        }
901        Expr::Field(_) | Expr::QualifiedField { .. } | Expr::Param(_) => {}
902        Expr::BinaryOp(l, _, r) => {
903            substitute_expr(l, literals, idx);
904            substitute_expr(r, literals, idx);
905        }
906        Expr::UnaryOp(_, inner) => {
907            substitute_expr(inner, literals, idx);
908        }
909        Expr::FunctionCall(_, inner, _) => {
910            substitute_expr(inner, literals, idx);
911        }
912        Expr::Coalesce(l, r) => {
913            substitute_expr(l, literals, idx);
914            substitute_expr(r, literals, idx);
915        }
916        Expr::InList { expr, list, .. } => {
917            substitute_expr(expr, literals, idx);
918            for item in list {
919                substitute_expr(item, literals, idx);
920            }
921        }
922        Expr::ScalarFunc(_, args) => {
923            for a in args {
924                substitute_expr(a, literals, idx);
925            }
926        }
927        Expr::Cast(inner, _) => substitute_expr(inner, literals, idx),
928        Expr::Case { whens, else_expr } => {
929            for (cond, result) in whens {
930                substitute_expr(cond, literals, idx);
931                substitute_expr(result, literals, idx);
932            }
933            if let Some(e) = else_expr {
934                substitute_expr(e, literals, idx);
935            }
936        }
937        Expr::InSubquery { expr, .. } => {
938            substitute_expr(expr, literals, idx);
939        }
940        Expr::ExistsSubquery { .. } => {
941            // Subquery has its own literal list; nothing to substitute
942            // at this level.
943        }
944        Expr::Window {
945            args,
946            partition_by,
947            order_by,
948            ..
949        } => {
950            for a in args {
951                substitute_expr(a, literals, idx);
952            }
953            for expr in partition_by {
954                substitute_expr(expr, literals, idx);
955            }
956            for key in order_by {
957                substitute_expr(&mut key.expr, literals, idx);
958            }
959        }
960        // JSON path segments are STRUCTURAL (#137): substitution only recurses
961        // into the base, mirroring `count_expr`, so the slot walk stays aligned.
962        Expr::JsonPath { base, .. } => substitute_expr(base, literals, idx),
963        // Runtime-only literal (correlated/subquery substitution); never
964        // reaches the plan cache, so there is nothing to substitute.
965        Expr::ValueLit(_) => {}
966        Expr::Null => {}
967        // Never cached (see `plan_contains_nested_project`); nothing to
968        // substitute.
969        Expr::NestedQuery(_) => {}
970    }
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use crate::canonicalize::canonicalize;
977    use crate::planner;
978
979    #[test]
980    fn test_cache_hit_substitutes_literal() {
981        let mut cache = PlanCache::new(100);
982
983        // First call: "User filter .id = 42" — miss, plan + insert.
984        let q1 = "User filter .id = 42";
985        let (h1, lits1) = canonicalize(q1).unwrap();
986        let p1 = planner::plan(q1).unwrap();
987        cache.insert(h1, p1, lits1.len());
988
989        // Second call with a different literal — should hit and produce
990        // a plan with the new literal substituted in.
991        let q2 = "User filter .id = 99";
992        let (h2, lits2) = canonicalize(q2).unwrap();
993        assert_eq!(h1, h2, "different literals must hash the same");
994
995        let plan = cache.get_with_substitution(h2, &lits2).expect("hit");
996
997        // The plan should be `IndexScan { key: Literal::Int(99) }`.
998        match plan {
999            PlanNode::IndexScan { key, .. } => {
1000                assert_eq!(key, Expr::Literal(Literal::Int(99)));
1001            }
1002            other => panic!("expected IndexScan, got {other:?}"),
1003        }
1004
1005        // First call's literal vector still holds 42, untouched — proves
1006        // we substituted on a clone, not the cached template.
1007        assert_eq!(lits1, vec![Literal::Int(42)]);
1008        assert_eq!(cache.hits, 1);
1009        assert_eq!(cache.misses, 0);
1010    }
1011
1012    #[test]
1013    fn test_subquery_plan_not_cached() {
1014        // #137: `canonicalize` collects the inner `100` literal at the token
1015        // level, but it lives in an un-walked subquery AST that
1016        // `substitute_plan` can't reach (`count_literal_slots` returns 0).
1017        // The counts disagree, so the cache must refuse to store the plan —
1018        // otherwise a later same-shape call with a different inner literal
1019        // would be served this plan's stale `100`.
1020        let mut cache = PlanCache::new(100);
1021        let q = "User filter .id in (Ord filter .total > 100 { .user_id })";
1022        let (h, lits) = canonicalize(q).unwrap();
1023        assert_eq!(lits.len(), 1, "canonicalize collects the inner literal");
1024        let plan = planner::plan(q).unwrap();
1025        assert_eq!(
1026            count_literal_slots(&plan),
1027            0,
1028            "the subquery literal is not a reachable substitution slot"
1029        );
1030        cache.insert(h, plan, lits.len());
1031        assert!(cache.is_empty(), "subquery plans must not be cached (#137)");
1032        assert!(cache.get_with_substitution(h, &lits).is_none());
1033    }
1034
1035    #[test]
1036    fn test_cache_miss_returns_none_and_bumps_counter() {
1037        let mut cache = PlanCache::new(100);
1038        assert!(cache.get_with_substitution(99999, &[]).is_none());
1039        assert_eq!(cache.misses, 1);
1040        assert_eq!(cache.hits, 0);
1041    }
1042
1043    #[test]
1044    fn test_multi_literal_filter_substitution() {
1045        let mut cache = PlanCache::new(100);
1046        let q1 = r#"User filter .age > 30 and .status = "active" { .name }"#;
1047        let (h1, lits1) = canonicalize(q1).unwrap();
1048        cache.insert(h1, planner::plan(q1).unwrap(), lits1.len());
1049
1050        let q2 = r#"User filter .age > 50 and .status = "pending" { .name }"#;
1051        let (h2, lits2) = canonicalize(q2).unwrap();
1052        let plan = cache.get_with_substitution(h2, &lits2).expect("hit");
1053
1054        // Walk the plan and pull out every literal — should be [50, "pending"].
1055        let mut found = Vec::new();
1056        collect_literals_for_test(&plan, &mut found);
1057        assert_eq!(
1058            found,
1059            vec![Literal::Int(50), Literal::String("pending".into()),]
1060        );
1061    }
1062
1063    #[test]
1064    fn test_grouped_join_having_is_not_cached_without_source_slot_ordinals() {
1065        // Qualified grouped joins have the same ambiguity as single-table
1066        // grouped HAVING: planning no longer retains whether HAVING appeared
1067        // before or after the projection, so matching slot counts alone do not
1068        // make literal replay safe.
1069        let mut cache = PlanCache::new(100);
1070        let q1 = "User as u join Order as o on u.id = o.user_id \
1071                  group u.status having count(o.total) > 1 { u.status, n: count(o.total) }";
1072        let (h1, lits1) = canonicalize(q1).unwrap();
1073        let p1 = planner::plan(q1).unwrap();
1074
1075        // Only the HAVING `1` is a substitutable literal; the slot count still
1076        // matches, proving the grouped-HAVING shape guard is what protects the
1077        // cache rather than an incidental count mismatch.
1078        assert_eq!(lits1.len(), 1, "only the HAVING literal is collected");
1079        assert_eq!(
1080            count_literal_slots(&p1),
1081            lits1.len(),
1082            "group keys/args are structural, so slots == literals (#137)"
1083        );
1084        cache.insert(h1, p1, lits1.len());
1085        assert!(cache.is_empty(), "grouped HAVING plans must not cache");
1086
1087        let q2 = "User as u join Order as o on u.id = o.user_id \
1088                  group u.status having count(o.total) > 5 { u.status, n: count(o.total) }";
1089        let (h2, lits2) = canonicalize(q2).unwrap();
1090        assert_eq!(h1, h2, "different HAVING literal must hash the same");
1091
1092        assert!(cache.get_with_substitution(h2, &lits2).is_none());
1093        assert_eq!(cache.hits, 0);
1094        assert_eq!(cache.misses, 1);
1095    }
1096
1097    #[test]
1098    fn json_path_slot_count_invariant() {
1099        // #137 property: over path-bearing queries, count_literal_slots(plan)
1100        // must equal the source literal count from canonicalize. Path segments
1101        // (keys and array indexes) are structural and must not inflate either
1102        // side, so the plan is cacheable.
1103        for q in [
1104            r#"Post filter .data->author->name = "x""#,
1105            r#"Post filter .data->tags->0 = "rust""#,
1106            r#"Post filter .data->age > 21 and .data->year = 2026"#,
1107            r#"Post filter .data->"weird key" = 1 { .id }"#,
1108            r#"Post { author: .data->author, first_tag: .data->tags->0 }"#,
1109        ] {
1110            let (_, lits) = canonicalize(q).unwrap();
1111            let plan = planner::plan(q).unwrap();
1112            assert_eq!(
1113                count_literal_slots(&plan),
1114                lits.len(),
1115                "slot count must equal source literal count for `{q}`"
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn json_path_plan_round_trips_cache() {
1122        // A path-bearing query caches on first call and, on a second call with
1123        // a DIFFERENT comparison literal but the SAME path, hits the cache and
1124        // substitutes the new literal (no panic, no stale value).
1125        let mut cache = PlanCache::new(100);
1126        let q1 = r#"Post filter .data->age > 21"#;
1127        let (h1, lits1) = canonicalize(q1).unwrap();
1128        let p1 = planner::plan(q1).unwrap();
1129        assert_eq!(count_literal_slots(&p1), lits1.len());
1130        cache.insert(h1, p1, lits1.len());
1131        assert_eq!(cache.len(), 1, "path plan must cache");
1132
1133        let q2 = r#"Post filter .data->age > 65"#;
1134        let (h2, lits2) = canonicalize(q2).unwrap();
1135        assert_eq!(h1, h2, "same path, different literal → same hash");
1136        let plan = cache.get_with_substitution(h2, &lits2).expect("hit");
1137
1138        let mut found = Vec::new();
1139        collect_literals_for_test(&plan, &mut found);
1140        assert_eq!(found, vec![Literal::Int(65)], "new literal substituted");
1141        assert_eq!(cache.hits, 1);
1142    }
1143
1144    #[test]
1145    fn json_path_different_path_is_a_cache_miss() {
1146        let mut cache = PlanCache::new(100);
1147        let q1 = r#"Post filter .data->age > 21"#;
1148        let (h1, lits1) = canonicalize(q1).unwrap();
1149        cache.insert(h1, planner::plan(q1).unwrap(), lits1.len());
1150
1151        // Different key → different shape → miss (would otherwise serve a plan
1152        // that walks the wrong path).
1153        let q2 = r#"Post filter .data->year > 21"#;
1154        let (h2, lits2) = canonicalize(q2).unwrap();
1155        assert!(
1156            cache.get_with_substitution(h2, &lits2).is_none(),
1157            "a different path must not hit the cached plan"
1158        );
1159    }
1160
1161    #[test]
1162    fn expression_aggregate_literal_substitutes_on_cache_hit() {
1163        let mut cache = PlanCache::new(8);
1164        let q1 = "Post group .data->kind { total: sum(.data->age + 1) }";
1165        let (h1, literals1) = canonicalize(q1).unwrap();
1166        let plan1 = planner::plan(q1).unwrap();
1167        assert_eq!(count_literal_slots(&plan1), literals1.len());
1168        cache.insert(h1, plan1, literals1.len());
1169
1170        let q2 = "Post group .data->kind { total: sum(.data->age + 7) }";
1171        let (h2, literals2) = canonicalize(q2).unwrap();
1172        assert_eq!(h1, h2);
1173        let plan = cache.get_with_substitution(h2, &literals2).expect("hit");
1174        let mut found = Vec::new();
1175        collect_literals_for_test(&plan, &mut found);
1176        assert_eq!(found, vec![Literal::Int(7)]);
1177    }
1178
1179    #[test]
1180    fn expression_index_equality_and_range_bounds_substitute_on_cache_hits() {
1181        let mut cache = PlanCache::new(8);
1182
1183        let q1 = "Post filter .data->age = 21";
1184        let (h1, literals1) = canonicalize(q1).unwrap();
1185        let plan1 = planner::plan(q1).unwrap();
1186        assert!(matches!(plan1, PlanNode::ExprIndexScan { .. }));
1187        assert_eq!(count_literal_slots(&plan1), literals1.len());
1188        cache.insert(h1, plan1, literals1.len());
1189
1190        let q2 = "Post filter .data->age = 65";
1191        let (h2, literals2) = canonicalize(q2).unwrap();
1192        assert_eq!(h1, h2);
1193        let plan = cache.get_with_substitution(h2, &literals2).expect("hit");
1194        let mut found = Vec::new();
1195        collect_literals_for_test(&plan, &mut found);
1196        assert_eq!(found, vec![Literal::Int(65)]);
1197
1198        let q3 = "Post filter .data->age >= 18 and .data->age < 65";
1199        let (h3, literals3) = canonicalize(q3).unwrap();
1200        let plan3 = planner::plan(q3).unwrap();
1201        assert!(matches!(plan3, PlanNode::ExprRangeScan { .. }));
1202        assert_eq!(count_literal_slots(&plan3), literals3.len());
1203        cache.insert(h3, plan3, literals3.len());
1204
1205        let q4 = "Post filter .data->age >= 25 and .data->age < 80";
1206        let (h4, literals4) = canonicalize(q4).unwrap();
1207        assert_eq!(h3, h4);
1208        let plan = cache.get_with_substitution(h4, &literals4).expect("hit");
1209        let mut found = Vec::new();
1210        collect_literals_for_test(&plan, &mut found);
1211        assert_eq!(found, vec![Literal::Int(25), Literal::Int(80)]);
1212    }
1213
1214    #[test]
1215    fn ordered_expression_scan_limit_offset_substitute_in_canonical_order() {
1216        let mut cache = PlanCache::new(8);
1217        let q1 = "Post order .data->age desc limit 10 offset 2";
1218        let (h1, literals1) = canonicalize(q1).unwrap();
1219        let plan1 = planner::plan(q1).unwrap();
1220        assert!(matches!(plan1, PlanNode::OrderedExprIndexScan { .. }));
1221        assert_eq!(count_literal_slots(&plan1), literals1.len());
1222        cache.insert(h1, plan1, literals1.len());
1223
1224        let q2 = "Post order .data->age desc limit 20 offset 3";
1225        let (h2, literals2) = canonicalize(q2).unwrap();
1226        assert_eq!(h1, h2);
1227        let plan = cache.get_with_substitution(h2, &literals2).expect("hit");
1228        let mut found = Vec::new();
1229        collect_literals_for_test(&plan, &mut found);
1230        assert_eq!(found, vec![Literal::Int(20), Literal::Int(3)]);
1231    }
1232
1233    #[test]
1234    fn test_update_by_pk_substitution() {
1235        let mut cache = PlanCache::new(100);
1236        let q1 = "User filter .id = 1 update { age := 100 }";
1237        let (h1, lits1) = canonicalize(q1).unwrap();
1238        cache.insert(h1, planner::plan(q1).unwrap(), lits1.len());
1239
1240        let q2 = "User filter .id = 7 update { age := 200 }";
1241        let (h2, lits2) = canonicalize(q2).unwrap();
1242        let plan = cache.get_with_substitution(h2, &lits2).expect("hit");
1243
1244        let mut found = Vec::new();
1245        collect_literals_for_test(&plan, &mut found);
1246        assert_eq!(found, vec![Literal::Int(7), Literal::Int(200)]);
1247    }
1248
1249    #[test]
1250    fn test_insert_substitution() {
1251        let mut cache = PlanCache::new(100);
1252        let q1 = r#"insert User { id := 1, name := "Alice", age := 20 }"#;
1253        let (h1, lits1) = canonicalize(q1).unwrap();
1254        cache.insert(h1, planner::plan(q1).unwrap(), lits1.len());
1255
1256        let q2 = r#"insert User { id := 2, name := "Bob", age := 30 }"#;
1257        let (h2, lits2) = canonicalize(q2).unwrap();
1258        let plan = cache.get_with_substitution(h2, &lits2).expect("hit");
1259
1260        let mut found = Vec::new();
1261        collect_literals_for_test(&plan, &mut found);
1262        assert_eq!(
1263            found,
1264            vec![
1265                Literal::Int(2),
1266                Literal::String("Bob".into()),
1267                Literal::Int(30),
1268            ]
1269        );
1270    }
1271
1272    /// #151/#140-class: `uuid("…")` const-fold sugar plans as
1273    /// `Cast(Literal::String, Uuid)` — one substitutable slot. It must cache
1274    /// AND rebind the inner string on a same-shape second call (a bulk load).
1275    #[test]
1276    fn test_insert_uuid_sugar_cacheable_and_substitutes() {
1277        let mut cache = PlanCache::new(100);
1278        let q1 = r#"insert User { id := uuid("00000000-0000-0000-0000-000000000001") }"#;
1279        let (h1, lits1) = canonicalize(q1).unwrap();
1280        assert_eq!(lits1.len(), 1, "the inner string is the only literal");
1281        let plan = planner::plan(q1).unwrap();
1282        assert_eq!(
1283            count_literal_slots(&plan),
1284            1,
1285            "Cast wrapping a Literal is a reachable substitution slot"
1286        );
1287        cache.insert(h1, plan, lits1.len());
1288        assert!(!cache.is_empty(), "uuid() insert must be cacheable");
1289
1290        let q2 = r#"insert User { id := uuid("00000000-0000-0000-0000-000000000002") }"#;
1291        let (h2, lits2) = canonicalize(q2).unwrap();
1292        assert_eq!(h1, h2, "same shape hashes identically");
1293        let subst = cache.get_with_substitution(h2, &lits2).expect("hit");
1294
1295        let mut found = Vec::new();
1296        collect_literals_for_test(&subst, &mut found);
1297        assert_eq!(
1298            found,
1299            vec![Literal::String(
1300                "00000000-0000-0000-0000-000000000002".into()
1301            )],
1302            "the second call's uuid must be substituted in, not the cached one"
1303        );
1304    }
1305
1306    /// Two-arg `cast(.x, "uuid")` bakes the target into the AST but leaves the
1307    /// `"uuid"` string as a collected literal with no matching plan slot, so
1308    /// the count-mismatch guard refuses to cache it (pre-existing behavior,
1309    /// now confirmed for the uuid target).
1310    #[test]
1311    fn test_two_arg_cast_uuid_not_cached() {
1312        let mut cache = PlanCache::new(100);
1313        let q = r#"User filter .id = cast(.other, "uuid")"#;
1314        let (h, lits) = canonicalize(q).unwrap();
1315        assert_eq!(
1316            lits.len(),
1317            1,
1318            "canonicalize collects the cast-target string"
1319        );
1320        let plan = planner::plan(q).unwrap();
1321        assert_eq!(
1322            count_literal_slots(&plan),
1323            0,
1324            "the cast target is baked into the AST, not a slot"
1325        );
1326        cache.insert(h, plan, lits.len());
1327        assert!(cache.is_empty(), "cast(x, \"uuid\") must not be cached");
1328    }
1329
1330    #[test]
1331    fn test_eviction_on_capacity() {
1332        let mut cache = PlanCache::new(2);
1333        let q1 = "User";
1334        let q2 = "User filter .age > 1";
1335        let _q3 = "User filter .age > 2";
1336        // q3 has same canonical as q2 — won't trigger eviction.
1337        // Use a different shape to force eviction.
1338        let q3_distinct = "User filter .id = 5";
1339
1340        let (h1, lits1) = canonicalize(q1).unwrap();
1341        let (h2, lits2) = canonicalize(q2).unwrap();
1342        let (h3, lits3) = canonicalize(q3_distinct).unwrap();
1343        cache.insert(h1, planner::plan(q1).unwrap(), lits1.len());
1344        cache.insert(h2, planner::plan(q2).unwrap(), lits2.len());
1345        // Cache full → inserting a third *new* shape should clear.
1346        cache.insert(h3, planner::plan(q3_distinct).unwrap(), lits3.len());
1347        assert!(cache.cache.contains_key(&h3));
1348        assert_eq!(cache.cache.len(), 1);
1349    }
1350
1351    /// Test helper — depth-first walk that pulls out every Literal in the
1352    /// same order `substitute_plan` would visit them. Used to verify
1353    /// substitution actually wrote to the right slots.
1354    fn collect_literals_for_test(plan: &PlanNode, out: &mut Vec<Literal>) {
1355        match plan {
1356            PlanNode::SeqScan { .. } => {}
1357            PlanNode::AliasScan { .. } => {}
1358            PlanNode::IndexScan { key, .. } => collect_expr_literals(key, out),
1359            PlanNode::RangeScan { start, end, .. } => {
1360                if let Some((expr, _)) = start {
1361                    collect_expr_literals(expr, out);
1362                }
1363                if let Some((expr, _)) = end {
1364                    collect_expr_literals(expr, out);
1365                }
1366            }
1367            PlanNode::ExprIndexScan { key, .. } => collect_expr_literals(key, out),
1368            PlanNode::ExprRangeScan { start, end, .. } => {
1369                if let Some((expr, _)) = start {
1370                    collect_expr_literals(expr, out);
1371                }
1372                if let Some((expr, _)) = end {
1373                    collect_expr_literals(expr, out);
1374                }
1375            }
1376            PlanNode::OrderedExprIndexScan { limit, offset, .. } => {
1377                collect_expr_literals(limit, out);
1378                if let Some(offset) = offset {
1379                    collect_expr_literals(offset, out);
1380                }
1381            }
1382            PlanNode::Filter { input, predicate } => {
1383                collect_literals_for_test(input, out);
1384                collect_expr_literals(predicate, out);
1385            }
1386            PlanNode::Project { input, fields } => {
1387                collect_literals_for_test(input, out);
1388                for f in fields {
1389                    collect_expr_literals(&f.expr, out);
1390                }
1391            }
1392            PlanNode::NestedProject { input, fields } => {
1393                fn collect_nested(nested: &crate::plan::NestedProjection, out: &mut Vec<Literal>) {
1394                    if let Some(residual) = &nested.residual {
1395                        collect_expr_literals(residual, out);
1396                    }
1397                    if let Some(limit) = &nested.limit {
1398                        collect_expr_literals(limit, out);
1399                    }
1400                    if let Some(offset) = &nested.offset {
1401                        collect_expr_literals(offset, out);
1402                    }
1403                    for field in &nested.fields {
1404                        if let crate::plan::NestedField::Nested(inner) = field {
1405                            collect_nested(inner, out);
1406                        }
1407                    }
1408                }
1409                collect_literals_for_test(input, out);
1410                for field in fields {
1411                    match field {
1412                        crate::plan::NestedProjectField::Plain(f) => {
1413                            collect_expr_literals(&f.expr, out);
1414                        }
1415                        crate::plan::NestedProjectField::Nested(nested) => {
1416                            collect_nested(nested, out);
1417                        }
1418                    }
1419                }
1420            }
1421            PlanNode::Sort { input, keys } => {
1422                collect_literals_for_test(input, out);
1423                for key in keys {
1424                    collect_expr_literals(&key.expr, out);
1425                }
1426            }
1427            PlanNode::Limit { input, count } => {
1428                collect_literals_for_test(input, out);
1429                collect_expr_literals(count, out);
1430            }
1431            PlanNode::Offset { input, count } => {
1432                collect_literals_for_test(input, out);
1433                collect_expr_literals(count, out);
1434            }
1435            PlanNode::Aggregate {
1436                input, argument, ..
1437            } => {
1438                collect_literals_for_test(input, out);
1439                if let Some(argument) = argument {
1440                    collect_expr_literals(argument, out);
1441                }
1442            }
1443            PlanNode::NestedLoopJoin {
1444                left, right, on, ..
1445            } => {
1446                collect_literals_for_test(left, out);
1447                collect_literals_for_test(right, out);
1448                if let Some(pred) = on {
1449                    collect_expr_literals(pred, out);
1450                }
1451            }
1452            PlanNode::Insert { rows, .. } => {
1453                for assignments in rows {
1454                    for a in assignments {
1455                        collect_expr_literals(&a.value, out);
1456                    }
1457                }
1458            }
1459            PlanNode::Upsert {
1460                assignments,
1461                on_conflict,
1462                ..
1463            } => {
1464                for a in assignments {
1465                    collect_expr_literals(&a.value, out);
1466                }
1467                for a in on_conflict {
1468                    collect_expr_literals(&a.value, out);
1469                }
1470            }
1471            PlanNode::Update {
1472                input, assignments, ..
1473            } => {
1474                collect_literals_for_test(input, out);
1475                for a in assignments {
1476                    collect_expr_literals(&a.value, out);
1477                }
1478            }
1479            PlanNode::Distinct { input } => collect_literals_for_test(input, out),
1480            PlanNode::GroupBy {
1481                input,
1482                keys,
1483                aggregates,
1484                having,
1485            } => {
1486                collect_literals_for_test(input, out);
1487                for key in keys {
1488                    collect_expr_literals(&key.expr, out);
1489                }
1490                for aggregate in aggregates {
1491                    collect_expr_literals(&aggregate.argument, out);
1492                }
1493                if let Some(pred) = having {
1494                    collect_expr_literals(pred, out);
1495                }
1496            }
1497            PlanNode::Delete { input, .. } => collect_literals_for_test(input, out),
1498            PlanNode::CreateTable { .. } => {}
1499            PlanNode::AlterTable { .. } => {}
1500            PlanNode::DropTable { .. } => {}
1501            PlanNode::CreateView { .. } => {}
1502            PlanNode::RefreshView { .. } => {}
1503            PlanNode::DropView { .. } => {}
1504            PlanNode::Window { input, windows } => {
1505                collect_literals_for_test(input, out);
1506                for w in windows {
1507                    for arg in &w.args {
1508                        collect_expr_literals(arg, out);
1509                    }
1510                    for expr in &w.partition_by {
1511                        collect_expr_literals(expr, out);
1512                    }
1513                    for key in &w.order_by {
1514                        collect_expr_literals(&key.expr, out);
1515                    }
1516                }
1517            }
1518            PlanNode::Union { left, right, .. } => {
1519                collect_literals_for_test(left, out);
1520                collect_literals_for_test(right, out);
1521            }
1522            PlanNode::Explain { input } => {
1523                collect_literals_for_test(input, out);
1524            }
1525            PlanNode::ListTypes | PlanNode::Describe { .. } => {}
1526            PlanNode::Begin | PlanNode::Commit | PlanNode::Rollback => {}
1527        }
1528    }
1529
1530    fn collect_expr_literals(expr: &Expr, out: &mut Vec<Literal>) {
1531        match expr {
1532            Expr::Literal(l) => out.push(l.clone()),
1533            Expr::Field(_) | Expr::QualifiedField { .. } | Expr::Param(_) => {}
1534            Expr::BinaryOp(l, _, r) => {
1535                collect_expr_literals(l, out);
1536                collect_expr_literals(r, out);
1537            }
1538            Expr::UnaryOp(_, inner) => collect_expr_literals(inner, out),
1539            Expr::FunctionCall(_, inner, _) => collect_expr_literals(inner, out),
1540            Expr::Coalesce(l, r) => {
1541                collect_expr_literals(l, out);
1542                collect_expr_literals(r, out);
1543            }
1544            Expr::InList { expr, list, .. } => {
1545                collect_expr_literals(expr, out);
1546                for item in list {
1547                    collect_expr_literals(item, out);
1548                }
1549            }
1550            Expr::ScalarFunc(_, args) => {
1551                for a in args {
1552                    collect_expr_literals(a, out);
1553                }
1554            }
1555            Expr::Cast(inner, _) => collect_expr_literals(inner, out),
1556            Expr::Case { whens, else_expr } => {
1557                for (cond, result) in whens {
1558                    collect_expr_literals(cond, out);
1559                    collect_expr_literals(result, out);
1560                }
1561                if let Some(e) = else_expr {
1562                    collect_expr_literals(e, out);
1563                }
1564            }
1565            Expr::InSubquery { expr, .. } => {
1566                collect_expr_literals(expr, out);
1567            }
1568            Expr::ExistsSubquery { .. } => {}
1569            Expr::Window {
1570                args,
1571                partition_by,
1572                order_by,
1573                ..
1574            } => {
1575                for a in args {
1576                    collect_expr_literals(a, out);
1577                }
1578                for expr in partition_by {
1579                    collect_expr_literals(expr, out);
1580                }
1581                for key in order_by {
1582                    collect_expr_literals(&key.expr, out);
1583                }
1584            }
1585            // JSON path segments are structural, never literals — mirror
1586            // count_expr/substitute_expr and recurse into the base only.
1587            Expr::JsonPath { base, .. } => collect_expr_literals(base, out),
1588            Expr::ValueLit(_) => {}
1589            Expr::Null => {}
1590            // Never cached; mirrors count_expr/substitute_expr.
1591            Expr::NestedQuery(_) => {}
1592        }
1593    }
1594}