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::LazyLock;
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
56static UUID_REGEX: LazyLock<Regex> = LazyLock::new(|| {
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/// Extracts entity UUIDs from mutation response objects.
62///
63/// Handles various response formats:
64/// - Simple: `{ "id": "uuid", "name": "..." }`
65/// - Nested: `{ "user": { "id": "uuid", "name": "..." } }`
66/// - Array: `[{ "id": "uuid1" }, { "id": "uuid2" }]`
67#[derive(Debug, Clone)]
68pub struct UUIDExtractor;
69
70impl UUIDExtractor {
71    /// Extract a single entity UUID from mutation response.
72    ///
73    /// # Arguments
74    ///
75    /// * `response` - JSON response from mutation
76    /// * `entity_type` - The entity type (e.g., "User", "Post")
77    ///
78    /// # Returns
79    ///
80    /// - `Ok(Some(uuid))` - UUID found and valid
81    /// - `Ok(None)` - No UUID found (e.g., null response)
82    ///
83    /// # Errors
84    ///
85    /// Returns [`FraiseQLError::Validation`] if an `"id"` field is found but
86    /// its value is not a valid UUID v4 string.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use fraiseql_core::cache::UUIDExtractor;
92    /// use serde_json::json;
93    ///
94    /// let response = json!({"id": "550e8400-e29b-41d4-a716-446655440000"});
95    /// let uuid = UUIDExtractor::extract_entity_uuid(&response, "User").unwrap();
96    /// assert_eq!(uuid, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
97    /// ```
98    pub fn extract_entity_uuid(response: &Value, _entity_type: &str) -> Result<Option<String>> {
99        match response {
100            Value::Object(obj) => {
101                // Try to find "id" field at top level
102                if let Some(id_value) = obj.get("id") {
103                    return extract_uuid_from_value(id_value);
104                }
105
106                // If not found at top level, try nested structure
107                // (e.g., { user: { id: "uuid" } })
108                for (_key, value) in obj {
109                    if let Value::Object(nested) = value {
110                        if let Some(id_value) = nested.get("id") {
111                            return extract_uuid_from_value(id_value);
112                        }
113                    }
114                }
115
116                Ok(None)
117            },
118            _ => Ok(None),
119        }
120    }
121
122    /// Extract multiple entity UUIDs from mutation response (batch operations).
123    ///
124    /// # Arguments
125    ///
126    /// * `response` - JSON response (array or object)
127    /// * `entity_type` - The entity type (e.g., "User", "Post")
128    ///
129    /// # Returns
130    ///
131    /// List of extracted UUIDs (empty if none found)
132    ///
133    /// # Errors
134    ///
135    /// Returns [`FraiseQLError::Validation`] if any `"id"` field found in the
136    /// response is not a valid UUID v4 string.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use fraiseql_core::cache::UUIDExtractor;
142    /// use serde_json::json;
143    ///
144    /// let response = json!([
145    ///     {"id": "550e8400-e29b-41d4-a716-446655440001"},
146    ///     {"id": "550e8400-e29b-41d4-a716-446655440002"},
147    ///     {"id": "550e8400-e29b-41d4-a716-446655440003"}
148    /// ]);
149    /// let uuids = UUIDExtractor::extract_batch_uuids(&response, "User").unwrap();
150    /// assert_eq!(uuids.len(), 3);
151    /// ```
152    pub fn extract_batch_uuids(response: &Value, _entity_type: &str) -> Result<Vec<String>> {
153        match response {
154            Value::Array(arr) => {
155                let mut uuids = Vec::new();
156                for item in arr {
157                    if let Ok(Some(uuid)) = extract_uuid_from_value(item) {
158                        uuids.push(uuid);
159                    }
160                }
161                Ok(uuids)
162            },
163            Value::Object(_) => {
164                // Single object - try to extract single UUID
165                match Self::extract_entity_uuid(response, "")? {
166                    Some(uuid) => Ok(vec![uuid]),
167                    None => Ok(vec![]),
168                }
169            },
170            _ => Ok(vec![]),
171        }
172    }
173
174    /// Validate if a string is a valid UUID format.
175    ///
176    /// # Arguments
177    ///
178    /// * `id` - String to validate
179    ///
180    /// # Returns
181    ///
182    /// `true` if valid UUID format, `false` otherwise
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use fraiseql_core::cache::UUIDExtractor;
188    ///
189    /// assert!(UUIDExtractor::is_valid_uuid("550e8400-e29b-41d4-a716-446655440000"));
190    /// assert!(!UUIDExtractor::is_valid_uuid("not-a-uuid"));
191    /// ```
192    #[must_use]
193    pub fn is_valid_uuid(id: &str) -> bool {
194        UUID_REGEX.is_match(&id.to_lowercase())
195    }
196}
197
198/// Helper function to extract UUID from a JSON value.
199fn extract_uuid_from_value(value: &Value) -> Result<Option<String>> {
200    match value {
201        Value::String(s) => {
202            if UUIDExtractor::is_valid_uuid(s) {
203                Ok(Some(s.to_lowercase()))
204            } else {
205                // String value that's not a UUID - could be a valid use case
206                // (e.g., response mutation returns string ID that isn't UUID format)
207                Ok(None)
208            }
209        },
210        Value::Object(obj) => {
211            // Try to recursively find id in nested object
212            if let Some(id_val) = obj.get("id") {
213                return extract_uuid_from_value(id_val);
214            }
215            Ok(None)
216        },
217        _ => Ok(None),
218    }
219}