vibesql_executor/cache/prepared_statement/
plan.rs

1//! Query plan caching for prepared statements
2//!
3//! This module provides cached execution plans that bypass the full query planning
4//! pipeline for simple query patterns. When a prepared statement is created, we
5//! analyze the AST to detect patterns that can use fast execution paths.
6//!
7//! ## Supported Patterns
8//!
9//! ### Primary Key Point Lookup
10//!
11//! Queries like `SELECT * FROM table WHERE pk_col = ?` are detected and cached with:
12//! - Table name
13//! - Primary key column indices in schema
14//! - Parameter indices for PK values
15//! - Projection column indices (or wildcard flag)
16//!
17//! At execution time, this allows direct index lookup without:
18//! - Re-parsing the AST
19//! - Checking if it's a simple query
20//! - Resolving table/column names
21//! - Building schema objects
22//!
23//! ## Performance Impact
24//!
25//! For simple point SELECT via prepared statement:
26//! - Without plan caching: ~60µs (AST manipulation, pattern detection, table lookup)
27//! - With plan caching: ~1-5µs (direct index lookup)
28//!
29//! ## Cache Invalidation
30//!
31//! Cached plans are invalidated when:
32//! - DDL modifies the referenced table (schema change)
33//! - The table is dropped
34//! - Indexes are added/removed
35
36use std::sync::{Arc, OnceLock};
37
38use vibesql_ast::{
39    DeleteStmt, Expression, FromClause, SelectItem, SelectStmt, Statement, WhereClause,
40};
41
42/// Cached execution plan for a prepared statement
43#[derive(Debug, Clone)]
44pub enum CachedPlan {
45    /// Simple primary key point lookup
46    /// SELECT [cols] FROM table WHERE pk_col = ? [AND pk_col2 = ? ...]
47    PkPointLookup(PkPointLookupPlan),
48
49    /// Simple fast-path eligible query
50    /// Caches the result of is_simple_point_query() check to avoid
51    /// recomputing it on every execution.
52    SimpleFastPath(SimpleFastPathPlan),
53
54    /// Simple primary key DELETE
55    /// DELETE FROM table WHERE pk_col = ? [AND pk_col2 = ? ...]
56    PkDelete(PkDeletePlan),
57
58    /// Query doesn't match any fast-path pattern
59    /// Fall back to standard execution
60    Standard,
61}
62
63impl CachedPlan {
64    /// Check if this is a cacheable fast-path plan
65    pub fn is_fast_path(&self) -> bool {
66        !matches!(self, CachedPlan::Standard)
67    }
68}
69
70/// Cached plan for simple fast-path eligible queries
71///
72/// This caches the result of `is_simple_point_query()` to avoid
73/// recomputing it on every execution. Provides moderate speedup for
74/// queries that pass fast-path validation but don't match the
75/// specialized PkPointLookup pattern.
76///
77/// ## Performance Optimization (#3780)
78///
79/// The `resolved_columns` field caches column names derived from the SELECT list
80/// after the first execution. This eliminates repeated:
81/// - Table lookups via `database.get_table()`
82/// - SELECT list iteration and column name derivation
83/// - Schema column iteration for wildcards
84///
85/// This reduces per-query overhead from ~5-15µs to ~1-2µs for repeated executions.
86#[derive(Debug, Clone)]
87pub struct SimpleFastPathPlan {
88    /// Table name (normalized to uppercase for case-insensitive matching)
89    pub table_name: String,
90
91    /// Lazily-cached column names derived from the SELECT list
92    /// Populated on first execution to avoid repeated column name derivation
93    resolved_columns: Arc<OnceLock<Arc<[String]>>>,
94}
95
96impl SimpleFastPathPlan {
97    /// Create a new SimpleFastPathPlan with the given table name
98    pub fn new(table_name: String) -> Self {
99        Self { table_name, resolved_columns: Arc::new(OnceLock::new()) }
100    }
101
102    /// Get or initialize the cached column names
103    ///
104    /// The resolver function is called only on the first invocation and derives
105    /// column names from the SELECT list and table schema.
106    pub fn get_or_resolve_columns<F>(&self, resolver: F) -> Option<&Arc<[String]>>
107    where
108        F: FnOnce() -> Option<Vec<String>>,
109    {
110        self.resolved_columns.get_or_init(|| {
111            // If resolution fails, we return an empty array as a sentinel
112            resolver().map(|v| v.into()).unwrap_or_else(|| Arc::from([]))
113        });
114
115        // Check if we got a valid resolution (non-empty)
116        self.resolved_columns.get().filter(|cols| !cols.is_empty())
117    }
118
119    /// Check if columns have been resolved
120    pub fn is_resolved(&self) -> bool {
121        self.resolved_columns.get().is_some()
122    }
123}
124
125/// Cached plan for primary key point lookup queries
126#[derive(Debug, Clone)]
127pub struct PkPointLookupPlan {
128    /// Table name (normalized to uppercase for case-insensitive matching)
129    pub table_name: String,
130
131    /// Primary key column names in order
132    pub pk_columns: Vec<String>,
133
134    /// Mapping from parameter index (0-based) to PK column index
135    /// e.g., for `WHERE pk1 = ? AND pk2 = ?`, this would be [(0, 0), (1, 1)]
136    pub param_to_pk_col: Vec<(usize, usize)>,
137
138    /// Projection type
139    pub projection: ProjectionPlan,
140
141    /// Lazily-cached resolved projection (column indices and output names)
142    /// Populated on first execution to avoid repeated schema lookups
143    resolved: Arc<OnceLock<ResolvedProjection>>,
144}
145
146/// Resolved projection information cached after first execution
147///
148/// This avoids repeated O(n) column name lookups on every query.
149#[derive(Debug, Clone)]
150pub struct ResolvedProjection {
151    /// Column indices in the source table for projection
152    pub column_indices: Vec<usize>,
153    /// Output column names (may include aliases)
154    pub column_names: Arc<[String]>,
155}
156
157impl PkPointLookupPlan {
158    /// Get or initialize the resolved projection for this plan
159    ///
160    /// The resolver function is called only on the first invocation and takes
161    /// the projection plan to resolve column names to indices.
162    pub fn get_or_resolve<F>(&self, resolver: F) -> Option<&ResolvedProjection>
163    where
164        F: FnOnce(&ProjectionPlan) -> Option<ResolvedProjection>,
165    {
166        self.resolved.get_or_init(|| {
167            // We need to handle the case where resolution fails
168            // Using a sentinel value or Option would complicate the API
169            // Instead, if resolution fails, we return a "failed" marker
170            resolver(&self.projection).unwrap_or(ResolvedProjection {
171                column_indices: vec![],
172                column_names: Arc::from([]),
173            })
174        });
175
176        // Check if we got a valid resolution (non-empty)
177        self.resolved.get().filter(|r| {
178            !r.column_names.is_empty() || matches!(self.projection, ProjectionPlan::Wildcard)
179        })
180    }
181
182    /// Check if projection has been resolved
183    pub fn is_resolved(&self) -> bool {
184        self.resolved.get().is_some()
185    }
186}
187
188/// How to project columns from the result
189#[derive(Debug, Clone)]
190pub enum ProjectionPlan {
191    /// SELECT * - return all columns
192    Wildcard,
193
194    /// SELECT col1, col2, ... - return specific columns by index
195    Columns(Vec<ColumnProjection>),
196}
197
198/// A single column in the projection
199#[derive(Debug, Clone)]
200pub struct ColumnProjection {
201    /// Column name in the table
202    pub column_name: String,
203
204    /// Output alias (if specified via AS)
205    pub alias: Option<String>,
206}
207
208/// Cached plan for primary key DELETE statements
209/// DELETE FROM table WHERE pk_col = ? [AND pk_col2 = ? ...]
210///
211/// This plan bypasses:
212/// - AST cloning during parameter binding
213/// - Schema cloning during execution
214/// - Re-checking the DELETE pattern on every execution
215///
216/// At execution time, we extract PK values directly from params and call delete_by_pk_fast.
217#[derive(Debug, Clone)]
218pub struct PkDeletePlan {
219    /// Table name (normalized to uppercase for case-insensitive matching)
220    pub table_name: String,
221
222    /// Primary key column names in order (for validation)
223    pub pk_columns: Vec<String>,
224
225    /// Mapping from parameter index (0-based) to PK column index
226    /// e.g., for `WHERE id = ?`, this would be [(0, 0)]
227    pub param_to_pk_col: Vec<(usize, usize)>,
228
229    /// Cached validation result: true = fast path is valid (no triggers/FKs)
230    /// Uses Arc<OnceLock> so the cache survives Clone
231    fast_path_valid: Arc<OnceLock<bool>>,
232}
233
234impl PkDeletePlan {
235    /// Create a new PkDeletePlan
236    pub fn new(
237        table_name: String,
238        pk_columns: Vec<String>,
239        param_to_pk_col: Vec<(usize, usize)>,
240    ) -> Self {
241        Self { table_name, pk_columns, param_to_pk_col, fast_path_valid: Arc::new(OnceLock::new()) }
242    }
243
244    /// Build the PK values array from parameters
245    pub fn build_pk_values(
246        &self,
247        params: &[vibesql_types::SqlValue],
248    ) -> Vec<vibesql_types::SqlValue> {
249        let mut pk_values = vec![vibesql_types::SqlValue::Null; self.pk_columns.len()];
250        for &(param_idx, pk_col_idx) in &self.param_to_pk_col {
251            if param_idx < params.len() && pk_col_idx < pk_values.len() {
252                pk_values[pk_col_idx] = params[param_idx].clone();
253            }
254        }
255        pk_values
256    }
257
258    /// Get cached validation result, if available
259    pub fn is_fast_path_valid(&self) -> Option<bool> {
260        self.fast_path_valid.get().copied()
261    }
262
263    /// Set validation result (can only be called once)
264    /// Returns the cached value (either newly set or existing)
265    pub fn set_fast_path_valid(&self, valid: bool) -> bool {
266        *self.fast_path_valid.get_or_init(|| valid)
267    }
268}
269
270/// Analyze a prepared statement and create a cached plan if possible
271pub fn analyze_for_plan(stmt: &Statement) -> CachedPlan {
272    match stmt {
273        Statement::Select(select) => analyze_select(select),
274        Statement::Delete(delete) => analyze_delete(delete),
275        _ => CachedPlan::Standard,
276    }
277}
278
279/// Analyze a SELECT statement for caching opportunities
280fn analyze_select(stmt: &SelectStmt) -> CachedPlan {
281    // First, try to create a PkPointLookup plan for the most optimized path
282    if let Some(plan) = try_analyze_pk_lookup(stmt) {
283        return CachedPlan::PkPointLookup(plan);
284    }
285
286    // Next, check if the query is eligible for the fast path
287    // This caches the result of is_simple_point_query() to avoid recomputing it every execution
288    if crate::select::is_simple_point_query(stmt) {
289        if let Some(table_name) = extract_single_table_name(stmt) {
290            return CachedPlan::SimpleFastPath(SimpleFastPathPlan::new(table_name.to_lowercase()));
291        }
292    }
293
294    CachedPlan::Standard
295}
296
297/// Analyze a DELETE statement for PK delete optimization
298fn analyze_delete(stmt: &DeleteStmt) -> CachedPlan {
299    // Must have a WHERE clause
300    let where_clause = match &stmt.where_clause {
301        Some(WhereClause::Condition(expr)) => expr,
302        _ => return CachedPlan::Standard,
303    };
304
305    // Extract parameter-to-column mappings from WHERE clause
306    let param_mappings = match extract_pk_param_mappings(where_clause) {
307        Some(mappings) if !mappings.is_empty() => mappings,
308        _ => return CachedPlan::Standard,
309    };
310
311    // Build the plan
312    let pk_columns: Vec<String> = param_mappings.iter().map(|(_, col)| col.clone()).collect();
313    let param_to_pk_col: Vec<(usize, usize)> = param_mappings
314        .iter()
315        .enumerate()
316        .map(|(pk_idx, (param_idx, _))| (*param_idx, pk_idx))
317        .collect();
318
319    CachedPlan::PkDelete(PkDeletePlan::new(
320        stmt.table_name.to_lowercase(),
321        pk_columns,
322        param_to_pk_col,
323    ))
324}
325
326/// Try to analyze a SELECT for PK point lookup optimization
327fn try_analyze_pk_lookup(stmt: &SelectStmt) -> Option<PkPointLookupPlan> {
328    // Must have exactly one table in FROM (no joins, no subqueries)
329    let table_name = match &stmt.from {
330        Some(FromClause::Table { name, alias: None, .. }) => name.clone(),
331        _ => return None,
332    };
333
334    // No complex clauses
335    if stmt.with_clause.is_some()
336        || stmt.set_operation.is_some()
337        || stmt.group_by.is_some()
338        || stmt.having.is_some()
339        || stmt.distinct
340        || stmt.order_by.is_some()
341        || stmt.limit.is_some()
342        || stmt.offset.is_some()
343        || stmt.into_table.is_some()
344        || stmt.into_variables.is_some()
345    {
346        return None;
347    }
348
349    // Check SELECT list - must be wildcard or simple column references
350    let projection = analyze_select_list(&stmt.select_list)?;
351
352    // Must have a WHERE clause with PK equality predicates
353    let where_clause = stmt.where_clause.as_ref()?;
354
355    // Extract parameter-to-column mappings from WHERE clause
356    let param_mappings = extract_pk_param_mappings(where_clause)?;
357    if param_mappings.is_empty() {
358        return None;
359    }
360
361    // Build the plan
362    // Note: We don't validate PK columns here because we don't have DB access.
363    // The Session will validate at prepare time and fall back if not valid.
364    let pk_columns: Vec<String> = param_mappings.iter().map(|(_, col)| col.clone()).collect();
365    let param_to_pk_col: Vec<(usize, usize)> = param_mappings
366        .iter()
367        .enumerate()
368        .map(|(pk_idx, (param_idx, _))| (*param_idx, pk_idx))
369        .collect();
370
371    Some(PkPointLookupPlan {
372        table_name: table_name.to_lowercase(),
373        pk_columns,
374        param_to_pk_col,
375        projection,
376        resolved: Arc::new(OnceLock::new()),
377    })
378}
379
380/// Extract the single table name from a simple FROM clause
381fn extract_single_table_name(stmt: &SelectStmt) -> Option<String> {
382    match &stmt.from {
383        Some(FromClause::Table { name, .. }) => Some(name.clone()),
384        _ => None,
385    }
386}
387
388/// Analyze SELECT list and return projection plan if it's simple
389fn analyze_select_list(select_list: &[SelectItem]) -> Option<ProjectionPlan> {
390    if select_list.len() == 1 {
391        if let SelectItem::Wildcard { .. } = &select_list[0] {
392            return Some(ProjectionPlan::Wildcard);
393        }
394    }
395
396    // Check each item is a simple column reference
397    let mut columns = Vec::with_capacity(select_list.len());
398    for item in select_list {
399        match item {
400            SelectItem::Wildcard { .. } => {
401                // Mixed wildcard and columns - fall back
402                return None;
403            }
404            SelectItem::QualifiedWildcard { .. } => {
405                // table.* - fall back for now
406                return None;
407            }
408            SelectItem::Expression { expr, alias, .. } => {
409                let column_name = match expr {
410                    Expression::ColumnRef(col_id) => col_id.column_canonical().to_string(),
411                    _ => {
412                        // Complex expression - fall back
413                        return None;
414                    }
415                };
416                columns.push(ColumnProjection { column_name, alias: alias.clone() });
417            }
418        }
419    }
420
421    Some(ProjectionPlan::Columns(columns))
422}
423
424/// Extract parameter-to-column mappings from WHERE clause
425///
426/// Returns a list of (parameter_index, column_name) pairs in order.
427/// For `WHERE pk1 = ? AND pk2 = ?`, returns [(0, "pk1"), (1, "pk2")]
428fn extract_pk_param_mappings(expr: &Expression) -> Option<Vec<(usize, String)>> {
429    let mut mappings = Vec::new();
430    collect_pk_param_mappings(expr, &mut mappings)?;
431
432    // Sort by parameter index to ensure consistent ordering
433    mappings.sort_by_key(|(idx, _)| *idx);
434
435    Some(mappings)
436}
437
438/// Recursively collect parameter-to-column mappings
439fn collect_pk_param_mappings(expr: &Expression, mappings: &mut Vec<(usize, String)>) -> Option<()> {
440    match expr {
441        Expression::BinaryOp { left, op, right } => {
442            match op {
443                vibesql_ast::BinaryOperator::And => {
444                    // Recurse into both sides
445                    collect_pk_param_mappings(left, mappings)?;
446                    collect_pk_param_mappings(right, mappings)?;
447                }
448                vibesql_ast::BinaryOperator::Equal => {
449                    // Check for col = ? pattern
450                    if let Some(mapping) = extract_column_placeholder_pair(left, right) {
451                        mappings.push(mapping);
452                    } else {
453                        // Non-placeholder equality - not a fast-path query
454                        return None;
455                    }
456                }
457                _ => {
458                    // Other operators (OR, <, >, etc.) - not a simple PK lookup
459                    return None;
460                }
461            }
462        }
463        _ => {
464            // Other expression types - not supported
465            return None;
466        }
467    }
468    Some(())
469}
470
471/// Extract (param_index, column_name) from an equality expression
472fn extract_column_placeholder_pair(
473    left: &Expression,
474    right: &Expression,
475) -> Option<(usize, String)> {
476    // Try col = ?
477    if let Expression::ColumnRef(col_id) = left {
478        if let Expression::Placeholder(idx) = right {
479            return Some((*idx, col_id.column_canonical().to_string()));
480        }
481    }
482
483    // Try ? = col
484    if let Expression::Placeholder(idx) = left {
485        if let Expression::ColumnRef(col_id) = right {
486            return Some((*idx, col_id.column_canonical().to_string()));
487        }
488    }
489
490    None
491}
492
493#[cfg(test)]
494mod tests {
495    use vibesql_parser::Parser;
496
497    use super::*;
498
499    fn parse_to_plan(sql: &str) -> CachedPlan {
500        let stmt = Parser::parse_sql(sql).unwrap();
501        analyze_for_plan(&stmt)
502    }
503
504    #[test]
505    fn test_simple_pk_lookup() {
506        let plan = parse_to_plan("SELECT * FROM users WHERE id = ?");
507        match plan {
508            CachedPlan::PkPointLookup(p) => {
509                assert_eq!(p.table_name, "users");
510                // Parser normalizes identifiers to lowercase
511                assert_eq!(p.pk_columns, vec!["id"]);
512                assert_eq!(p.param_to_pk_col, vec![(0, 0)]);
513                assert!(matches!(p.projection, ProjectionPlan::Wildcard));
514            }
515            _ => panic!("Expected PkPointLookup"),
516        }
517    }
518
519    #[test]
520    fn test_composite_pk_lookup() {
521        let plan = parse_to_plan("SELECT * FROM orders WHERE customer_id = ? AND order_id = ?");
522        match plan {
523            CachedPlan::PkPointLookup(p) => {
524                assert_eq!(p.table_name, "orders");
525                // Parser normalizes identifiers to lowercase
526                assert_eq!(p.pk_columns, vec!["customer_id", "order_id"]);
527                assert_eq!(p.param_to_pk_col.len(), 2);
528            }
529            _ => panic!("Expected PkPointLookup"),
530        }
531    }
532
533    #[test]
534    fn test_projected_columns() {
535        let plan = parse_to_plan("SELECT name, email FROM users WHERE id = ?");
536        match plan {
537            CachedPlan::PkPointLookup(p) => {
538                match p.projection {
539                    ProjectionPlan::Columns(cols) => {
540                        assert_eq!(cols.len(), 2);
541                        // Parser normalizes identifiers to lowercase
542                        assert_eq!(cols[0].column_name, "name");
543                        assert_eq!(cols[1].column_name, "email");
544                    }
545                    _ => panic!("Expected Columns projection"),
546                }
547            }
548            _ => panic!("Expected PkPointLookup"),
549        }
550    }
551
552    #[test]
553    fn test_not_cacheable_join() {
554        let plan =
555            parse_to_plan("SELECT * FROM users u JOIN orders o ON u.id = o.user_id WHERE u.id = ?");
556        assert!(matches!(plan, CachedPlan::Standard));
557    }
558
559    #[test]
560    fn test_not_cacheable_aggregate() {
561        let plan = parse_to_plan("SELECT COUNT(*) FROM users WHERE id = ?");
562        assert!(matches!(plan, CachedPlan::Standard));
563    }
564
565    #[test]
566    fn test_not_cacheable_order_by() {
567        let plan = parse_to_plan("SELECT * FROM users WHERE id = ? ORDER BY name");
568        assert!(matches!(plan, CachedPlan::Standard));
569    }
570
571    #[test]
572    fn test_not_cacheable_or() {
573        let plan = parse_to_plan("SELECT * FROM users WHERE id = ? OR name = ?");
574        assert!(matches!(plan, CachedPlan::Standard));
575    }
576
577    #[test]
578    fn test_literal_gets_simple_fast_path() {
579        // Queries with literals don't get PkPointLookup (requires placeholders),
580        // but they do get SimpleFastPath since they pass is_simple_point_query()
581        let plan = parse_to_plan("SELECT * FROM users WHERE id = 1");
582        assert!(matches!(plan, CachedPlan::SimpleFastPath(_)));
583    }
584
585    #[test]
586    fn test_delete_pk_lookup() {
587        let plan = parse_to_plan("DELETE FROM sbtest1 WHERE id = ?");
588        match plan {
589            CachedPlan::PkDelete(p) => {
590                assert_eq!(p.table_name, "sbtest1");
591                assert_eq!(p.pk_columns, vec!["id"]);
592                assert_eq!(p.param_to_pk_col, vec![(0, 0)]);
593            }
594            other => panic!("Expected PkDelete, got {:?}", other),
595        }
596    }
597
598    #[test]
599    fn test_delete_without_where_not_fast_path() {
600        let plan = parse_to_plan("DELETE FROM users");
601        assert!(matches!(plan, CachedPlan::Standard));
602    }
603}