Skip to main content

hydracache_db/
query.rs

1use std::error::Error;
2use std::future::Future;
3use std::marker::PhantomData;
4use std::time::Duration;
5
6use hydracache::{CacheKeyBuilder, CacheOptions, HydraCache, PostcardCodec, TagSet};
7use hydracache_core::CacheCodec;
8use serde::{de::DeserializeOwned, Serialize};
9
10use crate::{DbCacheError, Result};
11
12/// A database-oriented view over a [`HydraCache`] instance.
13///
14/// `DbCache` groups query result keys under a namespace while keeping all
15/// cache storage, single-flight, tags, TTL, and stats in the shared local cache.
16///
17/// # Example
18///
19/// ```rust
20/// use std::time::Duration;
21///
22/// use hydracache::HydraCache;
23/// use hydracache_db::DbCache;
24/// use serde::{Deserialize, Serialize};
25///
26/// #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27/// struct User {
28///     id: i64,
29///     name: String,
30/// }
31///
32/// # #[tokio::main]
33/// # async fn main() -> hydracache_db::Result<()> {
34/// let local = HydraCache::local().build();
35/// let queries = DbCache::new(local, "db");
36///
37/// let user = queries
38///     .cached::<User>()
39///     // Physical cache key: "db:user:42".
40///     .key("user:42")
41///     // Later, invalidate_tag("user:42") removes this result.
42///     .tag("user:42")
43///     .ttl(Duration::from_secs(60))
44///     .fetch_with(|| async {
45///         // Replace this block with code from sqlx, diesel, sea-orm, or any
46///         // other database client. It is called only when the cache does not
47///         // already contain "db:user:42" or when the cached value has expired.
48///         Ok::<_, std::io::Error>(User {
49///             id: 42,
50///             name: "Ada".to_owned(),
51///         })
52///     })
53///     .await?;
54///
55/// assert_eq!(user.id, 42);
56/// # Ok(())
57/// # }
58/// ```
59#[derive(Debug, Clone)]
60pub struct DbCache<C = PostcardCodec>
61where
62    C: CacheCodec,
63{
64    cache: HydraCache<C>,
65    namespace: String,
66}
67
68impl<C> DbCache<C>
69where
70    C: CacheCodec,
71{
72    /// Create a database query cache adapter over an existing local cache.
73    pub fn new(cache: HydraCache<C>, namespace: impl Into<String>) -> Self {
74        Self {
75            cache,
76            namespace: namespace.into(),
77        }
78    }
79
80    /// Return the namespace used for physical cache keys.
81    pub fn namespace(&self) -> &str {
82        &self.namespace
83    }
84
85    /// Return the underlying local cache.
86    pub fn cache(&self) -> &HydraCache<C> {
87        &self.cache
88    }
89
90    /// Start describing a cacheable database-loaded value.
91    ///
92    /// This is the preferred entry point when the query is already visible
93    /// inside the `fetch_with` loader through a database client, ORM, or
94    /// repository method.
95    pub fn cached<T>(&self) -> DbQuery<T, C> {
96        self.named("unnamed")
97    }
98
99    /// Start describing a cacheable database-loaded value with a diagnostic name.
100    pub fn named<T>(&self, name: impl Into<String>) -> DbQuery<T, C> {
101        DbQuery {
102            cache: self.cache.clone(),
103            namespace: self.namespace.clone(),
104            name: Some(name.into()),
105            key: None,
106            tags: TagSet::new(),
107            ttl: None,
108            value: PhantomData,
109        }
110    }
111
112    /// Start describing a cacheable SQL query result.
113    ///
114    /// Prefer [`DbCache::cached`] or [`DbCache::named`] when writing new code.
115    /// This method remains useful if you want the SQL text itself to be the
116    /// diagnostic label for errors and logs.
117    pub fn query_as<T>(&self, sql: impl Into<String>) -> DbQuery<T, C> {
118        self.named(sql)
119    }
120}
121
122/// A cacheable database query descriptor.
123///
124/// The descriptor is deliberately explicit: callers choose the key, tags, and
125/// TTL that match their freshness model. An operation name is optional and used
126/// only for diagnostics. `fetch_with` executes the supplied loader only on a
127/// cache miss.
128#[derive(Debug, Clone)]
129pub struct DbQuery<T, C = PostcardCodec>
130where
131    C: CacheCodec,
132{
133    cache: HydraCache<C>,
134    namespace: String,
135    name: Option<String>,
136    key: Option<String>,
137    tags: TagSet,
138    ttl: Option<Duration>,
139    value: PhantomData<fn() -> T>,
140}
141
142impl<T, C> DbQuery<T, C>
143where
144    C: CacheCodec,
145{
146    /// Return the optional diagnostic operation name.
147    pub fn name(&self) -> Option<&str> {
148        self.name.as_deref()
149    }
150
151    /// Set or replace the diagnostic operation name.
152    pub fn with_name(mut self, name: impl Into<String>) -> Self {
153        self.name = Some(name.into());
154        self
155    }
156
157    /// Return the namespace used for physical cache keys.
158    pub fn namespace(&self) -> &str {
159        &self.namespace
160    }
161
162    /// Return the logical key, if one has been configured.
163    pub fn key_value(&self) -> Option<&str> {
164        self.key.as_deref()
165    }
166
167    /// Return the physical cache key, including the adapter namespace.
168    pub fn physical_key(&self) -> Option<String> {
169        self.key
170            .as_deref()
171            .map(|key| physical_key(&self.namespace, key))
172    }
173
174    /// Return the configured tags.
175    pub fn tags_value(&self) -> &[String] {
176        self.tags.as_slice()
177    }
178
179    /// Return the configured per-entry TTL.
180    pub fn ttl_value(&self) -> Option<Duration> {
181        self.ttl
182    }
183
184    /// Set the logical cache key for this query result.
185    pub fn key(mut self, key: impl Into<String>) -> Self {
186        self.key = Some(key.into());
187        self
188    }
189
190    /// Set the logical cache key from a segmented key builder.
191    pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
192        self.key(key.build_string())
193    }
194
195    /// Add one invalidation tag.
196    pub fn tag(mut self, tag: impl Into<String>) -> Self {
197        self.tags = self.tags.tag(tag);
198        self
199    }
200
201    /// Add several invalidation tags.
202    pub fn tags<I, S>(mut self, tags: I) -> Self
203    where
204        I: IntoIterator<Item = S>,
205        S: Into<String>,
206    {
207        self.tags = self.tags.tags(tags);
208        self
209    }
210
211    /// Replace invalidation tags from a reusable [`TagSet`].
212    pub fn tag_set(mut self, tags: TagSet) -> Self {
213        self.tags = tags;
214        self
215    }
216
217    /// Set a per-entry TTL for this query result.
218    pub fn ttl(mut self, ttl: Duration) -> Self {
219        self.ttl = Some(ttl);
220        self
221    }
222
223    /// Fetch a cached value or run the supplied database loader on miss.
224    ///
225    /// The loader is intentionally caller-supplied so the database library
226    /// remains responsible for pools, transactions, compile-time checked
227    /// queries, and row mapping. HydraCache owns only the cache boundary.
228    pub async fn fetch_with<E, F, Fut>(self, loader: F) -> Result<T>
229    where
230        T: Serialize + DeserializeOwned + Send + 'static,
231        E: Error + Send + Sync + 'static,
232        F: FnOnce() -> Fut + Send + 'static,
233        Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
234    {
235        let Some(key) = self.physical_key() else {
236            return Err(DbCacheError::MissingKey {
237                operation: self.operation_label(),
238            });
239        };
240
241        self.cache
242            .get_or_load(&key, self.options(), loader)
243            .await
244            .map_err(DbCacheError::from)
245    }
246
247    fn options(&self) -> CacheOptions {
248        let mut options = CacheOptions::new().tag_set(self.tags.clone());
249        if let Some(ttl) = self.ttl {
250            options = options.ttl(ttl);
251        }
252        options
253    }
254
255    fn operation_label(&self) -> String {
256        match (&self.name, &self.key) {
257            (Some(name), _) => name.clone(),
258            (None, Some(key)) if self.namespace.is_empty() => key.clone(),
259            (None, Some(key)) => physical_key(&self.namespace, key),
260            (None, None) if self.namespace.is_empty() => "unnamed".to_owned(),
261            (None, None) => format!("{}:unnamed", self.namespace),
262        }
263    }
264}
265
266fn physical_key(namespace: &str, key: &str) -> String {
267    if namespace.is_empty() {
268        key.to_owned()
269    } else {
270        format!("{namespace}:{key}")
271    }
272}