Skip to main content

rustrails_record/
query_cache.rs

1use std::cell::RefCell;
2use std::sync::Arc;
3
4use rustrails_support::cache::{CacheOptions, CacheStore, MemoryStore};
5use serde_json::Value;
6
7thread_local! {
8    static QUERY_CACHE_SCOPE: RefCell<Vec<QueryCacheScope>> = const { RefCell::new(Vec::new()) };
9}
10
11#[derive(Clone)]
12struct QueryCacheScope {
13    cache: QueryCache,
14    bypass_depth: usize,
15}
16
17/// Per-request query cache backed by [`MemoryStore`].
18#[derive(Clone, Debug)]
19pub struct QueryCache {
20    store: Arc<MemoryStore>,
21}
22
23impl QueryCache {
24    /// Creates an empty query cache.
25    #[must_use]
26    pub fn new() -> Self {
27        Self {
28            store: Arc::new(MemoryStore::new()),
29        }
30    }
31
32    /// Computes the cache key for an SQL statement and bind values.
33    #[must_use]
34    pub fn cache_key(sql: &str, binds: &[Value]) -> String {
35        let binds = match serde_json::to_string(binds) {
36            Ok(serialized) => serialized,
37            Err(_) => "[]".to_owned(),
38        };
39        format!("{sql}::{binds}")
40    }
41
42    /// Runs `f` with an isolated request-scoped query cache.
43    pub fn with_request_scope<F, R>(&self, f: F) -> R
44    where
45        F: FnOnce() -> R,
46    {
47        QUERY_CACHE_SCOPE.with(|scope| {
48            scope.borrow_mut().push(QueryCacheScope {
49                cache: QueryCache::new(),
50                bypass_depth: 0,
51            });
52        });
53
54        struct Guard;
55        impl Drop for Guard {
56            fn drop(&mut self) {
57                QUERY_CACHE_SCOPE.with(|scope| {
58                    scope.borrow_mut().pop();
59                });
60            }
61        }
62
63        let _guard = Guard;
64        f()
65    }
66
67    /// Reads a cached query result or computes and stores it.
68    pub fn fetch<F>(&self, sql: &str, binds: &[Value], loader: F) -> Value
69    where
70        F: FnOnce() -> Value,
71    {
72        let key = Self::cache_key(sql, binds);
73
74        if let Some((cache, bypassed)) = current_scope() {
75            if bypassed {
76                return loader();
77            }
78            return cache.store.fetch(&key, CacheOptions::default(), loader);
79        }
80
81        self.store.fetch(&key, CacheOptions::default(), loader)
82    }
83
84    /// Clears cached query results after a write operation.
85    pub fn execute_write<F, R>(&self, operation: F) -> R
86    where
87        F: FnOnce() -> R,
88    {
89        let result = operation();
90        if let Some((cache, _)) = current_scope() {
91            cache.store.clear();
92        } else {
93            self.store.clear();
94        }
95        result
96    }
97
98    /// Executes `f` while bypassing the current request cache.
99    pub fn uncached<F, R>(f: F) -> R
100    where
101        F: FnOnce() -> R,
102    {
103        QUERY_CACHE_SCOPE.with(|scope| {
104            if let Some(current) = scope.borrow_mut().last_mut() {
105                current.bypass_depth += 1;
106            }
107        });
108
109        struct Guard;
110        impl Drop for Guard {
111            fn drop(&mut self) {
112                QUERY_CACHE_SCOPE.with(|scope| {
113                    if let Some(current) = scope.borrow_mut().last_mut() {
114                        current.bypass_depth = current.bypass_depth.saturating_sub(1);
115                    }
116                });
117            }
118        }
119
120        let _guard = Guard;
121        f()
122    }
123}
124
125impl Default for QueryCache {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131fn current_scope() -> Option<(QueryCache, bool)> {
132    QUERY_CACHE_SCOPE.with(|scope| {
133        scope
134            .borrow()
135            .last()
136            .map(|scope| (scope.cache.clone(), scope.bypass_depth > 0))
137    })
138}
139
140#[cfg(test)]
141mod tests {
142    use std::sync::atomic::{AtomicUsize, Ordering};
143
144    use serde_json::json;
145
146    use super::QueryCache;
147
148    #[test]
149    fn cache_key_depends_on_sql_and_binds() {
150        let first = QueryCache::cache_key("SELECT 1", &[json!(1)]);
151        let second = QueryCache::cache_key("SELECT 1", &[json!(2)]);
152        assert_ne!(first, second);
153    }
154
155    #[test]
156    fn fetch_uses_cached_value_within_request_scope() {
157        let cache = QueryCache::new();
158        let calls = AtomicUsize::new(0);
159
160        cache.with_request_scope(|| {
161            let first = cache.fetch("SELECT * FROM users WHERE id = ?", &[json!(1)], || {
162                calls.fetch_add(1, Ordering::SeqCst);
163                json!({"id": 1})
164            });
165            let second = cache.fetch("SELECT * FROM users WHERE id = ?", &[json!(1)], || {
166                calls.fetch_add(1, Ordering::SeqCst);
167                json!({"id": 2})
168            });
169
170            assert_eq!(first, second);
171        });
172
173        assert_eq!(calls.load(Ordering::SeqCst), 1);
174    }
175
176    #[test]
177    fn request_scopes_are_isolated() {
178        let cache = QueryCache::new();
179        let calls = AtomicUsize::new(0);
180
181        cache.with_request_scope(|| {
182            let _ = cache.fetch("SELECT 1", &[], || {
183                calls.fetch_add(1, Ordering::SeqCst);
184                json!(1)
185            });
186        });
187        cache.with_request_scope(|| {
188            let _ = cache.fetch("SELECT 1", &[], || {
189                calls.fetch_add(1, Ordering::SeqCst);
190                json!(1)
191            });
192        });
193
194        assert_eq!(calls.load(Ordering::SeqCst), 2);
195    }
196
197    #[test]
198    fn uncached_bypasses_request_cache() {
199        let cache = QueryCache::new();
200        let calls = AtomicUsize::new(0);
201
202        cache.with_request_scope(|| {
203            let _ = cache.fetch("SELECT 1", &[], || {
204                calls.fetch_add(1, Ordering::SeqCst);
205                json!(1)
206            });
207            let _ = QueryCache::uncached(|| {
208                cache.fetch("SELECT 1", &[], || {
209                    calls.fetch_add(1, Ordering::SeqCst);
210                    json!(2)
211                })
212            });
213        });
214
215        assert_eq!(calls.load(Ordering::SeqCst), 2);
216    }
217
218    #[test]
219    fn execute_write_invalidates_current_scope() {
220        let cache = QueryCache::new();
221        let calls = AtomicUsize::new(0);
222
223        cache.with_request_scope(|| {
224            let _ = cache.fetch("SELECT 1", &[], || {
225                calls.fetch_add(1, Ordering::SeqCst);
226                json!(1)
227            });
228            cache.execute_write(|| ());
229            let _ = cache.fetch("SELECT 1", &[], || {
230                calls.fetch_add(1, Ordering::SeqCst);
231                json!(1)
232            });
233        });
234
235        assert_eq!(calls.load(Ordering::SeqCst), 2);
236    }
237
238    #[test]
239    fn execute_write_invalidates_non_scoped_cache() {
240        let cache = QueryCache::new();
241        let calls = AtomicUsize::new(0);
242
243        let _ = cache.fetch("SELECT 1", &[], || {
244            calls.fetch_add(1, Ordering::SeqCst);
245            json!(1)
246        });
247        cache.execute_write(|| ());
248        let _ = cache.fetch("SELECT 1", &[], || {
249            calls.fetch_add(1, Ordering::SeqCst);
250            json!(1)
251        });
252
253        assert_eq!(calls.load(Ordering::SeqCst), 2);
254    }
255}