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
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}