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