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}