fraiseql_core/cache/
entity_key.rs1use std::{
30 fmt,
31 hash::{Hash, Hasher},
32};
33
34use crate::error::{FraiseQLError, Result};
35
36#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
41pub struct EntityKey {
42 pub entity_type: String,
44
45 pub entity_id: String,
47}
48
49impl EntityKey {
50 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 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 #[must_use]
121 pub fn to_cache_key(&self) -> String {
122 format!("{}:{}", self.entity_type, self.entity_id)
123 }
124
125 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}
176
177#[cfg(test)]
178mod tests {
179 #![allow(clippy::unwrap_used)] use super::*;
182
183 #[test]
184 fn test_create_valid_entity_key() {
185 let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").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_reject_empty_entity_type() {
192 let result = EntityKey::new("", "550e8400-e29b-41d4-a716-446655440000");
193 assert!(
194 matches!(result, Err(FraiseQLError::Validation { .. })),
195 "expected Validation error for empty entity_type, got: {result:?}"
196 );
197 }
198
199 #[test]
200 fn test_reject_empty_entity_id() {
201 let result = EntityKey::new("User", "");
202 assert!(
203 matches!(result, Err(FraiseQLError::Validation { .. })),
204 "expected Validation error for empty entity_id, got: {result:?}"
205 );
206 }
207
208 #[test]
209 fn test_serialize_to_cache_key_format() {
210 let key = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
211 let cache_key = key.to_cache_key();
212 assert_eq!(cache_key, "User:550e8400-e29b-41d4-a716-446655440000");
213 }
214
215 #[test]
216 fn test_deserialize_from_cache_key_format() {
217 let cache_key = "User:550e8400-e29b-41d4-a716-446655440000";
218 let key = EntityKey::from_cache_key(cache_key).unwrap();
219 assert_eq!(key.entity_type, "User");
220 assert_eq!(key.entity_id, "550e8400-e29b-41d4-a716-446655440000");
221 }
222
223 #[test]
224 fn test_reject_colon_in_entity_type() {
225 let result = EntityKey::new("User:Admin", "550e8400-e29b-41d4-a716-446655440000");
226 assert!(result.is_err(), "colon in entity_type must be rejected");
227 let msg = result.unwrap_err().to_string();
228 assert!(
229 msg.contains("colon") || msg.contains("separator"),
230 "error should mention the separator: {msg}"
231 );
232 }
233
234 #[test]
235 fn test_reject_colon_only_in_entity_type() {
236 let result = EntityKey::new(":", "some-id");
237 assert!(
238 matches!(result, Err(FraiseQLError::Validation { .. })),
239 "expected Validation error for colon-only entity_type, got: {result:?}"
240 );
241 }
242
243 #[test]
244 fn test_entity_id_may_contain_colon() {
245 let result = EntityKey::new("User", "urn:uuid:550e8400-e29b-41d4-a716-446655440000");
247 assert!(result.is_ok(), "colon in entity_id must be accepted");
248 let key = result.unwrap();
250 let cache_key = key.to_cache_key();
251 let parsed = EntityKey::from_cache_key(&cache_key).unwrap();
252 assert_eq!(parsed.entity_type, "User");
253 assert_eq!(parsed.entity_id, "urn:uuid:550e8400-e29b-41d4-a716-446655440000");
254 }
255
256 #[test]
257 fn test_hash_consistency_for_hashmap() {
258 use std::collections::HashMap;
259
260 let key1 = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
261 let key2 = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440000").unwrap();
262
263 let mut map = HashMap::new();
264 map.insert(key1, "value1");
265
266 assert_eq!(map.get(&key2), Some(&"value1"));
268
269 let key3 = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440001").unwrap();
271 assert_eq!(map.get(&key3), None);
272 }
273}