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