Skip to main content

hydracache_db/
prepared.rs

1use std::time::Duration;
2
3use hydracache::{CacheKeyBuilder, RefreshOptions, TagSet};
4
5use crate::policy::collection_tag;
6use crate::{CacheEntity, QueryCachePolicy};
7
8const SHORT_LIVED_TTL: Duration = Duration::from_secs(30);
9const READ_MOSTLY_TTL: Duration = Duration::from_secs(300);
10const PER_ENTITY_TTL: Duration = Duration::from_secs(300);
11const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(30);
12
13/// Prepared database query cache metadata.
14///
15/// `PreparedQueryPolicy` stores stable query-cache metadata once and binds only
16/// the dynamic part, such as an entity id, on the hot path. It remains
17/// database-neutral: SQLx, Diesel, SeaORM, or a hand-written repository can all
18/// turn the prepared policy into the ordinary [`QueryCachePolicy`] consumed by
19/// [`DbCache`](crate::DbCache).
20///
21/// # Example
22///
23/// ```rust
24/// use std::time::Duration;
25///
26/// use hydracache_db::{CacheEntity, PreparedQueryPolicy};
27///
28/// struct User;
29///
30/// impl CacheEntity for User {
31///     type Id = i64;
32///
33///     const ENTITY: &'static str = "user";
34///     const COLLECTION: Option<&'static str> = Some("users");
35/// }
36///
37/// let prepared = PreparedQueryPolicy::for_cache_entity::<User>()
38///     .with_name("load-user")
39///     .ttl(Duration::from_secs(60));
40///
41/// let policy = prepared.bind_id(42);
42/// assert_eq!(policy.name(), Some("load-user"));
43/// assert_eq!(policy.key_value(), Some("user:42"));
44/// assert_eq!(policy.tags_value(), &["users".to_owned(), "user:42".to_owned()]);
45/// ```
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PreparedQueryPolicy {
48    name: Option<String>,
49    key: PreparedQueryKey,
50    tags: TagSet,
51    ttl: Option<Duration>,
52    refresh: Option<RefreshOptions>,
53}
54
55impl Default for PreparedQueryPolicy {
56    fn default() -> Self {
57        Self {
58            name: None,
59            key: PreparedQueryKey::Missing,
60            tags: TagSet::new(),
61            ttl: None,
62            refresh: None,
63        }
64    }
65}
66
67impl PreparedQueryPolicy {
68    /// Create an empty prepared policy.
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Create a short-lived prepared policy for burst smoothing.
74    ///
75    /// The preset uses a 30 second TTL and leaves key/tags to the caller.
76    pub fn short_lived() -> Self {
77        Self::new().ttl(SHORT_LIVED_TTL)
78    }
79
80    /// Create a read-mostly prepared policy for values that change rarely.
81    ///
82    /// The preset uses a 5 minute TTL. Pair it with entity or collection tags
83    /// so writes can still invalidate cached results explicitly.
84    pub fn read_mostly() -> Self {
85        Self::new().ttl(READ_MOSTLY_TTL)
86    }
87
88    /// Create a prepared policy intended for one entity-shaped result.
89    ///
90    /// The preset uses a 5 minute TTL and expects the caller to add an entity
91    /// prefix with [`PreparedQueryPolicy::entity`] or to start from
92    /// [`PreparedQueryPolicy::for_cache_entity`].
93    pub fn per_entity() -> Self {
94        Self::new().ttl(PER_ENTITY_TTL)
95    }
96
97    /// Create a prepared policy for explicit-invalidation-only values.
98    ///
99    /// No TTL is configured. The value remains cached until invalidated,
100    /// removed, flushed, or evicted due to capacity pressure.
101    pub fn no_ttl_explicit_invalidation() -> Self {
102        Self::new()
103    }
104
105    /// Create a prepared policy for caching negative lookups briefly.
106    ///
107    /// Use this for `Option<T>` or domain-specific "not found" results where
108    /// repeated misses are expensive but long-lived absence would be unsafe.
109    /// The preset uses a 30 second TTL.
110    pub fn negative_cache() -> Self {
111        Self::new().ttl(NEGATIVE_CACHE_TTL)
112    }
113
114    /// Create a prepared policy with a diagnostic operation name.
115    pub fn named(name: impl Into<String>) -> Self {
116        Self::new().with_name(name)
117    }
118
119    /// Create a prepared entity-id policy from one escaped entity segment.
120    ///
121    /// The entity segment is escaped once. Each [`PreparedQueryPolicy::bind_id`]
122    /// call only escapes and appends the id segment.
123    pub fn for_entity(kind: impl ToString) -> Self {
124        Self::new().entity(kind)
125    }
126
127    /// Create a prepared entity-id policy from [`CacheEntity`] metadata.
128    ///
129    /// The entity prefix and optional collection tag are precomputed once.
130    pub fn for_cache_entity<T>() -> Self
131    where
132        T: CacheEntity,
133    {
134        Self::new().cache_entity::<T>()
135    }
136
137    /// Return the optional diagnostic operation name.
138    pub fn name(&self) -> Option<&str> {
139        self.name.as_deref()
140    }
141
142    /// Return `true` when this policy needs an id binding before it has a key.
143    pub fn requires_id(&self) -> bool {
144        matches!(self.key, PreparedQueryKey::EntityPrefix(_))
145    }
146
147    /// Return the static logical key, if this prepared policy has one.
148    pub fn static_key_value(&self) -> Option<&str> {
149        match &self.key {
150            PreparedQueryKey::Static(key) => Some(key),
151            PreparedQueryKey::Missing | PreparedQueryKey::EntityPrefix(_) => None,
152        }
153    }
154
155    /// Return the precomputed entity key prefix, if this is an entity policy.
156    pub fn entity_key_prefix(&self) -> Option<&str> {
157        match &self.key {
158            PreparedQueryKey::EntityPrefix(prefix) => Some(prefix),
159            PreparedQueryKey::Missing | PreparedQueryKey::Static(_) => None,
160        }
161    }
162
163    /// Return precomputed invalidation tags.
164    pub fn tags_value(&self) -> &[String] {
165        self.tags.as_slice()
166    }
167
168    /// Return the optional per-entry TTL.
169    pub fn ttl_value(&self) -> Option<Duration> {
170        self.ttl
171    }
172
173    /// Return the optional refresh/stale policy.
174    pub fn refresh_policy_value(&self) -> Option<RefreshOptions> {
175        self.refresh
176    }
177
178    /// Set or replace the diagnostic operation name.
179    pub fn with_name(mut self, name: impl Into<String>) -> Self {
180        self.name = Some(name.into());
181        self
182    }
183
184    /// Set a static logical key.
185    pub fn key(mut self, key: impl Into<String>) -> Self {
186        self.key = PreparedQueryKey::Static(key.into());
187        self
188    }
189
190    /// Set a static logical key from a segmented key builder.
191    pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
192        self.key(key.build_string())
193    }
194
195    /// Set an entity-id key prefix from one escaped entity segment.
196    pub fn entity(mut self, kind: impl ToString) -> Self {
197        self.key = PreparedQueryKey::EntityPrefix(escaped_segment(kind));
198        self
199    }
200
201    /// Set an entity-id key prefix and optional collection tag from
202    /// [`CacheEntity`] metadata while preserving preset TTL/name settings.
203    ///
204    /// # Example
205    ///
206    /// ```rust
207    /// use std::time::Duration;
208    ///
209    /// use hydracache_db::{CacheEntity, PreparedQueryPolicy};
210    ///
211    /// struct User;
212    ///
213    /// impl CacheEntity for User {
214    ///     type Id = i64;
215    ///
216    ///     const ENTITY: &'static str = "user";
217    ///     const COLLECTION: Option<&'static str> = Some("users");
218    /// }
219    ///
220    /// let policy = PreparedQueryPolicy::per_entity().cache_entity::<User>();
221    ///
222    /// assert_eq!(policy.entity_key_prefix(), Some("user"));
223    /// assert_eq!(policy.tags_value(), &["users".to_owned()]);
224    /// assert_eq!(policy.ttl_value(), Some(Duration::from_secs(300)));
225    /// ```
226    pub fn cache_entity<T>(self) -> Self
227    where
228        T: CacheEntity,
229    {
230        let mut policy = self.entity(T::ENTITY);
231        if let Some(tag) = T::COLLECTION {
232            policy = policy.collection_tag(tag);
233        }
234        policy
235    }
236
237    /// Set a static collection key and add the same collection invalidation tag.
238    pub fn collection(mut self, name: impl ToString) -> Self {
239        let tag = collection_tag(name);
240        self.key = PreparedQueryKey::Static(tag.clone());
241        self.tags = self.tags.tag(tag);
242        self
243    }
244
245    /// Add one precomputed invalidation tag.
246    pub fn tag(mut self, tag: impl Into<String>) -> Self {
247        self.tags = self.tags.tag(tag);
248        self
249    }
250
251    /// Add a precomputed collection invalidation tag from one escaped segment.
252    pub fn collection_tag(mut self, name: impl ToString) -> Self {
253        self.tags = self.tags.tag(collection_tag(name));
254        self
255    }
256
257    /// Add several precomputed invalidation tags.
258    pub fn tags<I, S>(mut self, tags: I) -> Self
259    where
260        I: IntoIterator<Item = S>,
261        S: Into<String>,
262    {
263        self.tags = self.tags.tags(tags);
264        self
265    }
266
267    /// Replace precomputed invalidation tags from a reusable [`TagSet`].
268    pub fn tag_set(mut self, tags: TagSet) -> Self {
269        self.tags = tags;
270        self
271    }
272
273    /// Set a precomputed per-entry TTL.
274    pub fn ttl(mut self, ttl: Duration) -> Self {
275        self.ttl = Some(ttl);
276        self
277    }
278
279    /// Set refresh/stale behavior for this prepared query result.
280    pub fn refresh_policy(mut self, refresh: RefreshOptions) -> Self {
281        self.refresh = Some(refresh);
282        self
283    }
284
285    /// Convert this prepared policy into a runtime [`QueryCachePolicy`].
286    ///
287    /// Entity-id policies still need [`PreparedQueryPolicy::bind_id`] to set a
288    /// key. Static-key and collection policies can use this method directly.
289    pub fn to_policy(&self) -> QueryCachePolicy {
290        let mut policy = self.base_policy();
291        if let PreparedQueryKey::Static(key) = &self.key {
292            policy = policy.key(key.clone());
293        }
294        policy
295    }
296
297    /// Bind an id to this prepared policy and produce a runtime policy.
298    ///
299    /// For entity policies, this creates the final logical key and adds the
300    /// entity tag. For static-key policies, the id is ignored and
301    /// [`PreparedQueryPolicy::to_policy`] behavior is used.
302    pub fn bind_id(&self, id: impl ToString) -> QueryCachePolicy {
303        let mut policy = self.to_policy();
304        if let PreparedQueryKey::EntityPrefix(prefix) = &self.key {
305            let key = format!("{prefix}:{}", escaped_segment(id));
306            policy = policy.key(key.clone()).tag(key);
307        }
308        policy
309    }
310
311    fn base_policy(&self) -> QueryCachePolicy {
312        let mut policy = QueryCachePolicy::new().tag_set(self.tags.clone());
313        if let Some(name) = &self.name {
314            policy = policy.with_name(name.clone());
315        }
316        if let Some(ttl) = self.ttl {
317            policy = policy.ttl(ttl);
318        }
319        if let Some(refresh) = self.refresh {
320            policy = policy.refresh_policy(refresh);
321        }
322        policy
323    }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
327enum PreparedQueryKey {
328    Missing,
329    Static(String),
330    EntityPrefix(String),
331}
332
333fn escaped_segment(segment: impl ToString) -> String {
334    CacheKeyBuilder::from_segment(segment).build_string()
335}
336
337#[cfg(test)]
338mod tests {
339    use std::time::Duration;
340
341    use hydracache::TagSet;
342
343    use crate::{CacheEntity, PreparedQueryPolicy};
344
345    struct User;
346
347    impl CacheEntity for User {
348        type Id = i64;
349
350        const ENTITY: &'static str = "user";
351        const COLLECTION: Option<&'static str> = Some("users");
352    }
353
354    #[test]
355    fn prepared_static_policy_builds_reusable_runtime_policy() {
356        let prepared = PreparedQueryPolicy::named("list-users")
357            .collection("users:active")
358            .ttl(Duration::from_secs(30));
359
360        assert!(!prepared.requires_id());
361        assert_eq!(prepared.name(), Some("list-users"));
362        assert_eq!(prepared.static_key_value(), Some("users%3Aactive"));
363        assert_eq!(prepared.entity_key_prefix(), None);
364        assert_eq!(prepared.tags_value(), &["users%3Aactive".to_owned()]);
365        assert_eq!(prepared.ttl_value(), Some(Duration::from_secs(30)));
366
367        let policy = prepared.to_policy();
368        assert_eq!(policy.key_value(), Some("users%3Aactive"));
369        assert_eq!(policy.tags_value(), &["users%3Aactive".to_owned()]);
370        assert_eq!(policy.ttl_value(), Some(Duration::from_secs(30)));
371    }
372
373    #[test]
374    fn prepared_presets_encode_common_ttl_intent() {
375        assert_eq!(
376            PreparedQueryPolicy::short_lived().ttl_value(),
377            Some(Duration::from_secs(30))
378        );
379        assert_eq!(
380            PreparedQueryPolicy::read_mostly().ttl_value(),
381            Some(Duration::from_secs(300))
382        );
383        assert_eq!(
384            PreparedQueryPolicy::per_entity().ttl_value(),
385            Some(Duration::from_secs(300))
386        );
387        assert_eq!(
388            PreparedQueryPolicy::no_ttl_explicit_invalidation().ttl_value(),
389            None
390        );
391        assert_eq!(
392            PreparedQueryPolicy::negative_cache().ttl_value(),
393            Some(Duration::from_secs(30))
394        );
395    }
396
397    #[test]
398    fn prepared_presets_compose_with_bound_entity_metadata() {
399        let prepared = PreparedQueryPolicy::per_entity()
400            .entity("user")
401            .collection_tag("users");
402        let policy = prepared.bind_id(42);
403
404        assert_eq!(policy.key_value(), Some("user:42"));
405        assert_eq!(
406            policy.tags_value(),
407            &["users".to_owned(), "user:42".to_owned()]
408        );
409        assert_eq!(policy.ttl_value(), Some(Duration::from_secs(300)));
410    }
411
412    #[test]
413    fn prepared_cache_entity_composes_with_presets() {
414        let prepared = PreparedQueryPolicy::per_entity()
415            .cache_entity::<User>()
416            .with_name("load-user");
417
418        assert_eq!(prepared.name(), Some("load-user"));
419        assert_eq!(prepared.entity_key_prefix(), Some("user"));
420        assert_eq!(prepared.tags_value(), &["users".to_owned()]);
421        assert_eq!(prepared.ttl_value(), Some(Duration::from_secs(300)));
422
423        let policy = prepared.bind_id(42);
424        assert_eq!(policy.key_value(), Some("user:42"));
425        assert_eq!(
426            policy.tags_value(),
427            &["users".to_owned(), "user:42".to_owned()]
428        );
429    }
430
431    #[test]
432    fn prepared_entity_policy_precomputes_prefix_and_binds_id() {
433        let prepared = PreparedQueryPolicy::for_entity("account:user")
434            .with_name("load-account-user")
435            .collection_tag("users:active");
436
437        assert!(prepared.requires_id());
438        assert_eq!(prepared.static_key_value(), None);
439        assert_eq!(prepared.entity_key_prefix(), Some("account%3Auser"));
440        assert_eq!(prepared.tags_value(), &["users%3Aactive".to_owned()]);
441
442        let policy = prepared.bind_id("42%beta");
443        assert_eq!(policy.name(), Some("load-account-user"));
444        assert_eq!(policy.key_value(), Some("account%3Auser:42%25beta"));
445        assert_eq!(
446            policy.tags_value(),
447            &[
448                "users%3Aactive".to_owned(),
449                "account%3Auser:42%25beta".to_owned()
450            ]
451        );
452    }
453
454    #[test]
455    fn prepared_cache_entity_policy_reuses_entity_metadata() {
456        let prepared = PreparedQueryPolicy::for_cache_entity::<User>()
457            .with_name("load-user")
458            .ttl(Duration::from_secs(60));
459
460        assert_eq!(prepared.entity_key_prefix(), Some("user"));
461        assert_eq!(prepared.tags_value(), &["users".to_owned()]);
462
463        let policy = prepared.bind_id(42);
464        assert_eq!(policy.name(), Some("load-user"));
465        assert_eq!(policy.key_value(), Some("user:42"));
466        assert_eq!(
467            policy.tags_value(),
468            &["users".to_owned(), "user:42".to_owned()]
469        );
470        assert_eq!(policy.ttl_value(), Some(Duration::from_secs(60)));
471    }
472
473    #[test]
474    fn prepared_policy_can_use_custom_static_key_and_tag_set() {
475        let prepared = PreparedQueryPolicy::new()
476            .key("tenant:7:users")
477            .tag_set(TagSet::new().tag("tenant:7").tag("users"));
478
479        let policy = prepared.to_policy();
480        assert_eq!(policy.key_value(), Some("tenant:7:users"));
481        assert_eq!(
482            policy.tags_value(),
483            &["tenant:7".to_owned(), "users".to_owned()]
484        );
485    }
486}