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