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 /// # Examples
63 ///
64 /// ```
65 /// use fraiseql_core::cache::EntityKey;
66 ///
67 /// let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
68 /// assert_eq!(key.entity_type, "User");
69 /// ```
70 pub fn new(entity_type: &str, entity_id: &str) -> Result<Self> {
71 if entity_type.is_empty() {
72 return Err(FraiseQLError::Validation {
73 message: "entity_type cannot be empty".to_string(),
74 path: None,
75 });
76 }
77
78 if entity_id.is_empty() {
79 return Err(FraiseQLError::Validation {
80 message: "entity_id cannot be empty".to_string(),
81 path: None,
82 });
83 }
84
85 Ok(Self {
86 entity_type: entity_type.to_string(),
87 entity_id: entity_id.to_string(),
88 })
89 }
90
91 /// Convert entity key to cache key format: "EntityType:entity_id"
92 ///
93 /// # Examples
94 ///
95 /// ```
96 /// use fraiseql_core::cache::EntityKey;
97 ///
98 /// let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
99 /// assert_eq!(key.to_cache_key(), "User:550e8400-e29b-41d4-a716-446655440000");
100 /// ```
101 #[must_use]
102 pub fn to_cache_key(&self) -> String {
103 format!("{}:{}", self.entity_type, self.entity_id)
104 }
105
106 /// Parse entity key from cache key format: "EntityType:entity_id"
107 ///
108 /// # Arguments
109 ///
110 /// * `cache_key` - String in format "Type:id"
111 ///
112 /// # Returns
113 ///
114 /// - `Ok(EntityKey)` - If format is valid
115 /// - `Err(_)` - If format is invalid
116 ///
117 /// # Examples
118 ///
119 /// ```
120 /// use fraiseql_core::cache::EntityKey;
121 ///
122 /// let key = EntityKey::from_cache_key("User:550e8400-e29b-41d4-a716-446655440000").unwrap();
123 /// assert_eq!(key.entity_type, "User");
124 /// ```
125 pub fn from_cache_key(cache_key: &str) -> Result<Self> {
126 let parts: Vec<&str> = cache_key.splitn(2, ':').collect();
127
128 if parts.len() != 2 {
129 return Err(FraiseQLError::Validation {
130 message: format!("Invalid entity key format: {}. Expected 'Type:id'", cache_key),
131 path: None,
132 });
133 }
134
135 Self::new(parts[0], parts[1])
136 }
137}
138
139impl fmt::Display for EntityKey {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 write!(f, "{}", self.to_cache_key())
142 }
143}
144
145impl Hash for EntityKey {
146 fn hash<H: Hasher>(&self, state: &mut H) {
147 self.entity_type.hash(state);
148 self.entity_id.hash(state);
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn test_create_valid_entity_key() {
158 let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
159 assert_eq!(key.entity_type, "User");
160 assert_eq!(key.entity_id, "550e8400-e29b-41d4-a716-446655440000");
161 }
162
163 #[test]
164 fn test_reject_empty_entity_type() {
165 let result = EntityKey::new("", "550e8400-e29b-41d4-a716-446655440000");
166 assert!(result.is_err());
167 }
168
169 #[test]
170 fn test_reject_empty_entity_id() {
171 let result = EntityKey::new("User", "");
172 assert!(result.is_err());
173 }
174
175 #[test]
176 fn test_serialize_to_cache_key_format() {
177 let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
178 let cache_key = key.to_cache_key();
179 assert_eq!(cache_key, "User:550e8400-e29b-41d4-a716-446655440000");
180 }
181
182 #[test]
183 fn test_deserialize_from_cache_key_format() {
184 let cache_key = "User:550e8400-e29b-41d4-a716-446655440000";
185 let key = EntityKey::from_cache_key(cache_key).unwrap();
186 assert_eq!(key.entity_type, "User");
187 assert_eq!(key.entity_id, "550e8400-e29b-41d4-a716-446655440000");
188 }
189
190 #[test]
191 fn test_hash_consistency_for_hashmap() {
192 use std::collections::HashMap;
193
194 let key1 = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
195 let key2 = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
196
197 let mut map = HashMap::new();
198 map.insert(key1.clone(), "value1");
199
200 // Same key should retrieve same value
201 assert_eq!(map.get(&key2), Some(&"value1"));
202
203 // Different key should not match
204 let key3 = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440001").unwrap();
205 assert_eq!(map.get(&key3), None);
206 }
207}