fraiseql_core/cache/entity_key.rs
1//! Type-safe entity keys for entity-level cache invalidation.
2//!
3//! An `EntityKey` represents a specific entity instance, combining:
4//! - `entity_type`: The type of entity (e.g., "User", "Post", "Comment")
5//! - `entity_id`: The unique identifier (UUID) of that instance
6//!
7//! Entity keys are used to track which queries depend on which specific entities,
8//! enabling precise invalidation when those entities are modified.
9//!
10//! # Format
11//!
12//! Entity keys are serialized as: `"EntityType:uuid"`
13//!
14//! Example:
15//! ```text
16//! "User:550e8400-e29b-41d4-a716-446655440000"
17//! "Post:e7d7a1a1-b2c3-4d5e-f6g7-h8i9j0k1l2m3"
18//! ```
19//!
20//! # Examples
21//!
22//! ```
23//! use fraiseql_core::cache::EntityKey;
24//!
25//! let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
26//! assert_eq!(key.to_cache_key(), "User:550e8400-e29b-41d4-a716-446655440000");
27//! ```
28
29use std::{
30 fmt,
31 hash::{Hash, Hasher},
32};
33
34use crate::error::{FraiseQLError, Result};
35
36/// Type-safe entity key for cache invalidation.
37///
38/// Combines entity type and ID into a single, hashable key for use in
39/// dependency tracking and cache invalidation.
40#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
41pub struct EntityKey {
42 /// Entity type (e.g., "User", "Post")
43 pub entity_type: String,
44
45 /// Entity ID (UUID)
46 pub entity_id: String,
47}
48
49impl EntityKey {
50 /// Create a new entity key with validation.
51 ///
52 /// # Arguments
53 ///
54 /// * `entity_type` - The type of entity (must be non-empty)
55 /// * `entity_id` - The entity's unique identifier (must be non-empty)
56 ///
57 /// # Returns
58 ///
59 /// - `Ok(EntityKey)` - If both arguments are valid
60 /// - `Err(_)` - If either argument is empty
61 ///
62 /// # Errors
63 ///
64 /// Returns [`FraiseQLError::Validation`] if `entity_type` or `entity_id` is
65 /// empty, or if `entity_type` contains a colon character (reserved as the
66 /// cache-key separator).
67 ///
68 /// # Examples
69 ///
70 /// ```
71 /// use fraiseql_core::cache::EntityKey;
72 ///
73 /// let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
74 /// assert_eq!(key.entity_type, "User");
75 /// ```
76 pub fn new(entity_type: &str, entity_id: &str) -> Result<Self> {
77 if entity_type.is_empty() {
78 return Err(FraiseQLError::Validation {
79 message: "entity_type cannot be empty".to_string(),
80 path: None,
81 });
82 }
83
84 // A colon in entity_type would corrupt the "Type:id" cache key format.
85 // `from_cache_key` uses splitn(2, ':'), so "Foo:Bar:id" would be parsed
86 // as type="Foo", id="Bar:id" — silently wrong.
87 if entity_type.contains(':') {
88 return Err(FraiseQLError::Validation {
89 message: format!(
90 "entity_type {entity_type:?} must not contain a colon character; \
91 colons are used as the cache-key separator"
92 ),
93 path: None,
94 });
95 }
96
97 if entity_id.is_empty() {
98 return Err(FraiseQLError::Validation {
99 message: "entity_id cannot be empty".to_string(),
100 path: None,
101 });
102 }
103
104 Ok(Self {
105 entity_type: entity_type.to_string(),
106 entity_id: entity_id.to_string(),
107 })
108 }
109
110 /// Convert entity key to cache key format: "`EntityType:entity_id`"
111 ///
112 /// # Examples
113 ///
114 /// ```
115 /// use fraiseql_core::cache::EntityKey;
116 ///
117 /// let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
118 /// assert_eq!(key.to_cache_key(), "User:550e8400-e29b-41d4-a716-446655440000");
119 /// ```
120 #[must_use]
121 pub fn to_cache_key(&self) -> String {
122 format!("{}:{}", self.entity_type, self.entity_id)
123 }
124
125 /// Parse entity key from cache key format: "`EntityType:entity_id`"
126 ///
127 /// # Arguments
128 ///
129 /// * `cache_key` - String in format "Type:id"
130 ///
131 /// # Returns
132 ///
133 /// - `Ok(EntityKey)` - If format is valid
134 /// - `Err(_)` - If format is invalid
135 ///
136 /// # Errors
137 ///
138 /// Returns [`FraiseQLError::Validation`] if `cache_key` does not contain a
139 /// colon separator or if the resulting type or id part is invalid (see
140 /// [`EntityKey::new`]).
141 ///
142 /// # Examples
143 ///
144 /// ```
145 /// use fraiseql_core::cache::EntityKey;
146 ///
147 /// let key = EntityKey::from_cache_key("User:550e8400-e29b-41d4-a716-446655440000").unwrap();
148 /// assert_eq!(key.entity_type, "User");
149 /// ```
150 pub fn from_cache_key(cache_key: &str) -> Result<Self> {
151 let parts: Vec<&str> = cache_key.splitn(2, ':').collect();
152
153 if parts.len() != 2 {
154 return Err(FraiseQLError::Validation {
155 message: format!("Invalid entity key format: {}. Expected 'Type:id'", cache_key),
156 path: None,
157 });
158 }
159
160 Self::new(parts[0], parts[1])
161 }
162}
163
164impl fmt::Display for EntityKey {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 write!(f, "{}", self.to_cache_key())
167 }
168}
169
170impl Hash for EntityKey {
171 fn hash<H: Hasher>(&self, state: &mut H) {
172 self.entity_type.hash(state);
173 self.entity_id.hash(state);
174 }
175}