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}