Skip to main content

hydracache_db/
query.rs

1use std::error::Error;
2use std::fmt;
3use std::future::Future;
4use std::marker::PhantomData;
5use std::time::Duration;
6
7use hydracache::{CacheKeyBuilder, CacheOptions, HydraCache, PostcardCodec, TagSet};
8use hydracache_core::CacheCodec;
9use serde::{de::DeserializeOwned, Serialize};
10
11use crate::{CacheEntity, DbCacheError, QueryCachePolicy, Result};
12
13/// A database-oriented view over a [`HydraCache`] instance.
14///
15/// `DbCache` groups query result keys under a namespace while keeping all
16/// cache storage, single-flight, tags, TTL, and stats in the shared local cache.
17///
18/// # Example
19///
20/// ```rust
21/// use std::time::Duration;
22///
23/// use hydracache::HydraCache;
24/// use hydracache_db::DbCache;
25/// use serde::{Deserialize, Serialize};
26///
27/// #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28/// struct User {
29///     id: i64,
30///     name: String,
31/// }
32///
33/// # #[tokio::main]
34/// # async fn main() -> hydracache_db::Result<()> {
35/// let local = HydraCache::local().build();
36/// let queries = DbCache::new(local, "db");
37///
38/// let user = queries
39///     .entity::<User>("user", 42)
40///     // Later, invalidate_tag("user:42") removes this result.
41///     .collection_tag("users")
42///     .ttl(Duration::from_secs(60))
43///     .fetch_with(|| async {
44///         // Replace this block with code from sqlx, diesel, sea-orm, or any
45///         // other database client. It is called only when the cache does not
46///         // already contain "db:user:42" or when the cached value has expired.
47///         Ok::<_, std::io::Error>(User {
48///             id: 42,
49///             name: "Ada".to_owned(),
50///         })
51///     })
52///     .await?;
53///
54/// assert_eq!(user.id, 42);
55/// # Ok(())
56/// # }
57/// ```
58pub struct DbCache<C = PostcardCodec>
59where
60    C: CacheCodec,
61{
62    cache: HydraCache<C>,
63    namespace: String,
64}
65
66impl<C> Clone for DbCache<C>
67where
68    C: CacheCodec,
69{
70    fn clone(&self) -> Self {
71        Self {
72            cache: self.cache.clone(),
73            namespace: self.namespace.clone(),
74        }
75    }
76}
77
78impl<C> fmt::Debug for DbCache<C>
79where
80    C: CacheCodec,
81{
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        formatter
84            .debug_struct("DbCache")
85            .field("namespace", &self.namespace)
86            .finish_non_exhaustive()
87    }
88}
89
90impl<C> DbCache<C>
91where
92    C: CacheCodec,
93{
94    /// Create a database query cache adapter over an existing local cache.
95    pub fn new(cache: HydraCache<C>, namespace: impl Into<String>) -> Self {
96        Self {
97            cache,
98            namespace: namespace.into(),
99        }
100    }
101
102    /// Return the namespace used for physical cache keys.
103    pub fn namespace(&self) -> &str {
104        &self.namespace
105    }
106
107    /// Return the underlying local cache.
108    pub fn cache(&self) -> &HydraCache<C> {
109        &self.cache
110    }
111
112    /// Start describing a cacheable database-loaded value.
113    ///
114    /// This is the preferred entry point when the query is already visible
115    /// inside the `fetch_with` loader through a database client, ORM, or
116    /// repository method.
117    pub fn cached<T>(&self) -> DbQuery<T, C> {
118        DbQuery {
119            cache: self.cache.clone(),
120            namespace: self.namespace.clone(),
121            policy: QueryCachePolicy::new(),
122            value: PhantomData,
123        }
124    }
125
126    /// Start describing a cacheable database-loaded value with a reusable
127    /// [`QueryCachePolicy`].
128    ///
129    /// This is useful when the same key/tag/TTL pattern is shared by a
130    /// repository method, a SQLx call site, and a future ORM adapter.
131    pub fn cached_with<T>(&self, policy: QueryCachePolicy) -> DbQuery<T, C> {
132        self.cached::<T>().with_policy(policy)
133    }
134
135    /// Start describing an entity-shaped cached value.
136    ///
137    /// This is a convenience layer over [`DbCache::cached`] that sets both the
138    /// logical key and the entity invalidation tag from escaped key segments.
139    /// For example, `entity::<User>("user", 42)` creates key `user:42` and tag
140    /// `user:42`; with namespace `db`, the physical cache key is `db:user:42`.
141    ///
142    /// # Example
143    ///
144    /// ```rust
145    /// use hydracache::HydraCache;
146    /// use hydracache_db::DbCache;
147    /// use serde::{Deserialize, Serialize};
148    ///
149    /// #[derive(Debug, Clone, Serialize, Deserialize)]
150    /// struct User {
151    ///     id: i64,
152    /// }
153    ///
154    /// let queries = DbCache::new(HydraCache::local().build(), "db");
155    /// let query = queries.entity::<User>("user", 42);
156    ///
157    /// assert_eq!(query.key_value(), Some("user:42"));
158    /// assert_eq!(query.tags_value(), &["user:42".to_owned()]);
159    /// assert_eq!(query.physical_key(), Some("db:user:42".to_owned()));
160    /// ```
161    pub fn entity<T>(&self, kind: impl ToString, id: impl ToString) -> DbQuery<T, C> {
162        self.cached::<T>().for_entity(kind, id)
163    }
164
165    /// Start describing an entity-shaped cached value from [`CacheEntity`]
166    /// metadata.
167    ///
168    /// This helper removes repeated entity and collection literals from call
169    /// sites. It sets the logical key, entity tag, and optional collection tag
170    /// defined by `T`.
171    ///
172    /// # Example
173    ///
174    /// ```rust
175    /// use hydracache::HydraCache;
176    /// use hydracache_db::{CacheEntity, DbCache};
177    /// use serde::{Deserialize, Serialize};
178    ///
179    /// #[derive(Debug, Clone, Serialize, Deserialize)]
180    /// struct User {
181    ///     id: i64,
182    /// }
183    ///
184    /// impl CacheEntity for User {
185    ///     type Id = i64;
186    ///
187    ///     const ENTITY: &'static str = "user";
188    ///     const COLLECTION: Option<&'static str> = Some("users");
189    /// }
190    ///
191    /// let queries = DbCache::new(HydraCache::local().build(), "db");
192    /// let query = queries.for_entity::<User>(42);
193    ///
194    /// assert_eq!(query.key_value(), Some("user:42"));
195    /// assert_eq!(
196    ///     query.tags_value(),
197    ///     &["user:42".to_owned(), "users".to_owned()]
198    /// );
199    /// ```
200    pub fn for_entity<T>(&self, id: T::Id) -> DbQuery<T, C>
201    where
202        T: CacheEntity,
203    {
204        self.cached::<T>().for_cache_entity(id)
205    }
206
207    /// Start describing a collection-shaped cached value.
208    ///
209    /// This sets both the logical key and the collection invalidation tag to
210    /// the escaped collection name. For example, `collection::<User>("users")`
211    /// creates key `users` and tag `users`.
212    ///
213    /// # Example
214    ///
215    /// ```rust
216    /// use hydracache::HydraCache;
217    /// use hydracache_db::DbCache;
218    /// use serde::{Deserialize, Serialize};
219    ///
220    /// #[derive(Debug, Clone, Serialize, Deserialize)]
221    /// struct User {
222    ///     id: i64,
223    /// }
224    ///
225    /// let queries = DbCache::new(HydraCache::local().build(), "db");
226    /// let query = queries.collection::<User>("users:active");
227    ///
228    /// assert_eq!(query.key_value(), Some("users%3Aactive"));
229    /// assert_eq!(query.tags_value(), &["users%3Aactive".to_owned()]);
230    /// assert_eq!(query.physical_key(), Some("db:users%3Aactive".to_owned()));
231    /// ```
232    pub fn collection<T>(&self, name: impl ToString) -> DbQuery<T, C> {
233        self.cached::<T>().collection(name)
234    }
235
236    /// Start describing a cacheable database-loaded value with a diagnostic name.
237    pub fn named<T>(&self, name: impl Into<String>) -> DbQuery<T, C> {
238        DbQuery {
239            cache: self.cache.clone(),
240            namespace: self.namespace.clone(),
241            policy: QueryCachePolicy::named(name),
242            value: PhantomData,
243        }
244    }
245
246    /// Start describing a cacheable SQL query result.
247    ///
248    /// Prefer [`DbCache::cached`] or [`DbCache::named`] when writing new code.
249    /// This method remains useful if you want the SQL text itself to be the
250    /// diagnostic label for errors and logs.
251    pub fn query_as<T>(&self, sql: impl Into<String>) -> DbQuery<T, C> {
252        self.named(sql)
253    }
254}
255
256/// A cacheable database query descriptor.
257///
258/// The descriptor is deliberately explicit: callers choose the key, tags, and
259/// TTL that match their freshness model. An operation name is optional and used
260/// only for diagnostics. `fetch_with` executes the supplied loader only on a
261/// cache miss.
262pub struct DbQuery<T, C = PostcardCodec>
263where
264    C: CacheCodec,
265{
266    cache: HydraCache<C>,
267    namespace: String,
268    policy: QueryCachePolicy,
269    value: PhantomData<fn() -> T>,
270}
271
272impl<T, C> Clone for DbQuery<T, C>
273where
274    C: CacheCodec,
275{
276    fn clone(&self) -> Self {
277        Self {
278            cache: self.cache.clone(),
279            namespace: self.namespace.clone(),
280            policy: self.policy.clone(),
281            value: PhantomData,
282        }
283    }
284}
285
286impl<T, C> fmt::Debug for DbQuery<T, C>
287where
288    C: CacheCodec,
289{
290    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291        formatter
292            .debug_struct("DbQuery")
293            .field("namespace", &self.namespace)
294            .field("policy", &self.policy)
295            .finish_non_exhaustive()
296    }
297}
298
299impl<T, C> DbQuery<T, C>
300where
301    C: CacheCodec,
302{
303    /// Return the optional diagnostic operation name.
304    pub fn name(&self) -> Option<&str> {
305        self.policy.name()
306    }
307
308    /// Set or replace the diagnostic operation name.
309    pub fn with_name(mut self, name: impl Into<String>) -> Self {
310        self.policy = self.policy.with_name(name);
311        self
312    }
313
314    /// Return the reusable cache policy backing this descriptor.
315    pub fn cache_policy(&self) -> &QueryCachePolicy {
316        &self.policy
317    }
318
319    /// Replace the current cache policy.
320    ///
321    /// This is the lowest-friction way to reuse one policy across SQLx,
322    /// Diesel, SeaORM, or repository-style call sites while keeping the loader
323    /// itself fully caller-controlled.
324    pub fn with_policy(mut self, policy: QueryCachePolicy) -> Self {
325        self.policy = policy;
326        self
327    }
328
329    /// Return the namespace used for physical cache keys.
330    pub fn namespace(&self) -> &str {
331        &self.namespace
332    }
333
334    /// Return the logical key, if one has been configured.
335    pub fn key_value(&self) -> Option<&str> {
336        self.policy.key_value()
337    }
338
339    /// Return the physical cache key, including the adapter namespace.
340    pub fn physical_key(&self) -> Option<String> {
341        let key = self.key_value()?;
342        Some(physical_key(&self.namespace, key))
343    }
344
345    /// Return the configured tags.
346    pub fn tags_value(&self) -> &[String] {
347        self.policy.tags_value()
348    }
349
350    /// Return the configured per-entry TTL.
351    pub fn ttl_value(&self) -> Option<Duration> {
352        self.policy.ttl_value()
353    }
354
355    /// Set the logical cache key for this query result.
356    pub fn key(mut self, key: impl Into<String>) -> Self {
357        self.policy = self.policy.key(key);
358        self
359    }
360
361    /// Set the logical cache key from a segmented key builder.
362    pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
363        self.key(key.build_string())
364    }
365
366    /// Set the logical key and add an entity invalidation tag.
367    ///
368    /// `for_entity("user", 42)` sets the key to `user:42` and adds the tag
369    /// `user:42`. Both segments are escaped with [`CacheKeyBuilder`], so `:` and
370    /// `%` inside one segment cannot accidentally create extra key segments.
371    ///
372    /// # Example
373    ///
374    /// ```rust
375    /// use hydracache::HydraCache;
376    /// use hydracache_db::DbCache;
377    /// use serde::{Deserialize, Serialize};
378    ///
379    /// #[derive(Debug, Clone, Serialize, Deserialize)]
380    /// struct User {
381    ///     id: i64,
382    /// }
383    ///
384    /// let queries = DbCache::new(HydraCache::local().build(), "db");
385    /// let query = queries
386    ///     .cached::<User>()
387    ///     .tag("users")
388    ///     .for_entity("user", 42);
389    ///
390    /// assert_eq!(query.key_value(), Some("user:42"));
391    /// assert_eq!(
392    ///     query.tags_value(),
393    ///     &["users".to_owned(), "user:42".to_owned()]
394    /// );
395    /// ```
396    pub fn for_entity(mut self, kind: impl ToString, id: impl ToString) -> Self {
397        self.policy = self.policy.for_entity(kind, id);
398        self
399    }
400
401    /// Set the logical key and tags from [`CacheEntity`] metadata.
402    ///
403    /// This is the metadata-driven equivalent of [`DbQuery::for_entity`]. It
404    /// preserves any existing tags, then adds the entity tag and optional
405    /// collection tag defined by `T`.
406    ///
407    /// # Example
408    ///
409    /// ```rust
410    /// use hydracache::HydraCache;
411    /// use hydracache_db::{CacheEntity, DbCache};
412    /// use serde::{Deserialize, Serialize};
413    ///
414    /// #[derive(Debug, Clone, Serialize, Deserialize)]
415    /// struct User {
416    ///     id: i64,
417    /// }
418    ///
419    /// impl CacheEntity for User {
420    ///     type Id = i64;
421    ///
422    ///     const ENTITY: &'static str = "user";
423    ///     const COLLECTION: Option<&'static str> = Some("users");
424    /// }
425    ///
426    /// let queries = DbCache::new(HydraCache::local().build(), "db");
427    /// let query = queries
428    ///     .cached::<User>()
429    ///     .tag("tenant:7")
430    ///     .for_cache_entity(42);
431    ///
432    /// assert_eq!(query.key_value(), Some("user:42"));
433    /// assert_eq!(
434    ///     query.tags_value(),
435    ///     &[
436    ///         "tenant:7".to_owned(),
437    ///         "user:42".to_owned(),
438    ///         "users".to_owned()
439    ///     ]
440    /// );
441    /// ```
442    pub fn for_cache_entity(mut self, id: T::Id) -> Self
443    where
444        T: CacheEntity,
445    {
446        self.policy = self.policy.for_cache_entity::<T>(id);
447        self
448    }
449
450    /// Set the logical key and invalidation tag for a collection result.
451    pub fn collection(mut self, name: impl ToString) -> Self {
452        self.policy = self.policy.collection(name);
453        self
454    }
455
456    /// Add one invalidation tag.
457    pub fn tag(mut self, tag: impl Into<String>) -> Self {
458        self.policy = self.policy.tag(tag);
459        self
460    }
461
462    /// Add a collection invalidation tag from one escaped key segment.
463    ///
464    /// Use this with [`DbCache::entity`] or [`DbQuery::for_entity`] when one
465    /// entity result also belongs to a broader list or query group.
466    ///
467    /// # Example
468    ///
469    /// ```rust
470    /// use hydracache::HydraCache;
471    /// use hydracache_db::DbCache;
472    /// use serde::{Deserialize, Serialize};
473    ///
474    /// #[derive(Debug, Clone, Serialize, Deserialize)]
475    /// struct User {
476    ///     id: i64,
477    /// }
478    ///
479    /// let queries = DbCache::new(HydraCache::local().build(), "db");
480    /// let query = queries
481    ///     .entity::<User>("user", 42)
482    ///     .collection_tag("users:active");
483    ///
484    /// assert_eq!(
485    ///     query.tags_value(),
486    ///     &["user:42".to_owned(), "users%3Aactive".to_owned()]
487    /// );
488    /// ```
489    pub fn collection_tag(mut self, name: impl ToString) -> Self {
490        self.policy = self.policy.collection_tag(name);
491        self
492    }
493
494    /// Add several invalidation tags.
495    pub fn tags<I, S>(mut self, tags: I) -> Self
496    where
497        I: IntoIterator<Item = S>,
498        S: Into<String>,
499    {
500        self.policy = self.policy.tags(tags);
501        self
502    }
503
504    /// Replace invalidation tags from a reusable [`TagSet`].
505    pub fn tag_set(mut self, tags: TagSet) -> Self {
506        self.policy = self.policy.tag_set(tags);
507        self
508    }
509
510    /// Set a per-entry TTL for this query result.
511    pub fn ttl(mut self, ttl: Duration) -> Self {
512        self.policy = self.policy.ttl(ttl);
513        self
514    }
515
516    /// Fetch a cached value or run the supplied repository/database loader on
517    /// miss.
518    ///
519    /// This is a short alias for [`DbQuery::fetch_with`]. It reads more
520    /// naturally when a call site is wrapping a repository method rather than a
521    /// raw SQL query.
522    pub async fn load<E, F, Fut>(self, loader: F) -> Result<T>
523    where
524        T: Serialize + DeserializeOwned + Send + 'static,
525        E: Error + Send + Sync + 'static,
526        F: FnOnce() -> Fut + Send + 'static,
527        Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
528    {
529        self.fetch_with(loader).await
530    }
531
532    /// Fetch a cached value or run the supplied database loader on miss.
533    ///
534    /// The loader is intentionally caller-supplied so the database library
535    /// remains responsible for pools, transactions, compile-time checked
536    /// queries, and row mapping. HydraCache owns only the cache boundary.
537    pub async fn fetch_with<E, F, Fut>(self, loader: F) -> Result<T>
538    where
539        T: Serialize + DeserializeOwned + Send + 'static,
540        E: Error + Send + Sync + 'static,
541        F: FnOnce() -> Fut + Send + 'static,
542        Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
543    {
544        self.fetch_value_with(loader).await
545    }
546
547    /// Fetch a cached value with an output type chosen by an adapter.
548    ///
549    /// Most application code should use [`DbQuery::fetch_with`]. This method is
550    /// intended for adapter crates that keep the descriptor type focused on a
551    /// database row while caching shapes such as `Option<T>` or `Vec<T>`.
552    pub async fn fetch_value_with<U, E, F, Fut>(self, loader: F) -> Result<U>
553    where
554        U: Serialize + DeserializeOwned + Send + 'static,
555        E: Error + Send + Sync + 'static,
556        F: FnOnce() -> Fut + Send + 'static,
557        Fut: Future<Output = std::result::Result<U, E>> + Send + 'static,
558    {
559        let key = self.required_physical_key()?;
560
561        self.cache
562            .get_or_load(&key, self.options(), loader)
563            .await
564            .map_err(DbCacheError::from)
565    }
566
567    fn options(&self) -> CacheOptions {
568        self.policy.cache_options()
569    }
570
571    fn required_physical_key(&self) -> Result<String> {
572        self.physical_key().ok_or_else(|| DbCacheError::MissingKey {
573            operation: self.operation_label(),
574        })
575    }
576
577    fn operation_label(&self) -> String {
578        self.name()
579            .map(str::to_owned)
580            .unwrap_or_else(|| default_operation_label(&self.namespace))
581    }
582}
583
584fn physical_key(namespace: &str, key: &str) -> String {
585    if namespace.is_empty() {
586        key.to_owned()
587    } else {
588        format!("{namespace}:{key}")
589    }
590}
591
592fn default_operation_label(namespace: &str) -> String {
593    if namespace.is_empty() {
594        "unnamed".to_owned()
595    } else {
596        format!("{namespace}:unnamed")
597    }
598}