Skip to main content

radixdb_executor/dispatch/
cache.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Query Cache for parsed SQL statements
16//!
17//! This module provides a cache for previously parsed SQL queries,
18//! storing the parse tree to avoid the overhead of parsing the same
19//! query multiple times.
20//!
21//! # Example
22//!
23//! ```ignore
24//! let cache = QueryCache::<()>::new(1000);
25//!
26//! // First query - will be parsed and cached
27//! if let Some(plan) = cache.get("SELECT * FROM users") {
28//!     // Use cached plan
29//! } else {
30//!     // Parse and cache
31//!     let stmt = parse(sql);
32//!     cache.put(sql, stmt, false, 0);
33//! }
34//!
35//! // Second identical query - retrieved from cache
36//! let plan = cache.get("SELECT * FROM users").unwrap();
37//! ```
38
39use radixdb_core::time_compat::Instant;
40use std::borrow::Cow;
41use std::sync::{Arc, RwLock};
42
43use radixdb_core::SmartString;
44use rustc_hash::FxHashMap;
45
46use crate::context::ExecutionContext;
47use radixdb_core::{Error, Result};
48use radixdb_sql::ast::Statement;
49
50pub use crate::compiled_plan::{
51    CompiledCountDistinct, CompiledCountStar, CompiledExecution, CompiledInsert, CompiledPkDelete,
52    CompiledPkLookup, CompiledPkUpdate, CompiledUpdateColumn, PkValueSource, UpdateValueSource,
53};
54
55/// Convert to lowercase without allocation if already lowercase.
56#[inline]
57fn to_lowercase_cow(s: &str) -> Cow<'_, str> {
58    if s.bytes().all(|b| !b.is_ascii_uppercase()) {
59        Cow::Borrowed(s)
60    } else {
61        Cow::Owned(s.to_lowercase())
62    }
63}
64
65/// Exact parameter shape owned by one parsed statement.
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct ParameterContract {
68    positional_count: usize,
69    named_params: Arc<Vec<SmartString>>,
70}
71
72impl ParameterContract {
73    #[doc(hidden)]
74    pub fn from_statement(statement: &Statement) -> Self {
75        let mut positional_count = 0;
76        let mut named_params = Vec::new();
77        radixdb_sql::ast::walk_statement_tree(statement, &mut |expression| {
78            if let radixdb_sql::ast::Expression::Parameter(parameter) = expression {
79                if let Some(name) = parameter.name.strip_prefix(':') {
80                    named_params.push(SmartString::new(name));
81                } else {
82                    positional_count = positional_count.max(parameter.index);
83                }
84            }
85        });
86        named_params.sort_unstable();
87        named_params.dedup();
88        Self {
89            positional_count,
90            named_params: Arc::new(named_params),
91        }
92    }
93
94    #[doc(hidden)]
95    pub fn from_statements(statements: &[Statement]) -> Self {
96        let mut positional_count = 0;
97        let mut named_params = Vec::new();
98        for statement in statements {
99            let contract = Self::from_statement(statement);
100            positional_count = positional_count.max(contract.positional_count);
101            named_params.extend(contract.named_params.iter().cloned());
102        }
103        named_params.sort_unstable();
104        named_params.dedup();
105        Self {
106            positional_count,
107            named_params: Arc::new(named_params),
108        }
109    }
110
111    /// Number of positional values required by the statement.
112    pub fn positional_count(&self) -> usize {
113        self.positional_count
114    }
115
116    /// Exact set of named bindings required by the statement.
117    pub fn named_params(&self) -> &[SmartString] {
118        &self.named_params
119    }
120
121    pub fn has_params(&self) -> bool {
122        self.positional_count != 0 || !self.named_params.is_empty()
123    }
124
125    #[doc(hidden)]
126    pub fn validate(&self, context: &ExecutionContext) -> Result<()> {
127        let provided_positional = context.params().len();
128        if provided_positional != self.positional_count {
129            return Err(Error::invalid_argument(format!(
130                "statement requires exactly {} positional parameters, got {}",
131                self.positional_count, provided_positional
132            )));
133        }
134
135        let provided_named = context.named_params();
136        let required_user_count = self
137            .named_params
138            .iter()
139            .filter(|name| !crate::context::is_system_context_name(name.as_str()))
140            .count();
141        let provided_user_count = provided_named
142            .keys()
143            .filter(|name| !crate::context::is_system_context_name(name))
144            .count();
145        if provided_user_count != required_user_count
146            || self
147                .named_params
148                .iter()
149                .any(|name| !provided_named.contains_key(name.as_str()))
150        {
151            let required = self
152                .named_params
153                .iter()
154                .filter(|name| !crate::context::is_system_context_name(name.as_str()))
155                .map(SmartString::as_str)
156                .collect::<Vec<_>>()
157                .join(", ");
158            return Err(Error::invalid_argument(format!(
159                "statement requires exactly the named parameters [{required}]"
160            )));
161        }
162        Ok(())
163    }
164}
165
166/// Default cache size (number of cached plans)
167pub const DEFAULT_CACHE_SIZE: usize = 1000;
168
169/// Lightweight reference to a cached plan for query execution.
170/// Contains only what's needed to execute: the immutable statement and param info.
171#[derive(Debug, Clone)]
172pub struct CachedPlanRef<B = ()> {
173    /// The parsed AST (cheap Arc clone)
174    #[doc(hidden)]
175    pub statement: Arc<Statement>,
176    /// Whether this query has parameter placeholders
177    pub(crate) has_params: bool,
178    /// Number of parameters required
179    pub(crate) param_count: usize,
180    /// Exact positional/named binding contract.
181    #[doc(hidden)]
182    pub parameter_contract: ParameterContract,
183    /// Shared reference to compiled execution state (lazily populated)
184    #[doc(hidden)]
185    pub compiled: Arc<RwLock<CompiledExecution>>,
186    /// Schema-bound logical reference graph, populated before physical paths.
187    #[doc(hidden)]
188    pub reference_expand: Arc<RwLock<B>>,
189    owner_token: Arc<()>,
190}
191
192impl<B> CachedPlanRef<B> {
193    /// Return the immutable parsed statement owned by this plan.
194    pub fn statement(&self) -> &Statement {
195        &self.statement
196    }
197
198    /// Whether the statement requires positional or named bindings.
199    pub fn has_params(&self) -> bool {
200        self.has_params
201    }
202
203    /// Exact number of positional bindings required by the statement.
204    pub fn param_count(&self) -> usize {
205        self.param_count
206    }
207
208    /// Read-only binding contract associated atomically with the plan.
209    pub fn parameter_contract(&self) -> &ParameterContract {
210        &self.parameter_contract
211    }
212
213    /// Shared compiled fast-path state owned by this parsed plan.
214    #[doc(hidden)]
215    pub fn compiled_state(&self) -> &Arc<RwLock<CompiledExecution>> {
216        &self.compiled
217    }
218
219    /// Higher-layer schema binding cache associated with this parsed plan.
220    #[doc(hidden)]
221    pub fn binding_cache(&self) -> &Arc<RwLock<B>> {
222        &self.reference_expand
223    }
224}
225
226/// Represents a parsed and prepared statement stored in the cache
227#[derive(Debug, Clone)]
228pub struct CachedQueryPlan<B = ()> {
229    /// The parsed AST (wrapped in Arc for cheap cloning - statements are immutable)
230    pub statement: Arc<Statement>,
231    /// Original query text
232    pub query_text: SmartString,
233    /// Last time this plan was used (monotonic)
234    pub last_used: Instant,
235    /// Number of times this plan has been used
236    pub usage_count: u64,
237    /// Whether this query has parameter placeholders
238    pub has_params: bool,
239    /// Number of parameters required
240    pub param_count: usize,
241    /// Exact positional/named binding contract.
242    pub parameter_contract: ParameterContract,
243    /// Normalized query text (cache key)
244    pub normalized_query: SmartString,
245    /// Compiled execution state (lazily populated on first execution)
246    pub compiled: Arc<RwLock<CompiledExecution>>,
247    /// Schema-bound logical reference graph (lazily populated).
248    #[doc(hidden)]
249    pub reference_expand: Arc<RwLock<B>>,
250}
251
252impl<B: Default> CachedQueryPlan<B> {
253    /// Create a new cached query plan
254    pub fn new(
255        statement: Arc<Statement>,
256        query_text: SmartString,
257        _has_params: bool,
258        _param_count: usize,
259        normalized_query: SmartString,
260    ) -> Self {
261        let parameter_contract = ParameterContract::from_statement(&statement);
262        let has_params = parameter_contract.has_params();
263        let param_count = parameter_contract.positional_count();
264        Self {
265            statement,
266            query_text,
267            last_used: Instant::now(),
268            usage_count: 1,
269            has_params,
270            param_count,
271            parameter_contract,
272            normalized_query,
273            compiled: Arc::new(RwLock::new(CompiledExecution::Unknown)),
274            reference_expand: Arc::new(RwLock::new(B::default())),
275        }
276    }
277}
278
279/// Query cache for parsed SQL statements
280///
281/// Provides thread-safe caching of parsed SQL queries to avoid
282/// the overhead of parsing the same query multiple times.
283pub struct QueryCache<B = ()> {
284    /// Cached plans indexed by normalized query text (FxHash for fast string hashing)
285    plans: RwLock<FxHashMap<SmartString, CachedQueryPlan<B>>>,
286    /// Maximum number of cached plans
287    max_size: usize,
288    /// Factor to determine how many plans to prune when cache is full (0.0-1.0)
289    prune_factor: f64,
290    owner_token: Arc<()>,
291}
292
293impl<B: Default> QueryCache<B> {
294    /// Create a new query cache with the given maximum size
295    pub fn new(max_size: usize) -> Self {
296        Self {
297            plans: RwLock::new(FxHashMap::default()),
298            max_size,
299            prune_factor: 0.2, // Prune 20% of entries when cache is full
300            owner_token: Arc::new(()),
301        }
302    }
303
304    /// Create a new query cache with default size
305    pub fn default_sized() -> Self {
306        Self::new(DEFAULT_CACHE_SIZE)
307    }
308
309    /// Get a cached plan for a query if available
310    ///
311    /// Returns a cheap Arc clone of the cached statement and metadata.
312    /// The Statement is immutable and shared via Arc.
313    ///
314    /// A hit updates recency and usage so eviction reflects actual access.
315    pub fn get(&self, query: &str) -> Option<CachedPlanRef<B>> {
316        let normalized = normalize_query(query);
317
318        let mut plans = self.plans.write().ok()?;
319        let plan = plans.get_mut(normalized.as_ref())?;
320        plan.last_used = Instant::now();
321        plan.usage_count = plan.usage_count.saturating_add(1);
322
323        // Only clone the Arc (cheap) and copy the small fields
324        Some(CachedPlanRef {
325            statement: plan.statement.clone(),
326            has_params: plan.has_params,
327            param_count: plan.param_count,
328            parameter_contract: plan.parameter_contract.clone(),
329            compiled: plan.compiled.clone(), // Share compiled state
330            reference_expand: plan.reference_expand.clone(),
331            owner_token: Arc::clone(&self.owner_token),
332        })
333    }
334
335    /// Add a plan to the cache
336    ///
337    /// Returns a lightweight reference to the cached plan (CachedPlanRef).
338    /// This avoids cloning SmartStrings since callers only need the statement
339    /// and compiled execution state.
340    pub fn put(
341        &self,
342        query: &str,
343        statement: Arc<Statement>,
344        _has_params: bool,
345        _param_count: usize,
346    ) -> CachedPlanRef<B> {
347        let parameter_contract = ParameterContract::from_statement(&statement);
348        let has_params = parameter_contract.has_params();
349        let param_count = parameter_contract.positional_count();
350        let normalized = normalize_query(query);
351        // Convert Cow to SmartString for storage
352        let normalized_key: SmartString = match normalized {
353            Cow::Borrowed(s) => SmartString::new(s),
354            Cow::Owned(s) => SmartString::new(&s),
355        };
356
357        // Create the compiled state upfront - shared between stored plan and returned ref
358        let compiled = Arc::new(RwLock::new(CompiledExecution::Unknown));
359        let reference_expand = Arc::new(RwLock::new(B::default()));
360
361        if self.max_size > 0 {
362            if let Ok(mut plans) = self.plans.write() {
363                // Check if we need to prune the cache
364                if plans.len() >= self.max_size {
365                    self.prune_cache(&mut plans);
366                }
367
368                // Insert plan into map - use normalized_key for both key and field
369                // Only clone normalized_key for the map key; move it into the plan struct
370                let key_for_insert = normalized_key.clone();
371                plans.insert(
372                    key_for_insert,
373                    CachedQueryPlan {
374                        statement: statement.clone(),
375                        query_text: SmartString::new(query),
376                        last_used: Instant::now(),
377                        usage_count: 1,
378                        has_params,
379                        param_count,
380                        parameter_contract: parameter_contract.clone(),
381                        normalized_query: normalized_key, // moved, not cloned
382                        compiled: compiled.clone(),       // Arc clone - cheap
383                        reference_expand: reference_expand.clone(),
384                    },
385                );
386            }
387        }
388
389        // Return lightweight reference - only Arc clones, no SmartString clones
390        CachedPlanRef {
391            statement,
392            has_params,
393            param_count,
394            parameter_contract,
395            compiled,
396            reference_expand,
397            owner_token: Arc::clone(&self.owner_token),
398        }
399    }
400
401    #[doc(hidden)]
402    pub fn owns(&self, plan: &CachedPlanRef<B>) -> bool {
403        Arc::ptr_eq(&self.owner_token, &plan.owner_token)
404    }
405
406    /// Clear the cache
407    pub fn clear(&self) {
408        if let Ok(mut plans) = self.plans.write() {
409            plans.clear();
410        }
411    }
412
413    /// Invalidate all cached plans that reference a specific table
414    /// Called after DDL operations (ALTER TABLE, DROP TABLE, etc.)
415    pub fn invalidate_table(&self, table_name: &str) {
416        let table_lower = to_lowercase_cow(table_name);
417        if let Ok(mut plans) = self.plans.write() {
418            // Remove plans that reference this table
419            // Check both the compiled lookup table name and query text
420            plans.retain(|_key, plan| {
421                // Check if compiled lookup references this table
422                if let Ok(compiled) = plan.compiled.read() {
423                    match &*compiled {
424                        CompiledExecution::PkLookup(lookup)
425                            if lookup.table_name == *table_lower =>
426                        {
427                            return false; // Remove this plan
428                        }
429                        CompiledExecution::CountDistinct(cd) if cd.table_name == *table_lower => {
430                            return false; // Remove this plan
431                        }
432                        CompiledExecution::CountStar(cs) if cs.table_name == *table_lower => {
433                            return false; // Remove this plan
434                        }
435                        _ => {}
436                    }
437                }
438                // Also check query text for table reference (simple heuristic)
439                let query_lower = to_lowercase_cow(&plan.query_text);
440                !query_lower.contains(&format!(" {} ", &*table_lower))
441                    && !query_lower.contains(&format!(" {}\n", &*table_lower))
442                    && !query_lower.contains(&format!(" {};", &*table_lower))
443                    && !query_lower.contains(&format!("from {}", &*table_lower))
444                    && !query_lower.contains(&format!("join {}", &*table_lower))
445                    && !query_lower.contains(&format!("into {}", &*table_lower))
446                    && !query_lower.contains(&format!("update {}", &*table_lower))
447            });
448        }
449    }
450
451    /// Get the number of plans in the cache
452    pub fn size(&self) -> usize {
453        self.plans.read().map(|p| p.len()).unwrap_or(0)
454    }
455
456    /// Get cache statistics
457    pub fn stats(&self) -> CacheStats {
458        let plans = match self.plans.read() {
459            Ok(p) => p,
460            Err(_) => {
461                return CacheStats {
462                    size: 0,
463                    max_size: self.max_size,
464                    total_usage: 0,
465                    avg_usage: 0.0,
466                }
467            }
468        };
469
470        let size = plans.len();
471        let total_usage: u64 = plans.values().map(|p| p.usage_count).sum();
472        let avg_usage = if size > 0 {
473            total_usage as f64 / size as f64
474        } else {
475            0.0
476        };
477
478        CacheStats {
479            size,
480            max_size: self.max_size,
481            total_usage,
482            avg_usage,
483        }
484    }
485
486    /// Prune the least recently used entries when the cache is full
487    fn prune_cache(&self, plans: &mut FxHashMap<SmartString, CachedQueryPlan<B>>) {
488        // Calculate how many entries to remove
489        let num_to_remove = ((self.max_size as f64) * self.prune_factor).ceil() as usize;
490        let num_to_remove = num_to_remove.max(1);
491
492        if plans.is_empty() {
493            return;
494        }
495
496        // Build a list of references sorted by last used time and usage count
497        // Use references to avoid cloning all keys
498        let mut entries: Vec<(&SmartString, Instant, u64)> = plans
499            .iter()
500            .map(|(k, p)| (k, p.last_used, p.usage_count))
501            .collect();
502
503        // Sort by last used (oldest first), then by usage count (least used first)
504        entries.sort_unstable_by(|a, b| a.1.cmp(&b.1).then_with(|| a.2.cmp(&b.2)));
505
506        // Collect only the keys to remove (clone only what we need)
507        let keys_to_remove: Vec<SmartString> = entries
508            .into_iter()
509            .take(num_to_remove.min(plans.len()))
510            .map(|(k, _, _)| k.clone())
511            .collect();
512
513        // Remove the oldest/least used entries
514        for key in keys_to_remove {
515            plans.remove(&key);
516        }
517    }
518}
519
520impl<B: Default> Default for QueryCache<B> {
521    fn default() -> Self {
522        Self::default_sized()
523    }
524}
525
526/// Cache statistics
527#[derive(Debug, Clone)]
528pub struct CacheStats {
529    /// Current number of cached plans
530    pub size: usize,
531    /// Maximum cache size
532    pub max_size: usize,
533    /// Total usage count across all cached plans
534    pub total_usage: u64,
535    /// Average usage per cached plan
536    pub avg_usage: f64,
537}
538
539/// Return the exact SQL source used as the cache identity.
540///
541/// SQL whitespace is not globally insignificant: it can occur inside string
542/// literals, quoted identifiers and comments. A lexer-unaware normalizer can
543/// therefore map different programs to the same cached AST. Exact source bytes
544/// are the conservative, allocation-free identity.
545#[inline]
546fn normalize_query(query: &str) -> std::borrow::Cow<'_, str> {
547    std::borrow::Cow::Borrowed(query)
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use radixdb_sql::ast::{Expression, GroupByClause, SelectStatement, StarExpression};
554    use radixdb_sql::token::{Position, Token, TokenType};
555
556    fn dummy_token() -> Token {
557        Token::new(TokenType::Keyword, "SELECT", Position::new(0, 1, 1))
558    }
559
560    fn star_token() -> Token {
561        Token::new(TokenType::Operator, "*", Position::new(0, 1, 1))
562    }
563
564    fn create_test_statement() -> Arc<Statement> {
565        Arc::new(Statement::Select(SelectStatement {
566            token: dummy_token(),
567            with: None,
568            distinct: false,
569            distinct_on: vec![],
570            columns: vec![Expression::Star(StarExpression {
571                token: star_token(),
572            })],
573            table_expr: None,
574            where_clause: None,
575            group_by: GroupByClause::default(),
576            having: None,
577            window_defs: vec![],
578            order_by: vec![],
579            limit: None,
580            offset: None,
581            set_operations: vec![],
582        }))
583    }
584
585    #[test]
586    fn test_cache_put_get() {
587        let cache = QueryCache::<()>::new(100);
588        let stmt = create_test_statement();
589
590        // Put in cache
591        cache.put("SELECT * FROM users", stmt.clone(), false, 0);
592        assert_eq!(cache.size(), 1);
593
594        // Get from cache
595        let plan = cache.get("SELECT * FROM users");
596        assert!(plan.is_some());
597
598        let plan = plan.unwrap();
599        assert!(!plan.has_params);
600        assert_eq!(plan.param_count, 0);
601    }
602
603    #[test]
604    fn test_cache_miss() {
605        let cache = QueryCache::<()>::new(100);
606
607        let plan = cache.get("SELECT * FROM users");
608        assert!(plan.is_none());
609    }
610
611    #[test]
612    fn test_cache_usage_count() {
613        let cache = QueryCache::<()>::new(100);
614        let stmt = create_test_statement();
615
616        cache.put("SELECT * FROM users", stmt, false, 0);
617
618        // Get multiple times and verify operational usage accounting.
619        for _ in 0..5 {
620            cache.get("SELECT * FROM users");
621        }
622
623        let stats = cache.stats();
624        assert_eq!(stats.total_usage, 6);
625    }
626
627    #[test]
628    fn r5_l03_cache_budgets_and_lru_follow_runtime_usage_query_plan() {
629        let cache = QueryCache::<()>::new(2);
630        let stmt = create_test_statement();
631        cache.put("SELECT 'a'", stmt.clone(), false, 0);
632        std::thread::sleep(std::time::Duration::from_millis(1));
633        cache.put("SELECT 'b'", stmt.clone(), false, 0);
634        assert!(cache.get("SELECT 'a'").is_some());
635        std::thread::sleep(std::time::Duration::from_millis(1));
636        cache.put("SELECT 'c'", stmt, false, 0);
637
638        assert!(cache.get("SELECT 'a'").is_some(), "hot plan was evicted");
639        assert!(cache.get("SELECT 'b'").is_none(), "cold plan survived");
640        assert!(cache.get("SELECT 'c'").is_some());
641    }
642
643    #[test]
644    fn test_cache_clear() {
645        let cache = QueryCache::<()>::new(100);
646        let stmt = create_test_statement();
647
648        cache.put("SELECT * FROM users", stmt, false, 0);
649        assert_eq!(cache.size(), 1);
650
651        cache.clear();
652        assert_eq!(cache.size(), 0);
653    }
654
655    #[test]
656    fn test_cache_pruning() {
657        let cache = QueryCache::<()>::new(5);
658        let stmt = create_test_statement();
659
660        // Fill the cache
661        for i in 0..10 {
662            let query = format!("SELECT * FROM table{}", i);
663            cache.put(&query, stmt.clone(), false, 0);
664        }
665
666        // Cache should have pruned some entries
667        assert!(cache.size() <= 5);
668    }
669
670    #[test]
671    fn test_normalize_query() {
672        assert_eq!(
673            normalize_query("  SELECT  *  FROM  users  "),
674            "  SELECT  *  FROM  users  "
675        );
676        assert_eq!(
677            normalize_query("SELECT\n*\nFROM\nusers"),
678            "SELECT\n*\nFROM\nusers"
679        );
680        assert_ne!(
681            normalize_query("SELECT 'a  b'"),
682            normalize_query("SELECT 'a b'")
683        );
684    }
685
686    #[test]
687    fn test_normalize_query_utf8() {
688        // UTF-8 characters should be preserved in fast path (no normalization needed)
689        assert_eq!(
690            normalize_query("SELECT * FROM t WHERE name = '日本語'"),
691            "SELECT * FROM t WHERE name = '日本語'"
692        );
693
694        // UTF-8 and its surrounding source bytes are preserved exactly.
695        assert_eq!(
696            normalize_query("SELECT  *  FROM t WHERE name = '日本語'"),
697            "SELECT  *  FROM t WHERE name = '日本語'"
698        );
699
700        // Mixed ASCII and UTF-8 with tabs/newlines
701        assert_eq!(
702            normalize_query("SELECT\t*\tFROM t WHERE city = '東京' AND country = '中国'"),
703            "SELECT\t*\tFROM t WHERE city = '東京' AND country = '中国'"
704        );
705
706        // Emoji should also be preserved
707        assert_eq!(
708            normalize_query("SELECT  *  FROM t WHERE emoji = '🎉'"),
709            "SELECT  *  FROM t WHERE emoji = '🎉'"
710        );
711    }
712
713    #[test]
714    fn test_distinct_source_has_distinct_cache_key() {
715        let cache = QueryCache::<()>::new(100);
716        let stmt = create_test_statement();
717
718        // Put with one formatting
719        cache.put("SELECT * FROM users", stmt, false, 0);
720
721        // Lexer-unaware whitespace folding is unsafe, so different source misses.
722        let plan = cache.get("  SELECT  *  FROM  users  ");
723        assert!(plan.is_none());
724    }
725
726    #[test]
727    fn test_parameterized_query() {
728        let cache = QueryCache::<()>::new(100);
729        let stmt = Arc::new(
730            radixdb_sql::parse_sql("SELECT * FROM users WHERE id = $1")
731                .expect("parse parameterized statement")
732                .into_iter()
733                .next()
734                .expect("one statement"),
735        );
736
737        cache.put("SELECT * FROM users WHERE id = $1", stmt, false, 0);
738
739        let plan = cache.get("SELECT * FROM users WHERE id = $1").unwrap();
740        assert!(plan.has_params);
741        assert_eq!(plan.param_count, 1);
742    }
743
744    #[test]
745    fn test_cache_stats() {
746        let cache = QueryCache::<()>::new(100);
747        let stmt = create_test_statement();
748
749        cache.put("SELECT 1", stmt.clone(), false, 0);
750        cache.put("SELECT 2", stmt.clone(), false, 0);
751
752        // Access first query more.
753        for _ in 0..5 {
754            cache.get("SELECT 1");
755        }
756
757        let stats = cache.stats();
758        assert_eq!(stats.size, 2);
759        assert_eq!(stats.max_size, 100);
760        assert_eq!(stats.total_usage, 7);
761    }
762
763    #[test]
764    fn v2_r5_zero_and_one_capacity_are_hard_bounds() {
765        let stmt = create_test_statement();
766        let disabled = QueryCache::<()>::new(0);
767        disabled.put("SELECT 1", stmt.clone(), false, 0);
768        assert_eq!(disabled.size(), 0);
769        assert!(disabled.get("SELECT 1").is_none());
770
771        let one = QueryCache::<()>::new(1);
772        one.put("SELECT 1", stmt.clone(), false, 0);
773        one.put("SELECT 2", stmt, false, 0);
774        assert_eq!(one.size(), 1);
775        assert!(one.get("SELECT 2").is_some());
776    }
777
778    #[test]
779    fn test_cache_thread_safety() {
780        use std::sync::Arc;
781        use std::thread;
782
783        let cache = Arc::new(QueryCache::<()>::new(1000));
784        let stmt = create_test_statement();
785
786        // Pre-populate
787        cache.put("SELECT * FROM users", stmt.clone(), false, 0);
788
789        let mut handles = vec![];
790
791        // Spawn multiple reader threads
792        for _ in 0..10 {
793            let cache = Arc::clone(&cache);
794            handles.push(thread::spawn(move || {
795                for _ in 0..100 {
796                    cache.get("SELECT * FROM users");
797                }
798            }));
799        }
800
801        // Spawn writer threads
802        for i in 0..5 {
803            let cache = Arc::clone(&cache);
804            let stmt = stmt.clone();
805            handles.push(thread::spawn(move || {
806                for j in 0..20 {
807                    let query = format!("SELECT * FROM table{}_{}", i, j);
808                    cache.put(&query, stmt.clone(), false, 0);
809                }
810            }));
811        }
812
813        for handle in handles {
814            handle.join().unwrap();
815        }
816
817        // Cache should still be functional
818        assert!(cache.get("SELECT * FROM users").is_some());
819    }
820}