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