fraiseql_core/cache/uuid_extractor.rs
1//! UUID extraction from mutation responses for entity-level cache invalidation.
2//!
3//! This module extracts entity UUIDs from GraphQL mutation response objects,
4//! enabling precise, entity-level cache invalidation instead of view-level invalidation.
5//!
6//! # Architecture
7//!
8//! ```text
9//! Mutation Response
10//! ┌──────────────────────────────────┐
11//! │ { │
12//! │ "id": "550e8400-e29b-...", │
13//! │ "name": "Alice", │
14//! │ "created_at": "2025-01-16" │
15//! │ } │
16//! └──────────┬───────────────────────┘
17//! │
18//! ↓ extract_entity_uuid()
19//! ┌──────────────────────────────────┐
20//! │ "550e8400-e29b-41d4-..." │
21//! └──────────────────────────────────┘
22//! ```
23//!
24//! # UUID Format Support
25//!
26//! - **UUID v4**: Standard format (RFC 4122)
27//! - **UUID v1**: Timestamp-based
28//! - **Custom UUIDs**: Any string matching UUID regex
29//!
30//! # Examples
31//!
32//! ```
33//! use fraiseql_core::cache::UUIDExtractor;
34//! use serde_json::json;
35//!
36//! let response = json!({
37//! "id": "550e8400-e29b-41d4-a716-446655440000",
38//! "name": "Alice"
39//! });
40//!
41//! let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
42//! assert_eq!(uuid, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
43//! ```
44
45use std::sync::OnceLock;
46
47use regex::Regex;
48use serde_json::Value;
49
50use crate::error::Result;
51
52/// UUID v4 format regex (RFC 4122).
53/// Matches: 550e8400-e29b-41d4-a716-446655440000
54fn uuid_regex() -> &'static Regex {
55 static REGEX: OnceLock<Regex> = OnceLock::new();
56 REGEX.get_or_init(|| {
57 Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
58 .expect("UUID regex is valid")
59 })
60}
61
62/// Extracts entity UUIDs from mutation response objects.
63///
64/// Handles various response formats:
65/// - Simple: `{ "id": "uuid", "name": "..." }`
66/// - Nested: `{ "user": { "id": "uuid", "name": "..." } }`
67/// - Array: `[{ "id": "uuid1" }, { "id": "uuid2" }]`
68#[derive(Debug, Clone)]
69pub struct UUIDExtractor;
70
71impl UUIDExtractor {
72 /// Extract a single entity UUID from mutation response.
73 ///
74 /// # Arguments
75 ///
76 /// * `response` - JSON response from mutation
77 /// * `entity_type` - The entity type (e.g., "User", "Post")
78 ///
79 /// # Returns
80 ///
81 /// - `Ok(Some(uuid))` - UUID found and valid
82 /// - `Ok(None)` - No UUID found (e.g., null response)
83 /// - `Err(_)` - Invalid UUID format
84 ///
85 /// # Examples
86 ///
87 /// ```
88 /// use fraiseql_core::cache::UUIDExtractor;
89 /// use serde_json::json;
90 ///
91 /// let response = json!({"id": "550e8400-e29b-41d4-a716-446655440000"});
92 /// let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
93 /// assert_eq!(uuid, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
94 /// ```
95 pub fn extract_entity_uuid(response: &Value, _entity_type: &str) -> Result<Option<String>> {
96 match response {
97 Value::Null => Ok(None),
98 Value::Object(obj) => {
99 // Try to find "id" field at top level
100 if let Some(id_value) = obj.get("id") {
101 return extract_uuid_from_value(id_value);
102 }
103
104 // If not found at top level, try nested structure
105 // (e.g., { user: { id: "uuid" } })
106 for (_key, value) in obj.iter() {
107 if let Value::Object(nested) = value {
108 if let Some(id_value) = nested.get("id") {
109 return extract_uuid_from_value(id_value);
110 }
111 }
112 }
113
114 Ok(None)
115 },
116 _ => Ok(None),
117 }
118 }
119
120 /// Extract multiple entity UUIDs from mutation response (batch operations).
121 ///
122 /// # Arguments
123 ///
124 /// * `response` - JSON response (array or object)
125 /// * `entity_type` - The entity type (e.g., "User", "Post")
126 ///
127 /// # Returns
128 ///
129 /// List of extracted UUIDs (empty if none found)
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// use fraiseql_core::cache::UUIDExtractor;
135 /// use serde_json::json;
136 ///
137 /// let response = json!([
138 /// {"id": "550e8400-e29b-41d4-a716-446655440001"},
139 /// {"id": "550e8400-e29b-41d4-a716-446655440002"},
140 /// {"id": "550e8400-e29b-41d4-a716-446655440003"}
141 /// ]);
142 /// let uuids = UUIDExtractor::extract_batch_uuids(&response, "User").unwrap();
143 /// assert_eq!(uuids.len(), 3);
144 /// ```
145 pub fn extract_batch_uuids(response: &Value, _entity_type: &str) -> Result<Vec<String>> {
146 match response {
147 Value::Array(arr) => {
148 let mut uuids = Vec::new();
149 for item in arr {
150 if let Ok(Some(uuid)) = extract_uuid_from_value(item) {
151 uuids.push(uuid);
152 }
153 }
154 Ok(uuids)
155 },
156 Value::Object(_) => {
157 // Single object - try to extract single UUID
158 match Self::extract_entity_uuid(response, "")? {
159 Some(uuid) => Ok(vec![uuid]),
160 None => Ok(vec![]),
161 }
162 },
163 Value::Null => Ok(vec![]),
164 _ => Ok(vec![]),
165 }
166 }
167
168 /// Validate if a string is a valid UUID format.
169 ///
170 /// # Arguments
171 ///
172 /// * `id` - String to validate
173 ///
174 /// # Returns
175 ///
176 /// `true` if valid UUID format, `false` otherwise
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use fraiseql_core::cache::UUIDExtractor;
182 ///
183 /// assert!(UUIDExtractor::is_valid_uuid("550e8400-e29b-41d4-a716-446655440000"));
184 /// assert!(!UUIDExtractor::is_valid_uuid("not-a-uuid"));
185 /// ```
186 #[must_use]
187 pub fn is_valid_uuid(id: &str) -> bool {
188 uuid_regex().is_match(&id.to_lowercase())
189 }
190}
191
192/// Helper function to extract UUID from a JSON value.
193fn extract_uuid_from_value(value: &Value) -> Result<Option<String>> {
194 match value {
195 Value::String(s) => {
196 if UUIDExtractor::is_valid_uuid(s) {
197 Ok(Some(s.to_lowercase()))
198 } else {
199 // String value that's not a UUID - could be a valid use case
200 // (e.g., response mutation returns string ID that isn't UUID format)
201 Ok(None)
202 }
203 },
204 Value::Object(obj) => {
205 // Try to recursively find id in nested object
206 if let Some(id_val) = obj.get("id") {
207 return extract_uuid_from_value(id_val);
208 }
209 Ok(None)
210 },
211 _ => Ok(None),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use serde_json::json;
218
219 use super::*;
220
221 #[test]
222 fn test_extract_single_uuid_from_response() {
223 let response = json!({
224 "id": "550e8400-e29b-41d4-a716-446655440000",
225 "name": "Alice"
226 });
227
228 let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
229 assert_eq!(uuid, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
230 }
231
232 #[test]
233 fn test_extract_uuid_from_nested_response() {
234 let response = json!({
235 "user": {
236 "id": "550e8400-e29b-41d4-a716-446655440000",
237 "name": "Alice"
238 }
239 });
240
241 let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
242 assert_eq!(uuid, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
243 }
244
245 #[test]
246 fn test_extract_uuid_from_null_response() {
247 let response = Value::Null;
248
249 let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
250 assert_eq!(uuid, None);
251 }
252
253 #[test]
254 fn test_extract_batch_uuids_from_array() {
255 let response = json!([
256 {"id": "550e8400-e29b-41d4-a716-446655440000"},
257 {"id": "550e8400-e29b-41d4-a716-446655440001"},
258 {"id": "550e8400-e29b-41d4-a716-446655440002"}
259 ]);
260
261 let uuids = UUIDExtractor::extract_batch_uuids(&response, "User").unwrap();
262 assert_eq!(uuids.len(), 3);
263 assert!(uuids.contains(&"550e8400-e29b-41d4-a716-446655440000".to_string()));
264 }
265
266 #[test]
267 fn test_is_valid_uuid() {
268 assert!(UUIDExtractor::is_valid_uuid("550e8400-e29b-41d4-a716-446655440000"));
269 assert!(UUIDExtractor::is_valid_uuid("550E8400-E29B-41D4-A716-446655440000"));
270 assert!(!UUIDExtractor::is_valid_uuid("not-a-uuid"));
271 assert!(!UUIDExtractor::is_valid_uuid("550e8400"));
272 }
273
274 #[test]
275 fn test_skip_non_uuid_id_fields() {
276 let response = json!({
277 "id": "some-string-id",
278 "name": "Alice"
279 });
280
281 let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
282 // Non-UUID id field should not be extracted
283 assert_eq!(uuid, None);
284 }
285
286 #[test]
287 fn test_batch_mutations_multiple_entities() {
288 let response = json!([
289 {"id": "550e8400-e29b-41d4-a716-446655440000", "name": "Alice"},
290 {"id": "550e8400-e29b-41d4-a716-446655440001", "name": "Bob"}
291 ]);
292
293 let uuids = UUIDExtractor::extract_batch_uuids(&response, "User").unwrap();
294 assert_eq!(uuids.len(), 2);
295 }
296
297 #[test]
298 fn test_error_cases_invalid_format() {
299 let response = json!({"id": 12345});
300 let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
301 assert_eq!(uuid, None);
302 }
303}