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