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    /// # 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}
176
177#[cfg(test)]
178mod tests {
179    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
180
181    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        // Entity IDs can contain colons (e.g. URNs) — only entity_type is restricted.
246        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        // from_cache_key uses splitn(2, ':'), so it should reconstruct correctly.
249        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        // Same key should retrieve same value
267        assert_eq!(map.get(&key2), Some(&"value1"));
268
269        // Different key should not match
270        let key3 = EntityKey::new("User", "550e8400-e29b-41d4-a716-446655440001").unwrap();
271        assert_eq!(map.get(&key3), None);
272    }
273}