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}