Skip to main content

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}