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}