Skip to main content

hydracache_db/
prepared.rs

1use std::time::Duration;
2
3use hydracache::{CacheKeyBuilder, TagSet};
4
5use crate::policy::collection_tag;
6use crate::{CacheEntity, QueryCachePolicy};
7
8/// Prepared database query cache metadata.
9///
10/// `PreparedQueryPolicy` stores stable query-cache metadata once and binds only
11/// the dynamic part, such as an entity id, on the hot path. It remains
12/// database-neutral: SQLx, Diesel, SeaORM, or a hand-written repository can all
13/// turn the prepared policy into the ordinary [`QueryCachePolicy`] consumed by
14/// [`DbCache`](crate::DbCache).
15///
16/// # Example
17///
18/// ```rust
19/// use std::time::Duration;
20///
21/// use hydracache_db::{CacheEntity, PreparedQueryPolicy};
22///
23/// struct User;
24///
25/// impl CacheEntity for User {
26///     type Id = i64;
27///
28///     const ENTITY: &'static str = "user";
29///     const COLLECTION: Option<&'static str> = Some("users");
30/// }
31///
32/// let prepared = PreparedQueryPolicy::for_cache_entity::<User>()
33///     .with_name("load-user")
34///     .ttl(Duration::from_secs(60));
35///
36/// let policy = prepared.bind_id(42);
37/// assert_eq!(policy.name(), Some("load-user"));
38/// assert_eq!(policy.key_value(), Some("user:42"));
39/// assert_eq!(policy.tags_value(), &["users".to_owned(), "user:42".to_owned()]);
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PreparedQueryPolicy {
43    name: Option<String>,
44    key: PreparedQueryKey,
45    tags: TagSet,
46    ttl: Option<Duration>,
47}
48
49impl Default for PreparedQueryPolicy {
50    fn default() -> Self {
51        Self {
52            name: None,
53            key: PreparedQueryKey::Missing,
54            tags: TagSet::new(),
55            ttl: None,
56        }
57    }
58}
59
60impl PreparedQueryPolicy {
61    /// Create an empty prepared policy.
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Create a prepared policy with a diagnostic operation name.
67    pub fn named(name: impl Into<String>) -> Self {
68        Self::new().with_name(name)
69    }
70
71    /// Create a prepared entity-id policy from one escaped entity segment.
72    ///
73    /// The entity segment is escaped once. Each [`PreparedQueryPolicy::bind_id`]
74    /// call only escapes and appends the id segment.
75    pub fn for_entity(kind: impl ToString) -> Self {
76        Self::new().entity(kind)
77    }
78
79    /// Create a prepared entity-id policy from [`CacheEntity`] metadata.
80    ///
81    /// The entity prefix and optional collection tag are precomputed once.
82    pub fn for_cache_entity<T>() -> Self
83    where
84        T: CacheEntity,
85    {
86        let mut policy = Self::for_entity(T::ENTITY);
87        if let Some(tag) = T::COLLECTION {
88            policy = policy.collection_tag(tag);
89        }
90        policy
91    }
92
93    /// Return the optional diagnostic operation name.
94    pub fn name(&self) -> Option<&str> {
95        self.name.as_deref()
96    }
97
98    /// Return `true` when this policy needs an id binding before it has a key.
99    pub fn requires_id(&self) -> bool {
100        matches!(self.key, PreparedQueryKey::EntityPrefix(_))
101    }
102
103    /// Return the static logical key, if this prepared policy has one.
104    pub fn static_key_value(&self) -> Option<&str> {
105        match &self.key {
106            PreparedQueryKey::Static(key) => Some(key),
107            PreparedQueryKey::Missing | PreparedQueryKey::EntityPrefix(_) => None,
108        }
109    }
110
111    /// Return the precomputed entity key prefix, if this is an entity policy.
112    pub fn entity_key_prefix(&self) -> Option<&str> {
113        match &self.key {
114            PreparedQueryKey::EntityPrefix(prefix) => Some(prefix),
115            PreparedQueryKey::Missing | PreparedQueryKey::Static(_) => None,
116        }
117    }
118
119    /// Return precomputed invalidation tags.
120    pub fn tags_value(&self) -> &[String] {
121        self.tags.as_slice()
122    }
123
124    /// Return the optional per-entry TTL.
125    pub fn ttl_value(&self) -> Option<Duration> {
126        self.ttl
127    }
128
129    /// Set or replace the diagnostic operation name.
130    pub fn with_name(mut self, name: impl Into<String>) -> Self {
131        self.name = Some(name.into());
132        self
133    }
134
135    /// Set a static logical key.
136    pub fn key(mut self, key: impl Into<String>) -> Self {
137        self.key = PreparedQueryKey::Static(key.into());
138        self
139    }
140
141    /// Set a static logical key from a segmented key builder.
142    pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
143        self.key(key.build_string())
144    }
145
146    /// Set an entity-id key prefix from one escaped entity segment.
147    pub fn entity(mut self, kind: impl ToString) -> Self {
148        self.key = PreparedQueryKey::EntityPrefix(escaped_segment(kind));
149        self
150    }
151
152    /// Set a static collection key and add the same collection invalidation tag.
153    pub fn collection(mut self, name: impl ToString) -> Self {
154        let tag = collection_tag(name);
155        self.key = PreparedQueryKey::Static(tag.clone());
156        self.tags = self.tags.tag(tag);
157        self
158    }
159
160    /// Add one precomputed invalidation tag.
161    pub fn tag(mut self, tag: impl Into<String>) -> Self {
162        self.tags = self.tags.tag(tag);
163        self
164    }
165
166    /// Add a precomputed collection invalidation tag from one escaped segment.
167    pub fn collection_tag(mut self, name: impl ToString) -> Self {
168        self.tags = self.tags.tag(collection_tag(name));
169        self
170    }
171
172    /// Add several precomputed invalidation tags.
173    pub fn tags<I, S>(mut self, tags: I) -> Self
174    where
175        I: IntoIterator<Item = S>,
176        S: Into<String>,
177    {
178        self.tags = self.tags.tags(tags);
179        self
180    }
181
182    /// Replace precomputed invalidation tags from a reusable [`TagSet`].
183    pub fn tag_set(mut self, tags: TagSet) -> Self {
184        self.tags = tags;
185        self
186    }
187
188    /// Set a precomputed per-entry TTL.
189    pub fn ttl(mut self, ttl: Duration) -> Self {
190        self.ttl = Some(ttl);
191        self
192    }
193
194    /// Convert this prepared policy into a runtime [`QueryCachePolicy`].
195    ///
196    /// Entity-id policies still need [`PreparedQueryPolicy::bind_id`] to set a
197    /// key. Static-key and collection policies can use this method directly.
198    pub fn to_policy(&self) -> QueryCachePolicy {
199        let mut policy = self.base_policy();
200        if let PreparedQueryKey::Static(key) = &self.key {
201            policy = policy.key(key.clone());
202        }
203        policy
204    }
205
206    /// Bind an id to this prepared policy and produce a runtime policy.
207    ///
208    /// For entity policies, this creates the final logical key and adds the
209    /// entity tag. For static-key policies, the id is ignored and
210    /// [`PreparedQueryPolicy::to_policy`] behavior is used.
211    pub fn bind_id(&self, id: impl ToString) -> QueryCachePolicy {
212        let mut policy = self.to_policy();
213        if let PreparedQueryKey::EntityPrefix(prefix) = &self.key {
214            let key = format!("{prefix}:{}", escaped_segment(id));
215            policy = policy.key(key.clone()).tag(key);
216        }
217        policy
218    }
219
220    fn base_policy(&self) -> QueryCachePolicy {
221        let mut policy = QueryCachePolicy::new().tag_set(self.tags.clone());
222        if let Some(name) = &self.name {
223            policy = policy.with_name(name.clone());
224        }
225        if let Some(ttl) = self.ttl {
226            policy = policy.ttl(ttl);
227        }
228        policy
229    }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
233enum PreparedQueryKey {
234    Missing,
235    Static(String),
236    EntityPrefix(String),
237}
238
239fn escaped_segment(segment: impl ToString) -> String {
240    CacheKeyBuilder::from_segment(segment).build_string()
241}
242
243#[cfg(test)]
244mod tests {
245    use std::time::Duration;
246
247    use hydracache::TagSet;
248
249    use crate::{CacheEntity, PreparedQueryPolicy};
250
251    struct User;
252
253    impl CacheEntity for User {
254        type Id = i64;
255
256        const ENTITY: &'static str = "user";
257        const COLLECTION: Option<&'static str> = Some("users");
258    }
259
260    #[test]
261    fn prepared_static_policy_builds_reusable_runtime_policy() {
262        let prepared = PreparedQueryPolicy::named("list-users")
263            .collection("users:active")
264            .ttl(Duration::from_secs(30));
265
266        assert!(!prepared.requires_id());
267        assert_eq!(prepared.name(), Some("list-users"));
268        assert_eq!(prepared.static_key_value(), Some("users%3Aactive"));
269        assert_eq!(prepared.entity_key_prefix(), None);
270        assert_eq!(prepared.tags_value(), &["users%3Aactive".to_owned()]);
271        assert_eq!(prepared.ttl_value(), Some(Duration::from_secs(30)));
272
273        let policy = prepared.to_policy();
274        assert_eq!(policy.key_value(), Some("users%3Aactive"));
275        assert_eq!(policy.tags_value(), &["users%3Aactive".to_owned()]);
276        assert_eq!(policy.ttl_value(), Some(Duration::from_secs(30)));
277    }
278
279    #[test]
280    fn prepared_entity_policy_precomputes_prefix_and_binds_id() {
281        let prepared = PreparedQueryPolicy::for_entity("account:user")
282            .with_name("load-account-user")
283            .collection_tag("users:active");
284
285        assert!(prepared.requires_id());
286        assert_eq!(prepared.static_key_value(), None);
287        assert_eq!(prepared.entity_key_prefix(), Some("account%3Auser"));
288        assert_eq!(prepared.tags_value(), &["users%3Aactive".to_owned()]);
289
290        let policy = prepared.bind_id("42%beta");
291        assert_eq!(policy.name(), Some("load-account-user"));
292        assert_eq!(policy.key_value(), Some("account%3Auser:42%25beta"));
293        assert_eq!(
294            policy.tags_value(),
295            &[
296                "users%3Aactive".to_owned(),
297                "account%3Auser:42%25beta".to_owned()
298            ]
299        );
300    }
301
302    #[test]
303    fn prepared_cache_entity_policy_reuses_entity_metadata() {
304        let prepared = PreparedQueryPolicy::for_cache_entity::<User>()
305            .with_name("load-user")
306            .ttl(Duration::from_secs(60));
307
308        assert_eq!(prepared.entity_key_prefix(), Some("user"));
309        assert_eq!(prepared.tags_value(), &["users".to_owned()]);
310
311        let policy = prepared.bind_id(42);
312        assert_eq!(policy.name(), Some("load-user"));
313        assert_eq!(policy.key_value(), Some("user:42"));
314        assert_eq!(
315            policy.tags_value(),
316            &["users".to_owned(), "user:42".to_owned()]
317        );
318        assert_eq!(policy.ttl_value(), Some(Duration::from_secs(60)));
319    }
320
321    #[test]
322    fn prepared_policy_can_use_custom_static_key_and_tag_set() {
323        let prepared = PreparedQueryPolicy::new()
324            .key("tenant:7:users")
325            .tag_set(TagSet::new().tag("tenant:7").tag("users"));
326
327        let policy = prepared.to_policy();
328        assert_eq!(policy.key_value(), Some("tenant:7:users"));
329        assert_eq!(
330            policy.tags_value(),
331            &["tenant:7".to_owned(), "users".to_owned()]
332        );
333    }
334}