Skip to main content

oxisql_cache/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4//! `oxisql-cache` — SQL query-result and prepared-plan caching for OxiSQL.
5//!
6//! [`SqlQueryCache`] caches query result sets and [`SqlPlanCache`] caches
7//! prepared statement plans, both built on top of the LRU + TTL primitives in
8//! [`oxistore_cache`].
9//!
10//! This crate supersedes the former `sql` feature of `oxistore-cache`: the
11//! SQL-layer cache types now live here, on the `oxisql` side of the
12//! dependency graph, so that `oxistore-cache` no longer needs to depend on
13//! `oxisql-core` (which broke a cross-repo dependency cycle between the
14//! `oxisql` and `oxistore` repositories). The sanctioned dependency direction
15//! is `oxisql-cache` → `oxistore-cache`.
16//!
17//! # SqlQueryCache
18//!
19//! Caches `RowSet` results produced by SQL queries.  Keys are normalised
20//! query strings (leading/trailing whitespace collapsed, consecutive spaces
21//! reduced to a single space, and ASCII letters upper-cased).  Values are
22//! cloned `RowSet` instances.
23//!
24//! TTL support allows stale result sets to be expired automatically; the next
25//! `get` after expiry returns `None` and the stale entry is removed lazily.
26//!
27//! # SqlPlanCache
28//!
29//! Stores an opaque, caller-supplied prepared-statement representation `P`
30//! (e.g. a compiled query plan or a byte buffer).  Unlike [`SqlQueryCache`]
31//! there is no default TTL — plans are typically valid for the lifetime of the
32//! connection and are only evicted by capacity pressure or explicit removal.
33//!
34//! Both caches are **not** thread-safe on their own; wrap with
35//! [`oxistore_cache::sync::SyncCache`] or [`oxistore_cache::sharded::ShardedCache`]
36//! for concurrent use.
37
38use std::time::Duration;
39
40use oxisql_core::RowSet;
41
42use oxistore_cache::Cache;
43use oxistore_cache::LruCache;
44
45// ── Key normalisation ─────────────────────────────────────────────────────────
46
47/// Normalise a SQL query string for use as a cache key.
48///
49/// Normalisation rules (applied in order):
50/// 1. Trim leading and trailing ASCII whitespace.
51/// 2. Collapse internal runs of whitespace (space, tab, newline, CR, FF) to a
52///    single ASCII space `' '`.
53/// 3. Convert ASCII letters to upper case.
54///
55/// This ensures that logically identical queries with minor formatting
56/// differences share the same cache entry.
57fn normalise_query(sql: &str) -> String {
58    let mut out = String::with_capacity(sql.len());
59    let mut prev_was_space = true; // skip leading spaces
60    for ch in sql.chars() {
61        if ch.is_ascii_whitespace() {
62            if !prev_was_space {
63                out.push(' ');
64                prev_was_space = true;
65            }
66        } else {
67            out.push(ch.to_ascii_uppercase());
68            prev_was_space = false;
69        }
70    }
71    // Trim trailing space that may have been appended.
72    if out.ends_with(' ') {
73        out.pop();
74    }
75    out
76}
77
78// ── SqlQueryCache ─────────────────────────────────────────────────────────────
79
80/// Statistics snapshot for [`SqlQueryCache`].
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct QueryCacheStats {
83    /// Total number of cache hits (normalised-key lookup returned a live entry).
84    pub hits: u64,
85    /// Total number of cache misses (key absent or entry expired).
86    pub misses: u64,
87    /// Number of entries currently held in the cache.
88    pub len: usize,
89    /// Maximum number of entries the cache can hold.
90    pub cap: usize,
91}
92
93impl QueryCacheStats {
94    /// Return the hit rate as a fraction in `[0.0, 1.0]`.
95    ///
96    /// Returns `0.0` if no accesses have been recorded.
97    #[must_use]
98    pub fn hit_rate(&self) -> f64 {
99        let total = self.hits + self.misses;
100        if total == 0 {
101            0.0
102        } else {
103            self.hits as f64 / total as f64
104        }
105    }
106}
107
108/// In-memory LRU cache of SQL query result sets.
109///
110/// Result sets are keyed by their normalised SQL text (leading/trailing whitespace collapsed,
111/// consecutive spaces reduced to a single space, ASCII letters upper-cased).
112/// Each entry stores a cloned [`RowSet`]; callers receive another clone on
113/// `get` so the cached copy is never mutated.
114///
115/// # TTL
116///
117/// Insert with a TTL via [`SqlQueryCache::put_with_ttl`].  After the TTL
118/// elapses, `get` returns `None` and the stale entry is lazily removed.
119///
120/// # Example
121///
122/// ```rust
123/// use oxisql_core::{Row, RowSet, Value};
124/// use oxisql_cache::SqlQueryCache;
125///
126/// let mut cache = SqlQueryCache::new(256);
127/// let rows = vec![Row::new(vec!["id".into()], vec![Value::I64(1)])];
128/// let rs = RowSet::from_rows(rows);
129/// cache.put("SELECT id FROM t WHERE id = 1", rs.clone());
130///
131/// // Equivalent queries (different whitespace/case) hit the same entry.
132/// assert!(cache.get("select  id  from  t  where  id = 1").is_some());
133/// ```
134pub struct SqlQueryCache {
135    inner: LruCache<String, RowSet>,
136    hits: u64,
137    misses: u64,
138}
139
140impl SqlQueryCache {
141    /// Create a new cache with the given maximum entry count.
142    #[must_use]
143    pub fn new(capacity: usize) -> Self {
144        Self {
145            inner: LruCache::new(capacity),
146            hits: 0,
147            misses: 0,
148        }
149    }
150
151    /// Look up a query result set by SQL text.
152    ///
153    /// The query is normalised before lookup.  Returns a clone of the cached
154    /// [`RowSet`] on a hit, `None` on a miss or TTL expiry.
155    pub fn get(&mut self, sql: &str) -> Option<RowSet> {
156        let key = normalise_query(sql);
157        match self.inner.get(&key) {
158            Some(rs) => {
159                self.hits += 1;
160                Some(rs.clone())
161            }
162            None => {
163                self.misses += 1;
164                None
165            }
166        }
167    }
168
169    /// Insert a query result set, replacing any existing entry for the same
170    /// normalised SQL.
171    pub fn put(&mut self, sql: &str, result: RowSet) {
172        let key = normalise_query(sql);
173        self.inner.put(key, result);
174    }
175
176    /// Insert a query result set that expires after `ttl`.
177    pub fn put_with_ttl(&mut self, sql: &str, result: RowSet, ttl: Duration) {
178        let key = normalise_query(sql);
179        self.inner.put_with_ttl(key, result, ttl);
180    }
181
182    /// Remove the cached result for `sql`, returning it if present.
183    pub fn invalidate(&mut self, sql: &str) -> Option<RowSet> {
184        let key = normalise_query(sql);
185        self.inner.remove(&key)
186    }
187
188    /// Remove all cached results.
189    pub fn clear(&mut self) {
190        self.inner.clear();
191    }
192
193    /// Return `true` if the cache contains a live entry for `sql`.
194    pub fn contains(&mut self, sql: &str) -> bool {
195        let key = normalise_query(sql);
196        self.inner.contains_key(&key)
197    }
198
199    /// Return the number of entries currently in the cache.
200    #[must_use]
201    pub fn len(&self) -> usize {
202        self.inner.len()
203    }
204
205    /// Return `true` if the cache is empty.
206    #[must_use]
207    pub fn is_empty(&self) -> bool {
208        self.inner.is_empty()
209    }
210
211    /// Return the maximum number of entries.
212    #[must_use]
213    pub fn cap(&self) -> usize {
214        self.inner.cap()
215    }
216
217    /// Return a stats snapshot.
218    #[must_use]
219    pub fn stats(&self) -> QueryCacheStats {
220        QueryCacheStats {
221            hits: self.hits,
222            misses: self.misses,
223            len: self.inner.len(),
224            cap: self.inner.cap(),
225        }
226    }
227
228    /// Dynamically resize the cache capacity.
229    ///
230    /// If `new_cap` is smaller than the current length, entries are evicted
231    /// (LRU policy) until `len() <= new_cap`.
232    pub fn resize(&mut self, new_cap: usize) {
233        self.inner.resize(new_cap);
234    }
235}
236
237// ── SqlPlanCache ──────────────────────────────────────────────────────────────
238
239/// In-memory LRU cache of prepared SQL statement plans.
240///
241/// `P` is the caller-supplied plan representation — typically a compiled query
242/// plan, a byte buffer, or a `Box<dyn Any>`.  The cache is generic so it can
243/// accommodate any backend's plan type without imposing a common trait.
244///
245/// Plans are never expired by TTL (they are considered stable for the lifetime
246/// of a connection).  TTL can be added via [`SqlPlanCache::put_with_ttl`] when
247/// plans have limited validity (e.g. after schema changes).
248///
249/// Keys are normalised SQL text (same rules as [`SqlQueryCache`]).
250///
251/// # Example
252///
253/// ```rust
254/// use oxisql_cache::SqlPlanCache;
255///
256/// // Use Vec<u8> as a stand-in for a serialised plan.
257/// let mut plan_cache: SqlPlanCache<Vec<u8>> = SqlPlanCache::new(512);
258/// plan_cache.put("SELECT 1", vec![0x01, 0x02]);
259/// assert!(plan_cache.get("select  1").is_some());
260/// ```
261pub struct SqlPlanCache<P> {
262    inner: LruCache<String, P>,
263    hits: u64,
264    misses: u64,
265}
266
267impl<P: Clone> SqlPlanCache<P> {
268    /// Create a new plan cache with the given maximum entry count.
269    #[must_use]
270    pub fn new(capacity: usize) -> Self {
271        Self {
272            inner: LruCache::new(capacity),
273            hits: 0,
274            misses: 0,
275        }
276    }
277
278    /// Look up a plan by SQL text.
279    ///
280    /// Returns a reference to the cached plan on a hit, `None` on a miss.
281    pub fn get(&mut self, sql: &str) -> Option<&P> {
282        let key = normalise_query(sql);
283        match self.inner.get(&key) {
284            Some(p) => {
285                self.hits += 1;
286                Some(p)
287            }
288            None => {
289                self.misses += 1;
290                None
291            }
292        }
293    }
294
295    /// Store a plan for `sql`.
296    pub fn put(&mut self, sql: &str, plan: P) {
297        let key = normalise_query(sql);
298        self.inner.put(key, plan);
299    }
300
301    /// Store a plan for `sql` that expires after `ttl`.
302    pub fn put_with_ttl(&mut self, sql: &str, plan: P, ttl: Duration) {
303        let key = normalise_query(sql);
304        self.inner.put_with_ttl(key, plan, ttl);
305    }
306
307    /// Remove the cached plan for `sql`, returning it if present.
308    pub fn invalidate(&mut self, sql: &str) -> Option<P> {
309        let key = normalise_query(sql);
310        self.inner.remove(&key)
311    }
312
313    /// Remove all cached plans.
314    pub fn clear(&mut self) {
315        self.inner.clear();
316    }
317
318    /// Return `true` if a live plan is cached for `sql`.
319    pub fn contains(&mut self, sql: &str) -> bool {
320        let key = normalise_query(sql);
321        self.inner.contains_key(&key)
322    }
323
324    /// Return the number of plans currently in the cache.
325    #[must_use]
326    pub fn len(&self) -> usize {
327        self.inner.len()
328    }
329
330    /// Return `true` if the cache is empty.
331    #[must_use]
332    pub fn is_empty(&self) -> bool {
333        self.inner.is_empty()
334    }
335
336    /// Return the maximum number of plans.
337    #[must_use]
338    pub fn cap(&self) -> usize {
339        self.inner.cap()
340    }
341
342    /// Return the number of cache hits since creation.
343    #[must_use]
344    pub fn hits(&self) -> u64 {
345        self.hits
346    }
347
348    /// Return the number of cache misses since creation.
349    #[must_use]
350    pub fn misses(&self) -> u64 {
351        self.misses
352    }
353
354    /// Return the hit rate as a fraction in `[0.0, 1.0]`.
355    #[must_use]
356    pub fn hit_rate(&self) -> f64 {
357        let total = self.hits + self.misses;
358        if total == 0 {
359            0.0
360        } else {
361            self.hits as f64 / total as f64
362        }
363    }
364
365    /// Dynamically resize the cache.
366    pub fn resize(&mut self, new_cap: usize) {
367        self.inner.resize(new_cap);
368    }
369}
370
371// ── CachedQueryRunner ─────────────────────────────────────────────────────────
372
373/// A read-through adapter that wraps a synchronous query executor with a
374/// [`SqlQueryCache`].
375///
376/// The executor `F` is a `FnMut(&str) -> Result<RowSet, E>` — a closure or
377/// function pointer that actually runs the SQL against a database backend.
378///
379/// On a cache hit the executor is never called.  On a miss the executor is
380/// called, the result stored in the cache, and returned to the caller.
381///
382/// # Example
383///
384/// ```rust
385/// use oxisql_core::{Row, RowSet, Value};
386/// use oxisql_cache::CachedQueryRunner;
387///
388/// let mut runner = CachedQueryRunner::new(
389///     32,
390///     |sql: &str| -> Result<RowSet, String> {
391///         // Simulated DB hit.
392///         Ok(RowSet::from_rows(vec![Row::new(
393///             vec!["n".into()],
394///             vec![Value::I64(1)],
395///         )]))
396///     },
397/// );
398///
399/// let r1 = runner.run("SELECT 1").unwrap();
400/// let r2 = runner.run("SELECT 1").unwrap();
401/// assert_eq!(r1.len(), r2.len());
402/// assert_eq!(runner.hits(), 1);
403/// assert_eq!(runner.misses(), 1);
404/// ```
405pub struct CachedQueryRunner<F, E>
406where
407    F: FnMut(&str) -> Result<RowSet, E>,
408{
409    cache: SqlQueryCache,
410    executor: F,
411}
412
413impl<F, E> CachedQueryRunner<F, E>
414where
415    F: FnMut(&str) -> Result<RowSet, E>,
416{
417    /// Create a new runner with the given cache capacity and executor.
418    pub fn new(capacity: usize, executor: F) -> Self {
419        Self {
420            cache: SqlQueryCache::new(capacity),
421            executor,
422        }
423    }
424
425    /// Run `sql` against the cache/executor.
426    ///
427    /// Returns a cached [`RowSet`] clone on a hit.  On a miss, calls the
428    /// executor, caches the result (without TTL), and returns it.
429    ///
430    /// # Errors
431    ///
432    /// Propagates any error returned by the executor.
433    pub fn run(&mut self, sql: &str) -> Result<RowSet, E> {
434        if let Some(cached) = self.cache.get(sql) {
435            return Ok(cached);
436        }
437        let result = (self.executor)(sql)?;
438        self.cache.put(sql, result.clone());
439        Ok(result)
440    }
441
442    /// Run `sql` with a TTL on the cached result.
443    ///
444    /// Cached results expire after `ttl`; the next call after expiry will
445    /// re-execute the query.
446    ///
447    /// # Errors
448    ///
449    /// Propagates any error returned by the executor.
450    pub fn run_with_ttl(&mut self, sql: &str, ttl: Duration) -> Result<RowSet, E> {
451        if let Some(cached) = self.cache.get(sql) {
452            return Ok(cached);
453        }
454        let result = (self.executor)(sql)?;
455        self.cache.put_with_ttl(sql, result.clone(), ttl);
456        Ok(result)
457    }
458
459    /// Invalidate a specific cached result.
460    pub fn invalidate(&mut self, sql: &str) {
461        self.cache.invalidate(sql);
462    }
463
464    /// Remove all cached results.
465    pub fn clear(&mut self) {
466        self.cache.clear();
467    }
468
469    /// Return the number of cache hits since creation.
470    #[must_use]
471    pub fn hits(&self) -> u64 {
472        self.cache.hits
473    }
474
475    /// Return the number of cache misses since creation.
476    #[must_use]
477    pub fn misses(&self) -> u64 {
478        self.cache.misses
479    }
480
481    /// Return a stats snapshot.
482    #[must_use]
483    pub fn stats(&self) -> QueryCacheStats {
484        self.cache.stats()
485    }
486}
487
488// ── Tests ─────────────────────────────────────────────────────────────────────
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use oxisql_core::{Row, RowSet, Value};
494
495    fn make_rowset(n: i64) -> RowSet {
496        let rows: Vec<Row> = (0..n)
497            .map(|i| Row::new(vec!["id".into()], vec![Value::I64(i)]))
498            .collect();
499        RowSet::from_rows(rows)
500    }
501
502    // ── normalise_query ───────────────────────────────────────────────────────
503
504    #[test]
505    fn normalise_trims_whitespace() {
506        assert_eq!(normalise_query("  select 1  "), "SELECT 1");
507    }
508
509    #[test]
510    fn normalise_collapses_internal_spaces() {
511        assert_eq!(normalise_query("select  id   from   t"), "SELECT ID FROM T");
512    }
513
514    #[test]
515    fn normalise_uppercase() {
516        assert_eq!(normalise_query("select id from t"), "SELECT ID FROM T");
517    }
518
519    #[test]
520    fn normalise_tabs_and_newlines() {
521        assert_eq!(normalise_query("SELECT\tid\nFROM\tt"), "SELECT ID FROM T");
522    }
523
524    #[test]
525    fn normalise_empty_string() {
526        assert_eq!(normalise_query(""), "");
527        assert_eq!(normalise_query("   "), "");
528    }
529
530    // ── SqlQueryCache ─────────────────────────────────────────────────────────
531
532    #[test]
533    fn put_and_get_basic() {
534        let mut cache = SqlQueryCache::new(8);
535        let rs = make_rowset(3);
536        cache.put("SELECT id FROM t", rs.clone());
537        let got = cache.get("SELECT id FROM t");
538        assert!(got.is_some());
539        assert_eq!(got.unwrap().len(), 3);
540    }
541
542    #[test]
543    fn get_normalises_key() {
544        let mut cache = SqlQueryCache::new(8);
545        cache.put("SELECT id FROM t", make_rowset(2));
546        // Different whitespace/case should hit the same entry.
547        assert!(cache.get("select  id  from  t").is_some());
548        assert!(cache.get("SELECT\tID\nFROM\tT").is_some());
549    }
550
551    #[test]
552    fn miss_returns_none() {
553        let mut cache = SqlQueryCache::new(8);
554        assert!(cache.get("SELECT 1").is_none());
555    }
556
557    #[test]
558    fn hits_and_misses_counted() {
559        let mut cache = SqlQueryCache::new(8);
560        cache.put("SELECT 1", make_rowset(1));
561        cache.get("SELECT 1"); // hit
562        cache.get("SELECT 2"); // miss
563        let stats = cache.stats();
564        assert_eq!(stats.hits, 1);
565        assert_eq!(stats.misses, 1);
566        assert!((stats.hit_rate() - 0.5).abs() < 1e-9);
567    }
568
569    #[test]
570    fn invalidate_removes_entry() {
571        let mut cache = SqlQueryCache::new(8);
572        cache.put("SELECT 1", make_rowset(1));
573        let removed = cache.invalidate("select 1"); // normalisation applies
574        assert!(removed.is_some());
575        assert!(cache.get("SELECT 1").is_none());
576    }
577
578    #[test]
579    fn clear_empties_cache() {
580        let mut cache = SqlQueryCache::new(8);
581        cache.put("SELECT 1", make_rowset(1));
582        cache.put("SELECT 2", make_rowset(2));
583        cache.clear();
584        assert!(cache.is_empty());
585    }
586
587    #[test]
588    fn ttl_expiry() {
589        let mut cache = SqlQueryCache::new(8);
590        cache.put_with_ttl("SELECT 1", make_rowset(1), Duration::from_nanos(1));
591        std::thread::yield_now();
592        // After TTL, get should return None.
593        assert!(cache.get("SELECT 1").is_none());
594    }
595
596    #[test]
597    fn resize_evicts_excess() {
598        let mut cache = SqlQueryCache::new(8);
599        for i in 0..6 {
600            cache.put(&format!("SELECT {i}"), make_rowset(1));
601        }
602        assert_eq!(cache.len(), 6);
603        cache.resize(3);
604        assert!(cache.len() <= 3);
605    }
606
607    #[test]
608    fn cap_returns_capacity() {
609        let cache = SqlQueryCache::new(100);
610        assert_eq!(cache.cap(), 100);
611    }
612
613    // ── SqlPlanCache ──────────────────────────────────────────────────────────
614
615    #[test]
616    fn plan_cache_put_and_get() {
617        let mut cache: SqlPlanCache<Vec<u8>> = SqlPlanCache::new(16);
618        cache.put("SELECT 1", vec![0x01, 0x02, 0x03]);
619        let plan = cache.get("SELECT 1").unwrap();
620        assert_eq!(plan, &[0x01u8, 0x02, 0x03]);
621    }
622
623    #[test]
624    fn plan_cache_normalises_key() {
625        let mut cache: SqlPlanCache<Vec<u8>> = SqlPlanCache::new(16);
626        cache.put("SELECT id FROM t", vec![0xAB]);
627        assert!(cache.get("select  id  from  t").is_some());
628    }
629
630    #[test]
631    fn plan_cache_invalidate() {
632        let mut cache: SqlPlanCache<Vec<u8>> = SqlPlanCache::new(16);
633        cache.put("SELECT 1", vec![1]);
634        assert!(cache.invalidate("SELECT 1").is_some());
635        assert!(cache.get("SELECT 1").is_none());
636    }
637
638    #[test]
639    fn plan_cache_hit_miss_stats() {
640        let mut cache: SqlPlanCache<Vec<u8>> = SqlPlanCache::new(16);
641        cache.put("SELECT 1", vec![1]);
642        cache.get("SELECT 1"); // hit
643        cache.get("SELECT 2"); // miss
644        assert_eq!(cache.hits(), 1);
645        assert_eq!(cache.misses(), 1);
646        assert!((cache.hit_rate() - 0.5).abs() < 1e-9);
647    }
648
649    #[test]
650    fn plan_cache_ttl_expiry() {
651        let mut cache: SqlPlanCache<Vec<u8>> = SqlPlanCache::new(16);
652        cache.put_with_ttl("SELECT 1", vec![1], Duration::from_nanos(1));
653        std::thread::yield_now();
654        assert!(cache.get("SELECT 1").is_none());
655    }
656
657    // ── CachedQueryRunner ─────────────────────────────────────────────────────
658
659    #[test]
660    fn runner_caches_result() {
661        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
662        let cc = std::sync::Arc::clone(&call_count);
663        let mut runner = CachedQueryRunner::new(32, move |_sql: &str| -> Result<RowSet, String> {
664            cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
665            Ok(make_rowset(5))
666        });
667        let r1 = runner.run("SELECT id FROM t").unwrap();
668        let r2 = runner.run("SELECT id FROM t").unwrap();
669        assert_eq!(r1.len(), r2.len());
670        // Executor was called exactly once.
671        assert_eq!(call_count.load(std::sync::atomic::Ordering::Relaxed), 1);
672        assert_eq!(runner.hits(), 1);
673        assert_eq!(runner.misses(), 1);
674    }
675
676    #[test]
677    fn runner_propagates_executor_error() {
678        let mut runner = CachedQueryRunner::new(8, |_sql: &str| -> Result<RowSet, String> {
679            Err("db error".to_string())
680        });
681        assert!(runner.run("SELECT 1").is_err());
682    }
683
684    #[test]
685    fn runner_ttl_invalidates_result() {
686        let mut runner = CachedQueryRunner::new(8, |_: &str| -> Result<RowSet, String> {
687            Ok(make_rowset(1))
688        });
689        runner
690            .run_with_ttl("SELECT 1", Duration::from_nanos(1))
691            .unwrap();
692        std::thread::yield_now();
693        // After TTL expiry, the next call should re-execute.
694        runner
695            .run_with_ttl("SELECT 1", Duration::from_nanos(1))
696            .unwrap();
697        assert_eq!(runner.misses(), 2); // both calls were misses
698    }
699
700    #[test]
701    fn runner_invalidate_forces_re_execution() {
702        let mut runner = CachedQueryRunner::new(8, |_: &str| -> Result<RowSet, String> {
703            Ok(make_rowset(1))
704        });
705        runner.run("SELECT 1").unwrap(); // miss
706        runner.invalidate("select 1"); // evict (normalisation applies)
707        runner.run("SELECT 1").unwrap(); // miss again
708        assert_eq!(runner.misses(), 2);
709        assert_eq!(runner.hits(), 0);
710    }
711}