Skip to main content

fraiseql_core/cache/
invalidation.rs

1//! Cache invalidation context and API.
2//!
3//! Provides structured invalidation contexts for different scenarios:
4//! - Mutation-triggered invalidation
5//! - Manual/administrative invalidation
6//! - Schema change invalidation
7//!
8//! # Current Scope
9//!
10//! - View-based invalidation (not entity-level)
11//! - Simple context types
12//! - Structured logging support
13//!
14//! # Future Enhancements
15//!
16//! - Entity-level invalidation with cascade metadata
17//! - Selective invalidation (by ID)
18//! - Invalidation batching
19
20/// Reason for cache invalidation.
21///
22/// Used for structured logging and debugging to understand why cache entries
23/// were invalidated.
24#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub enum InvalidationReason {
27    /// Invalidation triggered by a GraphQL mutation.
28    ///
29    /// Contains the mutation name that modified the data.
30    Mutation {
31        /// Name of the mutation (e.g., "createUser", "updatePost")
32        mutation_name: String,
33    },
34
35    /// Manual invalidation by administrator or system.
36    ///
37    /// Contains a custom reason string for audit logging.
38    Manual {
39        /// Human-readable reason (e.g., "maintenance", "data import")
40        reason: String,
41    },
42
43    /// Schema change requiring cache flush.
44    ///
45    /// Triggered when the schema version changes (e.g., after deployment).
46    SchemaChange {
47        /// Old schema version
48        old_version: String,
49        /// New schema version
50        new_version: String,
51    },
52}
53
54impl InvalidationReason {
55    /// Format reason as a log-friendly string.
56    ///
57    /// # Example
58    ///
59    /// ```rust
60    /// use fraiseql_core::cache::InvalidationReason;
61    ///
62    /// let reason = InvalidationReason::Mutation {
63    ///     mutation_name: "createUser".to_string()
64    /// };
65    ///
66    /// assert_eq!(
67    ///     reason.to_log_string(),
68    ///     "mutation:createUser"
69    /// );
70    /// ```
71    #[must_use]
72    pub fn to_log_string(&self) -> String {
73        match self {
74            Self::Mutation { mutation_name } => format!("mutation:{mutation_name}"),
75            Self::Manual { reason } => format!("manual:{reason}"),
76            Self::SchemaChange {
77                old_version,
78                new_version,
79            } => {
80                format!("schema_change:{old_version}->{new_version}")
81            },
82        }
83    }
84}
85
86/// Context for cache invalidation operations.
87///
88/// Encapsulates which views/tables were modified and why, providing
89/// structured information for logging and debugging.
90///
91/// # Example
92///
93/// ```rust
94/// use fraiseql_core::cache::InvalidationContext;
95///
96/// // Invalidate after mutation
97/// let ctx = InvalidationContext::for_mutation(
98///     "createUser",
99///     vec!["v_user".to_string()]
100/// );
101///
102/// // Invalidate manually
103/// let ctx = InvalidationContext::manual(
104///     vec!["v_user".to_string(), "v_post".to_string()],
105///     "data import completed"
106/// );
107///
108/// // Invalidate on schema change
109/// let ctx = InvalidationContext::schema_change(
110///     vec!["v_user".to_string()],
111///     "1.0.0",
112///     "1.1.0"
113/// );
114/// ```
115#[derive(Debug, Clone)]
116pub struct InvalidationContext {
117    /// List of views/tables that were modified.
118    ///
119    /// All cache entries accessing these views will be invalidated.
120    pub modified_views: Vec<String>,
121
122    /// Reason for invalidation.
123    ///
124    /// Used for structured logging and debugging.
125    pub reason: InvalidationReason,
126}
127
128impl InvalidationContext {
129    /// Create invalidation context for a mutation.
130    ///
131    /// Used by mutation handlers to invalidate cache entries after
132    /// modifying data.
133    ///
134    /// # Arguments
135    ///
136    /// * `mutation_name` - Name of the mutation (e.g., "createUser")
137    /// * `modified_views` - List of views/tables modified by the mutation
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use fraiseql_core::cache::InvalidationContext;
143    ///
144    /// let ctx = InvalidationContext::for_mutation(
145    ///     "createUser",
146    ///     vec!["v_user".to_string()]
147    /// );
148    ///
149    /// assert_eq!(ctx.modified_views, vec!["v_user"]);
150    /// ```
151    #[must_use]
152    pub fn for_mutation(mutation_name: &str, modified_views: Vec<String>) -> Self {
153        Self {
154            modified_views,
155            reason: InvalidationReason::Mutation {
156                mutation_name: mutation_name.to_string(),
157            },
158        }
159    }
160
161    /// Create invalidation context for manual invalidation.
162    ///
163    /// Used by administrators or background jobs to manually invalidate
164    /// cache entries (e.g., after data import, during maintenance).
165    ///
166    /// # Arguments
167    ///
168    /// * `modified_views` - List of views/tables to invalidate
169    /// * `reason` - Human-readable reason for audit logging
170    ///
171    /// # Example
172    ///
173    /// ```rust
174    /// use fraiseql_core::cache::InvalidationContext;
175    ///
176    /// let ctx = InvalidationContext::manual(
177    ///     vec!["v_user".to_string(), "v_post".to_string()],
178    ///     "maintenance: rebuilding indexes"
179    /// );
180    ///
181    /// assert_eq!(ctx.modified_views.len(), 2);
182    /// ```
183    #[must_use]
184    pub fn manual(modified_views: Vec<String>, reason: &str) -> Self {
185        Self {
186            modified_views,
187            reason: InvalidationReason::Manual {
188                reason: reason.to_string(),
189            },
190        }
191    }
192
193    /// Create invalidation context for schema change.
194    ///
195    /// Used during deployments when the schema version changes to flush
196    /// all cached entries that depend on the old schema structure.
197    ///
198    /// # Arguments
199    ///
200    /// * `affected_views` - All views in the schema (typically all views)
201    /// * `old_version` - Previous schema version
202    /// * `new_version` - New schema version
203    ///
204    /// # Example
205    ///
206    /// ```rust
207    /// use fraiseql_core::cache::InvalidationContext;
208    ///
209    /// let ctx = InvalidationContext::schema_change(
210    ///     vec!["v_user".to_string(), "v_post".to_string()],
211    ///     "1.0.0",
212    ///     "1.1.0"
213    /// );
214    ///
215    /// assert_eq!(ctx.modified_views.len(), 2);
216    /// ```
217    #[must_use]
218    pub fn schema_change(
219        affected_views: Vec<String>,
220        old_version: &str,
221        new_version: &str,
222    ) -> Self {
223        Self {
224            modified_views: affected_views,
225            reason:         InvalidationReason::SchemaChange {
226                old_version: old_version.to_string(),
227                new_version: new_version.to_string(),
228            },
229        }
230    }
231
232    /// Get a log-friendly description of this invalidation.
233    ///
234    /// # Example
235    ///
236    /// ```rust
237    /// use fraiseql_core::cache::InvalidationContext;
238    ///
239    /// let ctx = InvalidationContext::for_mutation(
240    ///     "createUser",
241    ///     vec!["v_user".to_string()]
242    /// );
243    ///
244    /// assert_eq!(
245    ///     ctx.to_log_string(),
246    ///     "mutation:createUser affecting 1 view(s)"
247    /// );
248    /// ```
249    #[must_use]
250    pub fn to_log_string(&self) -> String {
251        format!(
252            "{} affecting {} view(s)",
253            self.reason.to_log_string(),
254            self.modified_views.len()
255        )
256    }
257
258    /// Get the number of views affected by this invalidation.
259    ///
260    /// # Example
261    ///
262    /// ```rust
263    /// use fraiseql_core::cache::InvalidationContext;
264    ///
265    /// let ctx = InvalidationContext::manual(
266    ///     vec!["v_user".to_string(), "v_post".to_string()],
267    ///     "maintenance"
268    /// );
269    ///
270    /// assert_eq!(ctx.view_count(), 2);
271    /// ```
272    #[must_use]
273    pub const fn view_count(&self) -> usize {
274        self.modified_views.len()
275    }
276
277    /// Check if this invalidation affects a specific view.
278    ///
279    /// # Arguments
280    ///
281    /// * `view` - View name to check
282    ///
283    /// # Example
284    ///
285    /// ```rust
286    /// use fraiseql_core::cache::InvalidationContext;
287    ///
288    /// let ctx = InvalidationContext::for_mutation(
289    ///     "createUser",
290    ///     vec!["v_user".to_string(), "v_post".to_string()]
291    /// );
292    ///
293    /// assert!(ctx.affects_view("v_user"));
294    /// assert!(ctx.affects_view("v_post"));
295    /// assert!(!ctx.affects_view("v_comment"));
296    /// ```
297    #[must_use]
298    pub fn affects_view(&self, view: &str) -> bool {
299        self.modified_views.iter().any(|v| v == view)
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_for_mutation() {
309        let ctx = InvalidationContext::for_mutation("createUser", vec!["v_user".to_string()]);
310
311        assert_eq!(ctx.modified_views, vec!["v_user"]);
312        assert!(matches!(ctx.reason, InvalidationReason::Mutation { .. }));
313    }
314
315    #[test]
316    fn test_manual() {
317        let ctx = InvalidationContext::manual(
318            vec!["v_user".to_string(), "v_post".to_string()],
319            "maintenance",
320        );
321
322        assert_eq!(ctx.modified_views.len(), 2);
323        assert!(matches!(ctx.reason, InvalidationReason::Manual { .. }));
324    }
325
326    #[test]
327    fn test_schema_change() {
328        let ctx = InvalidationContext::schema_change(vec!["v_user".to_string()], "1.0.0", "1.1.0");
329
330        assert_eq!(ctx.modified_views, vec!["v_user"]);
331        assert!(matches!(ctx.reason, InvalidationReason::SchemaChange { .. }));
332    }
333
334    #[test]
335    fn test_mutation_log_string() {
336        let ctx = InvalidationContext::for_mutation("createUser", vec!["v_user".to_string()]);
337
338        assert_eq!(ctx.to_log_string(), "mutation:createUser affecting 1 view(s)");
339    }
340
341    #[test]
342    fn test_manual_log_string() {
343        let ctx = InvalidationContext::manual(vec!["v_user".to_string()], "data import");
344
345        assert_eq!(ctx.to_log_string(), "manual:data import affecting 1 view(s)");
346    }
347
348    #[test]
349    fn test_schema_change_log_string() {
350        let ctx = InvalidationContext::schema_change(vec!["v_user".to_string()], "1.0.0", "1.1.0");
351
352        assert_eq!(ctx.to_log_string(), "schema_change:1.0.0->1.1.0 affecting 1 view(s)");
353    }
354
355    #[test]
356    fn test_view_count() {
357        let ctx = InvalidationContext::for_mutation(
358            "createUser",
359            vec!["v_user".to_string(), "v_post".to_string()],
360        );
361
362        assert_eq!(ctx.view_count(), 2);
363    }
364
365    #[test]
366    fn test_affects_view() {
367        let ctx = InvalidationContext::for_mutation(
368            "createUser",
369            vec!["v_user".to_string(), "v_post".to_string()],
370        );
371
372        assert!(ctx.affects_view("v_user"));
373        assert!(ctx.affects_view("v_post"));
374        assert!(!ctx.affects_view("v_comment"));
375    }
376
377    #[test]
378    fn test_empty_views() {
379        let ctx = InvalidationContext::manual(vec![], "testing empty invalidation");
380
381        assert_eq!(ctx.view_count(), 0);
382        assert!(!ctx.affects_view("v_user"));
383    }
384
385    #[test]
386    fn test_reason_to_log_string_mutation() {
387        let reason = InvalidationReason::Mutation {
388            mutation_name: "updatePost".to_string(),
389        };
390
391        assert_eq!(reason.to_log_string(), "mutation:updatePost");
392    }
393
394    #[test]
395    fn test_reason_to_log_string_manual() {
396        let reason = InvalidationReason::Manual {
397            reason: "cache warmup".to_string(),
398        };
399
400        assert_eq!(reason.to_log_string(), "manual:cache warmup");
401    }
402
403    #[test]
404    fn test_reason_to_log_string_schema_change() {
405        let reason = InvalidationReason::SchemaChange {
406            old_version: "2.0.0".to_string(),
407            new_version: "2.1.0".to_string(),
408        };
409
410        assert_eq!(reason.to_log_string(), "schema_change:2.0.0->2.1.0");
411    }
412
413    #[test]
414    fn test_multiple_views() {
415        let views = vec![
416            "v_user".to_string(),
417            "v_post".to_string(),
418            "v_comment".to_string(),
419            "v_like".to_string(),
420        ];
421
422        let ctx = InvalidationContext::for_mutation("deleteUser", views);
423
424        assert_eq!(ctx.view_count(), 4);
425        assert!(ctx.affects_view("v_user"));
426        assert!(ctx.affects_view("v_post"));
427        assert!(ctx.affects_view("v_comment"));
428        assert!(ctx.affects_view("v_like"));
429        assert!(!ctx.affects_view("v_notification"));
430    }
431}