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