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//! ```ignore
42//! use fraiseql_core::cache::CascadeResponseParser;
43//! use serde_json::json;
44//!
45//! let parser = CascadeResponseParser::new();
46//!
47//! let response = json!({
48//!   "createPost": {
49//!     "cascade": {
50//!       "updated": [
51//!         { "__typename": "User", "id": "550e8400-e29b-41d4-a716-446655440000" }
52//!       ]
53//!     }
54//!   }
55//! });
56//!
57//! let entities = parser.parse_cascade_response(&response)?;
58//! assert_eq!(entities.updated.len(), 1);
59//! assert_eq!(entities.updated[0].entity_type, "User");
60//! ```
61
62use serde_json::Value;
63
64use super::entity_key::EntityKey;
65use crate::error::{FraiseQLError, Result};
66
67/// Cascade entities extracted from a GraphQL mutation response.
68///
69/// Represents all entities affected by a mutation (both updated and deleted),
70/// used to determine which caches need invalidation.
71#[derive(Debug, Clone, Eq, PartialEq)]
72pub struct CascadeEntities {
73    /// Updated entities - entries that were modified or created
74    pub updated: Vec<EntityKey>,
75
76    /// Deleted entities - entries that were removed
77    pub deleted: Vec<EntityKey>,
78}
79
80impl CascadeEntities {
81    /// Create new cascade entities with separate updated and deleted lists.
82    pub fn new(updated: Vec<EntityKey>, deleted: Vec<EntityKey>) -> Self {
83        Self { updated, deleted }
84    }
85
86    /// Get all affected entities (both updated and deleted).
87    #[must_use]
88    pub fn all_affected(&self) -> Vec<EntityKey> {
89        let mut all = self.updated.clone();
90        all.extend(self.deleted.clone());
91        all
92    }
93
94    /// Check if cascade has any affected entities.
95    #[must_use]
96    pub fn has_changes(&self) -> bool {
97        !self.updated.is_empty() || !self.deleted.is_empty()
98    }
99}
100
101/// Parser for GraphQL Cascade responses following the Cascade specification v1.1.
102///
103/// Extracts all affected entities from mutation responses to enable
104/// entity-level cache invalidation.
105#[derive(Debug, Clone)]
106pub struct CascadeResponseParser;
107
108impl CascadeResponseParser {
109    /// Create new cascade response parser.
110    #[must_use]
111    pub fn new() -> Self {
112        Self
113    }
114
115    /// Parse cascade data from a GraphQL mutation response.
116    ///
117    /// # Arguments
118    ///
119    /// * `response` - The full GraphQL response containing cascade field
120    ///
121    /// # Returns
122    ///
123    /// `CascadeEntities` with all updated and deleted entities
124    ///
125    /// # Examples
126    ///
127    /// ```ignore
128    /// let response = json!({
129    ///   "createPost": {
130    ///     "cascade": {
131    ///       "updated": [
132    ///         { "__typename": "User", "id": "uuid-123" }
133    ///       ]
134    ///     }
135    ///   }
136    /// });
137    ///
138    /// let entities = parser.parse_cascade_response(&response)?;
139    /// ```
140    pub fn parse_cascade_response(&self, response: &Value) -> Result<CascadeEntities> {
141        // Find cascade field in response
142        let cascade = self.find_cascade_field(response)?;
143
144        if cascade.is_null() {
145            // No cascade data - return empty
146            return Ok(CascadeEntities {
147                updated: Vec::new(),
148                deleted: Vec::new(),
149            });
150        }
151
152        // Extract updated entities
153        let updated = self.extract_entities_list(&cascade, "updated")?;
154
155        // Extract deleted entities
156        let deleted = self.extract_entities_list(&cascade, "deleted")?;
157
158        Ok(CascadeEntities { updated, deleted })
159    }
160
161    /// Find cascade field in nested response structure.
162    ///
163    /// Cascade field can be at various depths:
164    /// - response.mutation { cascade { ... } }
165    /// - response.data.mutation { cascade { ... } }
166    /// - etc.
167    fn find_cascade_field(&self, response: &Value) -> Result<Value> {
168        // Try direct cascade field
169        if let Some(cascade) = response.get("cascade") {
170            return Ok(cascade.clone());
171        }
172
173        // Try nested in data
174        if let Some(data) = response.get("data") {
175            if let Some(cascade) = data.get("cascade") {
176                return Ok(cascade.clone());
177            }
178
179            // Try deeper nesting (mutation result contains cascade)
180            for (_key, value) in data.as_object().unwrap_or(&Default::default()).iter() {
181                if let Some(cascade) = value.get("cascade") {
182                    return Ok(cascade.clone());
183                }
184            }
185        }
186
187        // Try top-level mutation response
188        for (_key, value) in response.as_object().unwrap_or(&Default::default()).iter() {
189            if let Some(cascade) = value.get("cascade") {
190                return Ok(cascade.clone());
191            }
192        }
193
194        // No cascade field found - return null (valid case: no side effects)
195        Ok(Value::Null)
196    }
197
198    /// Extract list of entities from cascade.updated or cascade.deleted.
199    fn extract_entities_list(&self, cascade: &Value, field_name: &str) -> Result<Vec<EntityKey>> {
200        let entities_array = match cascade.get(field_name) {
201            Some(Value::Array(arr)) => arr,
202            Some(Value::Null) | None => return Ok(Vec::new()),
203            Some(val) => {
204                return Err(FraiseQLError::Validation {
205                    message: format!(
206                        "cascade.{} should be array, got {}",
207                        field_name,
208                        match val {
209                            Value::Object(_) => "object",
210                            Value::String(_) => "string",
211                            Value::Number(_) => "number",
212                            Value::Bool(_) => "boolean",
213                            Value::Null => "null",
214                            _ => "unknown",
215                        }
216                    ),
217                    path:    Some(format!("cascade.{}", field_name)),
218                });
219            },
220        };
221
222        let mut entities = Vec::new();
223
224        for entity_obj in entities_array.iter() {
225            let entity = self.parse_cascade_entity(entity_obj)?;
226            entities.push(entity);
227        }
228
229        Ok(entities)
230    }
231
232    /// Parse a single entity from cascade.updated or cascade.deleted.
233    ///
234    /// Expects object with `__typename` and `id` fields.
235    fn parse_cascade_entity(&self, entity_obj: &Value) -> Result<EntityKey> {
236        let obj = entity_obj.as_object().ok_or_else(|| FraiseQLError::Validation {
237            message: "Cascade entity should be object".to_string(),
238            path:    Some("cascade.updated[*]".to_string()),
239        })?;
240
241        // Extract __typename
242        let type_name = obj.get("__typename").and_then(Value::as_str).ok_or_else(|| {
243            FraiseQLError::Validation {
244                message: "Cascade entity missing __typename field".to_string(),
245                path:    Some("cascade.updated[*].__typename".to_string()),
246            }
247        })?;
248
249        // Extract id
250        let entity_id =
251            obj.get("id").and_then(Value::as_str).ok_or_else(|| FraiseQLError::Validation {
252                message: "Cascade entity missing id field".to_string(),
253                path:    Some("cascade.updated[*].id".to_string()),
254            })?;
255
256        EntityKey::new(type_name, entity_id)
257    }
258}
259
260impl Default for CascadeResponseParser {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use serde_json::json;
269
270    use super::*;
271
272    #[test]
273    fn test_parse_simple_cascade_response() {
274        let parser = CascadeResponseParser::new();
275        let response = json!({
276            "createPost": {
277                "cascade": {
278                    "updated": [
279                        {
280                            "__typename": "User",
281                            "id": "550e8400-e29b-41d4-a716-446655440000",
282                            "postCount": 5
283                        }
284                    ]
285                }
286            }
287        });
288
289        let entities = parser.parse_cascade_response(&response).unwrap();
290        assert_eq!(entities.updated.len(), 1);
291        assert_eq!(entities.updated[0].entity_type, "User");
292        assert_eq!(entities.updated[0].entity_id, "550e8400-e29b-41d4-a716-446655440000");
293        assert_eq!(entities.deleted.len(), 0);
294    }
295
296    #[test]
297    fn test_parse_multiple_updated_entities() {
298        let parser = CascadeResponseParser::new();
299        let response = json!({
300            "updateUser": {
301                "cascade": {
302                    "updated": [
303                        { "__typename": "User", "id": "uuid-1" },
304                        { "__typename": "Post", "id": "uuid-2" },
305                        { "__typename": "Notification", "id": "uuid-3" }
306                    ]
307                }
308            }
309        });
310
311        let entities = parser.parse_cascade_response(&response).unwrap();
312        assert_eq!(entities.updated.len(), 3);
313        assert_eq!(entities.updated[0].entity_type, "User");
314        assert_eq!(entities.updated[1].entity_type, "Post");
315        assert_eq!(entities.updated[2].entity_type, "Notification");
316    }
317
318    #[test]
319    fn test_parse_deleted_entities() {
320        let parser = CascadeResponseParser::new();
321        let response = json!({
322            "deletePost": {
323                "cascade": {
324                    "deleted": [
325                        { "__typename": "Post", "id": "post-uuid" },
326                        { "__typename": "Comment", "id": "comment-uuid" }
327                    ]
328                }
329            }
330        });
331
332        let entities = parser.parse_cascade_response(&response).unwrap();
333        assert_eq!(entities.updated.len(), 0);
334        assert_eq!(entities.deleted.len(), 2);
335        assert_eq!(entities.deleted[0].entity_type, "Post");
336        assert_eq!(entities.deleted[1].entity_type, "Comment");
337    }
338
339    #[test]
340    fn test_parse_both_updated_and_deleted() {
341        let parser = CascadeResponseParser::new();
342        let response = json!({
343            "mutation": {
344                "cascade": {
345                    "updated": [{ "__typename": "User", "id": "u-1" }],
346                    "deleted": [{ "__typename": "Session", "id": "s-1" }]
347                }
348            }
349        });
350
351        let entities = parser.parse_cascade_response(&response).unwrap();
352        assert_eq!(entities.updated.len(), 1);
353        assert_eq!(entities.deleted.len(), 1);
354        assert_eq!(entities.all_affected().len(), 2);
355    }
356
357    #[test]
358    fn test_parse_empty_cascade() {
359        let parser = CascadeResponseParser::new();
360        let response = json!({
361            "mutation": {
362                "cascade": {
363                    "updated": [],
364                    "deleted": []
365                }
366            }
367        });
368
369        let entities = parser.parse_cascade_response(&response).unwrap();
370        assert!(!entities.has_changes());
371        assert_eq!(entities.all_affected().len(), 0);
372    }
373
374    #[test]
375    fn test_parse_no_cascade_field() {
376        let parser = CascadeResponseParser::new();
377        let response = json!({
378            "createPost": {
379                "post": { "id": "post-1", "title": "Hello" }
380            }
381        });
382
383        let entities = parser.parse_cascade_response(&response).unwrap();
384        assert!(!entities.has_changes());
385    }
386
387    #[test]
388    fn test_parse_nested_in_data_field() {
389        let parser = CascadeResponseParser::new();
390        let response = json!({
391            "data": {
392                "createPost": {
393                    "cascade": {
394                        "updated": [{ "__typename": "User", "id": "uuid-1" }]
395                    }
396                }
397            }
398        });
399
400        let entities = parser.parse_cascade_response(&response).unwrap();
401        assert_eq!(entities.updated.len(), 1);
402    }
403
404    #[test]
405    fn test_parse_missing_typename() {
406        let parser = CascadeResponseParser::new();
407        let response = json!({
408            "mutation": {
409                "cascade": {
410                    "updated": [{ "id": "uuid-1" }]
411                }
412            }
413        });
414
415        let result = parser.parse_cascade_response(&response);
416        assert!(result.is_err());
417    }
418
419    #[test]
420    fn test_parse_missing_id() {
421        let parser = CascadeResponseParser::new();
422        let response = json!({
423            "mutation": {
424                "cascade": {
425                    "updated": [{ "__typename": "User" }]
426                }
427            }
428        });
429
430        let result = parser.parse_cascade_response(&response);
431        assert!(result.is_err());
432    }
433
434    #[test]
435    fn test_cascade_entities_all_affected() {
436        let updated = vec![
437            EntityKey::new("User", "u-1").unwrap(),
438            EntityKey::new("User", "u-2").unwrap(),
439        ];
440        let deleted = vec![EntityKey::new("Post", "p-1").unwrap()];
441
442        let cascade = CascadeEntities::new(updated, deleted);
443        let all = cascade.all_affected();
444        assert_eq!(all.len(), 3);
445    }
446}