Skip to main content

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}