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