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#[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 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn short_lived() -> Self {
77 Self::new().ttl(SHORT_LIVED_TTL)
78 }
79
80 pub fn read_mostly() -> Self {
85 Self::new().ttl(READ_MOSTLY_TTL)
86 }
87
88 pub fn per_entity() -> Self {
94 Self::new().ttl(PER_ENTITY_TTL)
95 }
96
97 pub fn no_ttl_explicit_invalidation() -> Self {
102 Self::new()
103 }
104
105 pub fn negative_cache() -> Self {
111 Self::new().ttl(NEGATIVE_CACHE_TTL)
112 }
113
114 pub fn named(name: impl Into<String>) -> Self {
116 Self::new().with_name(name)
117 }
118
119 pub fn for_entity(kind: impl ToString) -> Self {
124 Self::new().entity(kind)
125 }
126
127 pub fn for_cache_entity<T>() -> Self
131 where
132 T: CacheEntity,
133 {
134 Self::new().cache_entity::<T>()
135 }
136
137 pub fn name(&self) -> Option<&str> {
139 self.name.as_deref()
140 }
141
142 pub fn requires_id(&self) -> bool {
144 matches!(self.key, PreparedQueryKey::EntityPrefix(_))
145 }
146
147 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 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 pub fn tags_value(&self) -> &[String] {
165 self.tags.as_slice()
166 }
167
168 pub fn ttl_value(&self) -> Option<Duration> {
170 self.ttl
171 }
172
173 pub fn refresh_policy_value(&self) -> Option<RefreshOptions> {
175 self.refresh
176 }
177
178 pub fn with_name(mut self, name: impl Into<String>) -> Self {
180 self.name = Some(name.into());
181 self
182 }
183
184 pub fn key(mut self, key: impl Into<String>) -> Self {
186 self.key = PreparedQueryKey::Static(key.into());
187 self
188 }
189
190 pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
192 self.key(key.build_string())
193 }
194
195 pub fn entity(mut self, kind: impl ToString) -> Self {
197 self.key = PreparedQueryKey::EntityPrefix(escaped_segment(kind));
198 self
199 }
200
201 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 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 pub fn tag(mut self, tag: impl Into<String>) -> Self {
247 self.tags = self.tags.tag(tag);
248 self
249 }
250
251 pub fn collection_tag(mut self, name: impl ToString) -> Self {
253 self.tags = self.tags.tag(collection_tag(name));
254 self
255 }
256
257 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 pub fn tag_set(mut self, tags: TagSet) -> Self {
269 self.tags = tags;
270 self
271 }
272
273 pub fn ttl(mut self, ttl: Duration) -> Self {
275 self.ttl = Some(ttl);
276 self
277 }
278
279 pub fn refresh_policy(mut self, refresh: RefreshOptions) -> Self {
281 self.refresh = Some(refresh);
282 self
283 }
284
285 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 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}