Skip to main content

fraiseql_core/cache/
cascade_response_parser.rs

1//! Parser for GraphQL Cascade responses to extract entity invalidation data.
2//!
3//! This module parses GraphQL mutation responses following the GraphQL Cascade specification,
4//! extracting all affected entities (updated and deleted) to enable entity-level cache
5//! invalidation.
6//!
7//! # Architecture
8//!
9//! ```text
10//! GraphQL Mutation Response
11//! ┌──────────────────────────────────┐
12//! │ {                                │
13//! │   "createPost": {                │
14//! │     "post": { ... },             │
15//! │     "cascade": {                 │
16//! │       "updated": [               │
17//! │         {                        │
18//! │           "__typename": "User",  │
19//! │           "id": "uuid-123",      │
20//! │           ...                    │
21//! │         }                        │
22//! │       ],                         │
23//! │       "deleted": [ ... ]         │
24//! │     }                            │
25//! │   }                              │
26//! │ }                                │
27//! └──────────────────────────────────┘
28//!            │
29//!            ↓ parse_cascade_response()
30//! ┌──────────────────────────────────┐
31//! │ CascadeEntities:                 │
32//! │ updated: [                       │
33//! │   EntityKey("User", "uuid-123")  │
34//! │ ]                                │
35//! │ deleted: []                      │
36//! └──────────────────────────────────┘
37//! ```
38//!
39//! # Examples
40//!
41//! ```rust
42//! use fraiseql_core::cache::CascadeResponseParser;
43//! use serde_json::json;
44//! # use fraiseql_core::error::Result;
45//! # fn example() -> Result<()> {
46//!
47//! let parser = CascadeResponseParser::new();
48//!
49//! let response = json!({
50//!   "createPost": {
51//!     "cascade": {
52//!       "updated": [
53//!         { "__typename": "User", "id": "550e8400-e29b-41d4-a716-446655440000" }
54//!       ]
55//!     }
56//!   }
57//! });
58//!
59//! let entities = parser.parse_cascade_response(&response)?;
60//! assert_eq!(entities.updated.len(), 1);
61//! assert_eq!(entities.updated[0].entity_type, "User");
62//! # Ok(())
63//! # }
64//! ```
65
66use serde_json::Value;
67
68use super::entity_key::EntityKey;
69use crate::error::{FraiseQLError, Result};
70
71/// Cascade entities extracted from a GraphQL mutation response.
72///
73/// Represents all entities affected by a mutation (both updated and deleted),
74/// used to determine which caches need invalidation.
75#[derive(Debug, Clone, Eq, PartialEq)]
76pub struct CascadeEntities {
77    /// Updated entities - entries that were modified or created
78    pub updated: Vec<EntityKey>,
79
80    /// Deleted entities - entries that were removed
81    pub deleted: Vec<EntityKey>,
82}
83
84impl CascadeEntities {
85    /// Create new cascade entities with separate updated and deleted lists.
86    #[must_use]
87    pub const fn new(updated: Vec<EntityKey>, deleted: Vec<EntityKey>) -> Self {
88        Self { updated, deleted }
89    }
90
91    /// Get all affected entities (both updated and deleted).
92    #[must_use]
93    pub fn all_affected(&self) -> Vec<EntityKey> {
94        let mut all = self.updated.clone();
95        all.extend(self.deleted.clone());
96        all
97    }
98
99    /// Check if cascade has any affected entities.
100    #[must_use]
101    pub const fn has_changes(&self) -> bool {
102        !self.updated.is_empty() || !self.deleted.is_empty()
103    }
104}
105
106/// Parser for GraphQL Cascade responses following the Cascade specification v1.1.
107///
108/// Extracts all affected entities from mutation responses to enable
109/// entity-level cache invalidation.
110#[derive(Debug, Clone)]
111pub struct CascadeResponseParser;
112
113impl CascadeResponseParser {
114    /// Create new cascade response parser.
115    #[must_use]
116    pub const fn new() -> Self {
117        Self
118    }
119
120    /// Parse cascade data from a GraphQL mutation response.
121    ///
122    /// # Arguments
123    ///
124    /// * `response` - The full GraphQL response containing cascade field
125    ///
126    /// # Returns
127    ///
128    /// `CascadeEntities` with all updated and deleted entities
129    ///
130    /// # Examples
131    ///
132    /// ```rust
133    /// use fraiseql_core::cache::CascadeResponseParser;
134    /// use serde_json::json;
135    /// # use fraiseql_core::error::Result;
136    /// # fn example() -> Result<()> {
137    /// let parser = CascadeResponseParser::new();
138    /// let response = json!({
139    ///   "createPost": {
140    ///     "cascade": {
141    ///       "updated": [
142    ///         { "__typename": "User", "id": "uuid-123" }
143    ///       ]
144    ///     }
145    ///   }
146    /// });
147    ///
148    /// let entities = parser.parse_cascade_response(&response)?;
149    /// assert_eq!(entities.updated.len(), 1);
150    /// # Ok(())
151    /// # }
152    /// ```
153    ///
154    /// # Errors
155    ///
156    /// Returns [`FraiseQLError::Validation`] if the cascade field is present but
157    /// malformed (e.g., `updated` or `deleted` is not an array, or an entity is
158    /// missing `__typename` / `id`).
159    pub fn parse_cascade_response(&self, response: &Value) -> Result<CascadeEntities> {
160        // Find cascade field in response
161        let cascade = self.find_cascade_field(response)?;
162
163        if cascade.is_null() {
164            // No cascade data - return empty
165            return Ok(CascadeEntities {
166                updated: Vec::new(),
167                deleted: Vec::new(),
168            });
169        }
170
171        // Extract updated entities
172        let updated = self.extract_entities_list(&cascade, "updated")?;
173
174        // Extract deleted entities
175        let deleted = self.extract_entities_list(&cascade, "deleted")?;
176
177        Ok(CascadeEntities { updated, deleted })
178    }
179
180    /// Find cascade field in nested response structure.
181    ///
182    /// Cascade field can be at various depths:
183    /// - response.mutation { cascade { ... } }
184    /// - response.data.mutation { cascade { ... } }
185    /// - etc.
186    fn find_cascade_field(&self, response: &Value) -> Result<Value> {
187        // Try direct cascade field
188        if let Some(cascade) = response.get("cascade") {
189            return Ok(cascade.clone());
190        }
191
192        // Try nested in data
193        if let Some(data) = response.get("data") {
194            if let Some(cascade) = data.get("cascade") {
195                return Ok(cascade.clone());
196            }
197
198            // Try deeper nesting (mutation result contains cascade)
199            for (_key, value) in data.as_object().unwrap_or(&serde_json::Map::default()) {
200                if let Some(cascade) = value.get("cascade") {
201                    return Ok(cascade.clone());
202                }
203            }
204        }
205
206        // Try top-level mutation response
207        for (_key, value) in response.as_object().unwrap_or(&serde_json::Map::default()) {
208            if let Some(cascade) = value.get("cascade") {
209                return Ok(cascade.clone());
210            }
211        }
212
213        // No cascade field found - return null (valid case: no side effects)
214        Ok(Value::Null)
215    }
216
217    /// Extract list of entities from cascade.updated or cascade.deleted.
218    fn extract_entities_list(&self, cascade: &Value, field_name: &str) -> Result<Vec<EntityKey>> {
219        let entities_array = match cascade.get(field_name) {
220            Some(Value::Array(arr)) => arr,
221            Some(Value::Null) | None => return Ok(Vec::new()),
222            Some(val) => {
223                return Err(FraiseQLError::Validation {
224                    message: format!(
225                        "cascade.{} should be array, got {}",
226                        field_name,
227                        match val {
228                            Value::Object(_) => "object",
229                            Value::String(_) => "string",
230                            Value::Number(_) => "number",
231                            Value::Bool(_) => "boolean",
232                            Value::Null => "null",
233                            Value::Array(_) => "unknown",
234                        }
235                    ),
236                    path:    Some(format!("cascade.{}", field_name)),
237                });
238            },
239        };
240
241        let mut entities = Vec::new();
242
243        for entity_obj in entities_array {
244            let entity = self.parse_cascade_entity(entity_obj)?;
245            entities.push(entity);
246        }
247
248        Ok(entities)
249    }
250
251    /// Parse a single entity from cascade.updated or cascade.deleted.
252    ///
253    /// Expects object with `__typename` and `id` fields.
254    fn parse_cascade_entity(&self, entity_obj: &Value) -> Result<EntityKey> {
255        let obj = entity_obj.as_object().ok_or_else(|| FraiseQLError::Validation {
256            message: "Cascade entity should be object".to_string(),
257            path:    Some("cascade.updated[*]".to_string()),
258        })?;
259
260        // Extract __typename
261        let type_name = obj.get("__typename").and_then(Value::as_str).ok_or_else(|| {
262            FraiseQLError::Validation {
263                message: "Cascade entity missing __typename field".to_string(),
264                path:    Some("cascade.updated[*].__typename".to_string()),
265            }
266        })?;
267
268        // Extract id
269        let entity_id =
270            obj.get("id").and_then(Value::as_str).ok_or_else(|| FraiseQLError::Validation {
271                message: "Cascade entity missing id field".to_string(),
272                path:    Some("cascade.updated[*].id".to_string()),
273            })?;
274
275        EntityKey::new(type_name, entity_id)
276    }
277}
278
279impl Default for CascadeResponseParser {
280    fn default() -> Self {
281        Self::new()
282    }
283}