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, PreparedQueryPolicy, 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    /// Prepare a reusable database query descriptor from stable metadata.
136    ///
137    /// Use this when a repository method runs many times and only a small part
138    /// of the cache metadata changes per call, such as the entity id.
139    ///
140    /// # Example
141    ///
142    /// ```rust
143    /// use std::time::Duration;
144    ///
145    /// use hydracache::HydraCache;
146    /// use hydracache_db::{CacheEntity, DbCache, PreparedQueryPolicy};
147    /// use serde::{Deserialize, Serialize};
148    ///
149    /// #[derive(Debug, Clone, Serialize, Deserialize)]
150    /// struct User {
151    ///     id: i64,
152    /// }
153    ///
154    /// impl CacheEntity for User {
155    ///     type Id = i64;
156    ///
157    ///     const ENTITY: &'static str = "user";
158    ///     const COLLECTION: Option<&'static str> = Some("users");
159    /// }
160    ///
161    /// let queries = DbCache::new(HydraCache::local().build(), "db");
162    /// let load_user = queries.prepare::<User>(
163    ///     PreparedQueryPolicy::for_cache_entity::<User>()
164    ///         .with_name("load-user")
165    ///         .ttl(Duration::from_secs(60)),
166    /// );
167    ///
168    /// let query = load_user.for_id(42);
169    /// assert_eq!(query.physical_key(), Some("db:user:42".to_owned()));
170    /// assert_eq!(query.tags_value(), &["users".to_owned(), "user:42".to_owned()]);
171    /// ```
172    pub fn prepare<T>(&self, policy: PreparedQueryPolicy) -> PreparedDbQuery<T, C> {
173        PreparedDbQuery {
174            cache: self.cache.clone(),
175            namespace: self.namespace.clone(),
176            policy,
177            value: PhantomData,
178        }
179    }
180
181    /// Prepare a reusable entity-id descriptor from [`CacheEntity`] metadata.
182    pub fn prepare_entity<T>(&self) -> PreparedDbQuery<T, C>
183    where
184        T: CacheEntity,
185    {
186        self.prepare(PreparedQueryPolicy::for_cache_entity::<T>())
187    }
188
189    /// Start describing an entity-shaped cached value.
190    ///
191    /// This is a convenience layer over [`DbCache::cached`] that sets both the
192    /// logical key and the entity invalidation tag from escaped key segments.
193    /// For example, `entity::<User>("user", 42)` creates key `user:42` and tag
194    /// `user:42`; with namespace `db`, the physical cache key is `db:user:42`.
195    ///
196    /// # Example
197    ///
198    /// ```rust
199    /// use hydracache::HydraCache;
200    /// use hydracache_db::DbCache;
201    /// use serde::{Deserialize, Serialize};
202    ///
203    /// #[derive(Debug, Clone, Serialize, Deserialize)]
204    /// struct User {
205    ///     id: i64,
206    /// }
207    ///
208    /// let queries = DbCache::new(HydraCache::local().build(), "db");
209    /// let query = queries.entity::<User>("user", 42);
210    ///
211    /// assert_eq!(query.key_value(), Some("user:42"));
212    /// assert_eq!(query.tags_value(), &["user:42".to_owned()]);
213    /// assert_eq!(query.physical_key(), Some("db:user:42".to_owned()));
214    /// ```
215    pub fn entity<T>(&self, kind: impl ToString, id: impl ToString) -> DbQuery<T, C> {
216        self.cached::<T>().for_entity(kind, id)
217    }
218
219    /// Start describing an entity-shaped cached value from [`CacheEntity`]
220    /// metadata.
221    ///
222    /// This helper removes repeated entity and collection literals from call
223    /// sites. It sets the logical key, entity tag, and optional collection tag
224    /// defined by `T`.
225    ///
226    /// # Example
227    ///
228    /// ```rust
229    /// use hydracache::HydraCache;
230    /// use hydracache_db::{CacheEntity, DbCache};
231    /// use serde::{Deserialize, Serialize};
232    ///
233    /// #[derive(Debug, Clone, Serialize, Deserialize)]
234    /// struct User {
235    ///     id: i64,
236    /// }
237    ///
238    /// impl CacheEntity for User {
239    ///     type Id = i64;
240    ///
241    ///     const ENTITY: &'static str = "user";
242    ///     const COLLECTION: Option<&'static str> = Some("users");
243    /// }
244    ///
245    /// let queries = DbCache::new(HydraCache::local().build(), "db");
246    /// let query = queries.for_entity::<User>(42);
247    ///
248    /// assert_eq!(query.key_value(), Some("user:42"));
249    /// assert_eq!(
250    ///     query.tags_value(),
251    ///     &["user:42".to_owned(), "users".to_owned()]
252    /// );
253    /// ```
254    pub fn for_entity<T>(&self, id: T::Id) -> DbQuery<T, C>
255    where
256        T: CacheEntity,
257    {
258        self.cached::<T>().for_cache_entity(id)
259    }
260
261    /// Start describing a collection-shaped cached value.
262    ///
263    /// This sets both the logical key and the collection invalidation tag to
264    /// the escaped collection name. For example, `collection::<User>("users")`
265    /// creates key `users` and tag `users`.
266    ///
267    /// # Example
268    ///
269    /// ```rust
270    /// use hydracache::HydraCache;
271    /// use hydracache_db::DbCache;
272    /// use serde::{Deserialize, Serialize};
273    ///
274    /// #[derive(Debug, Clone, Serialize, Deserialize)]
275    /// struct User {
276    ///     id: i64,
277    /// }
278    ///
279    /// let queries = DbCache::new(HydraCache::local().build(), "db");
280    /// let query = queries.collection::<User>("users:active");
281    ///
282    /// assert_eq!(query.key_value(), Some("users%3Aactive"));
283    /// assert_eq!(query.tags_value(), &["users%3Aactive".to_owned()]);
284    /// assert_eq!(query.physical_key(), Some("db:users%3Aactive".to_owned()));
285    /// ```
286    pub fn collection<T>(&self, name: impl ToString) -> DbQuery<T, C> {
287        self.cached::<T>().collection(name)
288    }
289
290    /// Start describing a cacheable database-loaded value with a diagnostic name.
291    pub fn named<T>(&self, name: impl Into<String>) -> DbQuery<T, C> {
292        DbQuery {
293            cache: self.cache.clone(),
294            namespace: self.namespace.clone(),
295            policy: QueryCachePolicy::named(name),
296            value: PhantomData,
297        }
298    }
299
300    /// Start describing a cacheable SQL query result.
301    ///
302    /// Prefer [`DbCache::cached`] or [`DbCache::named`] when writing new code.
303    /// This method remains useful if you want the SQL text itself to be the
304    /// diagnostic label for errors and logs.
305    pub fn query_as<T>(&self, sql: impl Into<String>) -> DbQuery<T, C> {
306        self.named(sql)
307    }
308}
309
310/// A cacheable database query descriptor.
311///
312/// The descriptor is deliberately explicit: callers choose the key, tags, and
313/// TTL that match their freshness model. An operation name is optional and used
314/// only for diagnostics. `fetch_with` executes the supplied loader only on a
315/// cache miss.
316pub struct DbQuery<T, C = PostcardCodec>
317where
318    C: CacheCodec,
319{
320    cache: HydraCache<C>,
321    namespace: String,
322    policy: QueryCachePolicy,
323    value: PhantomData<fn() -> T>,
324}
325
326/// A prepared database query descriptor.
327///
328/// `PreparedDbQuery` keeps adapter state and stable query-cache metadata close
329/// together. It can cheaply create ordinary [`DbQuery`] values for each call,
330/// or execute loaders directly through `load_id`/`fetch_with_id`.
331pub struct PreparedDbQuery<T, C = PostcardCodec>
332where
333    C: CacheCodec,
334{
335    cache: HydraCache<C>,
336    namespace: String,
337    policy: PreparedQueryPolicy,
338    value: PhantomData<fn() -> T>,
339}
340
341impl<T, C> Clone for PreparedDbQuery<T, C>
342where
343    C: CacheCodec,
344{
345    fn clone(&self) -> Self {
346        Self {
347            cache: self.cache.clone(),
348            namespace: self.namespace.clone(),
349            policy: self.policy.clone(),
350            value: PhantomData,
351        }
352    }
353}
354
355impl<T, C> fmt::Debug for PreparedDbQuery<T, C>
356where
357    C: CacheCodec,
358{
359    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
360        formatter
361            .debug_struct("PreparedDbQuery")
362            .field("namespace", &self.namespace)
363            .field("policy", &self.policy)
364            .finish_non_exhaustive()
365    }
366}
367
368impl<T, C> PreparedDbQuery<T, C>
369where
370    C: CacheCodec,
371{
372    /// Return the namespace used for physical cache keys.
373    pub fn namespace(&self) -> &str {
374        &self.namespace
375    }
376
377    /// Return the underlying local cache.
378    pub fn cache(&self) -> &HydraCache<C> {
379        &self.cache
380    }
381
382    /// Return the prepared policy backing this descriptor.
383    pub fn prepared_policy(&self) -> &PreparedQueryPolicy {
384        &self.policy
385    }
386
387    /// Return the optional diagnostic operation name.
388    pub fn name(&self) -> Option<&str> {
389        self.policy.name()
390    }
391
392    /// Return whether this descriptor needs an id binding before execution.
393    pub fn requires_id(&self) -> bool {
394        self.policy.requires_id()
395    }
396
397    /// Return the static logical key, if this descriptor has one.
398    pub fn static_key_value(&self) -> Option<&str> {
399        self.policy.static_key_value()
400    }
401
402    /// Return the precomputed entity key prefix, if this is an entity policy.
403    pub fn entity_key_prefix(&self) -> Option<&str> {
404        self.policy.entity_key_prefix()
405    }
406
407    /// Return the precomputed tags.
408    pub fn tags_value(&self) -> &[String] {
409        self.policy.tags_value()
410    }
411
412    /// Return the configured per-entry TTL.
413    pub fn ttl_value(&self) -> Option<Duration> {
414        self.policy.ttl_value()
415    }
416
417    /// Return the configured refresh/stale policy.
418    pub fn refresh_policy_value(&self) -> Option<hydracache::RefreshOptions> {
419        self.policy.refresh_policy_value()
420    }
421
422    /// Replace refresh/stale behavior for this prepared descriptor.
423    pub fn refresh_policy(mut self, refresh: hydracache::RefreshOptions) -> Self {
424        self.policy = self.policy.refresh_policy(refresh);
425        self
426    }
427
428    /// Create a runtime query from a static prepared policy.
429    ///
430    /// Entity-id policies should usually use [`PreparedDbQuery::for_id`] so the
431    /// dynamic id becomes part of the key and entity invalidation tag.
432    pub fn to_query(&self) -> DbQuery<T, C> {
433        self.query_from_policy(self.policy.to_policy())
434    }
435
436    /// Bind an id and create a runtime query.
437    pub fn for_id(&self, id: impl ToString) -> DbQuery<T, C> {
438        self.query_from_policy(self.policy.bind_id(id))
439    }
440
441    /// Fetch a cached value from a static prepared policy or run the loader.
442    pub async fn load<E, F, Fut>(&self, loader: F) -> Result<T>
443    where
444        T: Serialize + DeserializeOwned + Send + 'static,
445        E: Error + Send + Sync + 'static,
446        F: FnOnce() -> Fut + Send + 'static,
447        Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
448    {
449        self.to_query().load(loader).await
450    }
451
452    /// Bind an id, fetch a cached value, or run the loader.
453    pub async fn load_id<E, F, Fut>(&self, id: impl ToString, loader: F) -> Result<T>
454    where
455        T: Serialize + DeserializeOwned + Send + 'static,
456        E: Error + Send + Sync + 'static,
457        F: FnOnce() -> Fut + Send + 'static,
458        Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
459    {
460        self.for_id(id).load(loader).await
461    }
462
463    /// Fetch a static prepared value with an output type chosen by an adapter.
464    pub async fn fetch_value_with<U, E, F, Fut>(&self, loader: F) -> Result<U>
465    where
466        U: Serialize + DeserializeOwned + Send + 'static,
467        E: Error + Send + Sync + 'static,
468        F: FnOnce() -> Fut + Send + 'static,
469        Fut: Future<Output = std::result::Result<U, E>> + Send + 'static,
470    {
471        self.to_query().fetch_value_with(loader).await
472    }
473
474    /// Bind an id and fetch a value with an output type chosen by an adapter.
475    pub async fn fetch_value_with_id<U, E, F, Fut>(&self, id: impl ToString, loader: F) -> Result<U>
476    where
477        U: Serialize + DeserializeOwned + Send + 'static,
478        E: Error + Send + Sync + 'static,
479        F: FnOnce() -> Fut + Send + 'static,
480        Fut: Future<Output = std::result::Result<U, E>> + Send + 'static,
481    {
482        self.for_id(id).fetch_value_with(loader).await
483    }
484
485    fn query_from_policy(&self, policy: QueryCachePolicy) -> DbQuery<T, C> {
486        DbQuery {
487            cache: self.cache.clone(),
488            namespace: self.namespace.clone(),
489            policy,
490            value: PhantomData,
491        }
492    }
493}
494
495impl<T, C> Clone for DbQuery<T, C>
496where
497    C: CacheCodec,
498{
499    fn clone(&self) -> Self {
500        Self {
501            cache: self.cache.clone(),
502            namespace: self.namespace.clone(),
503            policy: self.policy.clone(),
504            value: PhantomData,
505        }
506    }
507}
508
509impl<T, C> fmt::Debug for DbQuery<T, C>
510where
511    C: CacheCodec,
512{
513    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
514        formatter
515            .debug_struct("DbQuery")
516            .field("namespace", &self.namespace)
517            .field("policy", &self.policy)
518            .finish_non_exhaustive()
519    }
520}
521
522impl<T, C> DbQuery<T, C>
523where
524    C: CacheCodec,
525{
526    /// Return the optional diagnostic operation name.
527    pub fn name(&self) -> Option<&str> {
528        self.policy.name()
529    }
530
531    /// Set or replace the diagnostic operation name.
532    pub fn with_name(mut self, name: impl Into<String>) -> Self {
533        self.policy = self.policy.with_name(name);
534        self
535    }
536
537    /// Return the reusable cache policy backing this descriptor.
538    pub fn cache_policy(&self) -> &QueryCachePolicy {
539        &self.policy
540    }
541
542    /// Replace the current cache policy.
543    ///
544    /// This is the lowest-friction way to reuse one policy across SQLx,
545    /// Diesel, SeaORM, or repository-style call sites while keeping the loader
546    /// itself fully caller-controlled.
547    pub fn with_policy(mut self, policy: QueryCachePolicy) -> Self {
548        self.policy = policy;
549        self
550    }
551
552    /// Return the namespace used for physical cache keys.
553    pub fn namespace(&self) -> &str {
554        &self.namespace
555    }
556
557    /// Return the logical key, if one has been configured.
558    pub fn key_value(&self) -> Option<&str> {
559        self.policy.key_value()
560    }
561
562    /// Return the physical cache key, including the adapter namespace.
563    pub fn physical_key(&self) -> Option<String> {
564        let key = self.key_value()?;
565        Some(physical_key(&self.namespace, key))
566    }
567
568    /// Return the configured tags.
569    pub fn tags_value(&self) -> &[String] {
570        self.policy.tags_value()
571    }
572
573    /// Return the configured per-entry TTL.
574    pub fn ttl_value(&self) -> Option<Duration> {
575        self.policy.ttl_value()
576    }
577
578    /// Return the configured refresh/stale policy.
579    pub fn refresh_policy_value(&self) -> Option<hydracache::RefreshOptions> {
580        self.policy.refresh_policy_value()
581    }
582
583    /// Set the logical cache key for this query result.
584    pub fn key(mut self, key: impl Into<String>) -> Self {
585        self.policy = self.policy.key(key);
586        self
587    }
588
589    /// Set the logical cache key from a segmented key builder.
590    pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
591        self.key(key.build_string())
592    }
593
594    /// Set the logical key and add an entity invalidation tag.
595    ///
596    /// `for_entity("user", 42)` sets the key to `user:42` and adds the tag
597    /// `user:42`. Both segments are escaped with [`CacheKeyBuilder`], so `:` and
598    /// `%` inside one segment cannot accidentally create extra key segments.
599    ///
600    /// # Example
601    ///
602    /// ```rust
603    /// use hydracache::HydraCache;
604    /// use hydracache_db::DbCache;
605    /// use serde::{Deserialize, Serialize};
606    ///
607    /// #[derive(Debug, Clone, Serialize, Deserialize)]
608    /// struct User {
609    ///     id: i64,
610    /// }
611    ///
612    /// let queries = DbCache::new(HydraCache::local().build(), "db");
613    /// let query = queries
614    ///     .cached::<User>()
615    ///     .tag("users")
616    ///     .for_entity("user", 42);
617    ///
618    /// assert_eq!(query.key_value(), Some("user:42"));
619    /// assert_eq!(
620    ///     query.tags_value(),
621    ///     &["users".to_owned(), "user:42".to_owned()]
622    /// );
623    /// ```
624    pub fn for_entity(mut self, kind: impl ToString, id: impl ToString) -> Self {
625        self.policy = self.policy.for_entity(kind, id);
626        self
627    }
628
629    /// Set the logical key and tags from [`CacheEntity`] metadata.
630    ///
631    /// This is the metadata-driven equivalent of [`DbQuery::for_entity`]. It
632    /// preserves any existing tags, then adds the entity tag and optional
633    /// collection tag defined by `T`.
634    ///
635    /// # Example
636    ///
637    /// ```rust
638    /// use hydracache::HydraCache;
639    /// use hydracache_db::{CacheEntity, DbCache};
640    /// use serde::{Deserialize, Serialize};
641    ///
642    /// #[derive(Debug, Clone, Serialize, Deserialize)]
643    /// struct User {
644    ///     id: i64,
645    /// }
646    ///
647    /// impl CacheEntity for User {
648    ///     type Id = i64;
649    ///
650    ///     const ENTITY: &'static str = "user";
651    ///     const COLLECTION: Option<&'static str> = Some("users");
652    /// }
653    ///
654    /// let queries = DbCache::new(HydraCache::local().build(), "db");
655    /// let query = queries
656    ///     .cached::<User>()
657    ///     .tag("tenant:7")
658    ///     .for_cache_entity(42);
659    ///
660    /// assert_eq!(query.key_value(), Some("user:42"));
661    /// assert_eq!(
662    ///     query.tags_value(),
663    ///     &[
664    ///         "tenant:7".to_owned(),
665    ///         "user:42".to_owned(),
666    ///         "users".to_owned()
667    ///     ]
668    /// );
669    /// ```
670    pub fn for_cache_entity(mut self, id: T::Id) -> Self
671    where
672        T: CacheEntity,
673    {
674        self.policy = self.policy.for_cache_entity::<T>(id);
675        self
676    }
677
678    /// Set the logical key and invalidation tag for a collection result.
679    pub fn collection(mut self, name: impl ToString) -> Self {
680        self.policy = self.policy.collection(name);
681        self
682    }
683
684    /// Add one invalidation tag.
685    pub fn tag(mut self, tag: impl Into<String>) -> Self {
686        self.policy = self.policy.tag(tag);
687        self
688    }
689
690    /// Add a collection invalidation tag from one escaped key segment.
691    ///
692    /// Use this with [`DbCache::entity`] or [`DbQuery::for_entity`] when one
693    /// entity result also belongs to a broader list or query group.
694    ///
695    /// # Example
696    ///
697    /// ```rust
698    /// use hydracache::HydraCache;
699    /// use hydracache_db::DbCache;
700    /// use serde::{Deserialize, Serialize};
701    ///
702    /// #[derive(Debug, Clone, Serialize, Deserialize)]
703    /// struct User {
704    ///     id: i64,
705    /// }
706    ///
707    /// let queries = DbCache::new(HydraCache::local().build(), "db");
708    /// let query = queries
709    ///     .entity::<User>("user", 42)
710    ///     .collection_tag("users:active");
711    ///
712    /// assert_eq!(
713    ///     query.tags_value(),
714    ///     &["user:42".to_owned(), "users%3Aactive".to_owned()]
715    /// );
716    /// ```
717    pub fn collection_tag(mut self, name: impl ToString) -> Self {
718        self.policy = self.policy.collection_tag(name);
719        self
720    }
721
722    /// Add several invalidation tags.
723    pub fn tags<I, S>(mut self, tags: I) -> Self
724    where
725        I: IntoIterator<Item = S>,
726        S: Into<String>,
727    {
728        self.policy = self.policy.tags(tags);
729        self
730    }
731
732    /// Replace invalidation tags from a reusable [`TagSet`].
733    pub fn tag_set(mut self, tags: TagSet) -> Self {
734        self.policy = self.policy.tag_set(tags);
735        self
736    }
737
738    /// Set a per-entry TTL for this query result.
739    pub fn ttl(mut self, ttl: Duration) -> Self {
740        self.policy = self.policy.ttl(ttl);
741        self
742    }
743
744    /// Set refresh/stale behavior for this query result.
745    pub fn refresh_policy(mut self, refresh: hydracache::RefreshOptions) -> Self {
746        self.policy = self.policy.refresh_policy(refresh);
747        self
748    }
749
750    /// Fetch a cached value or run the supplied repository/database loader on
751    /// miss.
752    ///
753    /// This is a short alias for [`DbQuery::fetch_with`]. It reads more
754    /// naturally when a call site is wrapping a repository method rather than a
755    /// raw SQL query.
756    pub async fn load<E, F, Fut>(self, loader: F) -> Result<T>
757    where
758        T: Serialize + DeserializeOwned + Send + 'static,
759        E: Error + Send + Sync + 'static,
760        F: FnOnce() -> Fut + Send + 'static,
761        Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
762    {
763        self.fetch_with(loader).await
764    }
765
766    /// Fetch a cached value or run the supplied database loader on miss.
767    ///
768    /// The loader is intentionally caller-supplied so the database library
769    /// remains responsible for pools, transactions, compile-time checked
770    /// queries, and row mapping. HydraCache owns only the cache boundary.
771    pub async fn fetch_with<E, F, Fut>(self, loader: F) -> Result<T>
772    where
773        T: Serialize + DeserializeOwned + Send + 'static,
774        E: Error + Send + Sync + 'static,
775        F: FnOnce() -> Fut + Send + 'static,
776        Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
777    {
778        self.fetch_value_with(loader).await
779    }
780
781    /// Fetch a cached value with an output type chosen by an adapter.
782    ///
783    /// Most application code should use [`DbQuery::fetch_with`]. This method is
784    /// intended for adapter crates that keep the descriptor type focused on a
785    /// database row while caching shapes such as `Option<T>` or `Vec<T>`.
786    pub async fn fetch_value_with<U, E, F, Fut>(self, loader: F) -> Result<U>
787    where
788        U: Serialize + DeserializeOwned + Send + 'static,
789        E: Error + Send + Sync + 'static,
790        F: FnOnce() -> Fut + Send + 'static,
791        Fut: Future<Output = std::result::Result<U, E>> + Send + 'static,
792    {
793        let key = self.required_physical_key()?;
794        let options = self.options();
795
796        match self.policy.refresh_policy_value() {
797            Some(refresh) => self
798                .cache
799                .get_or_load_with_refresh(&key, options, refresh, loader)
800                .await
801                .map_err(DbCacheError::from),
802            None => self
803                .cache
804                .get_or_load(&key, options, loader)
805                .await
806                .map_err(DbCacheError::from),
807        }
808    }
809
810    fn options(&self) -> CacheOptions {
811        self.policy.cache_options()
812    }
813
814    fn required_physical_key(&self) -> Result<String> {
815        self.physical_key().ok_or_else(|| DbCacheError::MissingKey {
816            operation: self.operation_label(),
817        })
818    }
819
820    fn operation_label(&self) -> String {
821        self.name()
822            .map(str::to_owned)
823            .unwrap_or_else(|| default_operation_label(&self.namespace))
824    }
825}
826
827fn physical_key(namespace: &str, key: &str) -> String {
828    if namespace.is_empty() {
829        key.to_owned()
830    } else {
831        format!("{namespace}:{key}")
832    }
833}
834
835fn default_operation_label(namespace: &str) -> String {
836    if namespace.is_empty() {
837        "unnamed".to_owned()
838    } else {
839        format!("{namespace}:unnamed")
840    }
841}