Skip to main content

rust_ef/
interceptor.rs

1//! SaveChanges interceptor �?hooks into the DbContext lifecycle.
2//!
3//! Provides an interception pipeline analogous to EFCore's
4//! `ISaveChangesInterceptor`: before/after save, and on failure.
5//! Interceptors are registered via `DbContextOptionsBuilder::add_interceptor()`.
6//!
7//! # Example (user code)
8//!
9//! ```rust,ignore
10//! use rust_ef::interceptor::{ISaveChangesInterceptor, SaveChangesContext};
11//!
12//! struct AuditInterceptor;
13//!
14//! #[async_trait::async_trait]
15//! impl ISaveChangesInterceptor for AuditInterceptor {
16//!     async fn on_saving(&self, ctx: &SaveChangesContext) -> EFResult<()> {
17//!         println!("Saving {} entries", ctx.entries().len());
18//!         Ok(())
19//!     }
20//! }
21//! ```
22
23use crate::error::{EFError, EFResult};
24use crate::tracking::EntityEntryView;
25use std::sync::Arc;
26
27// ---------------------------------------------------------------------------
28// SaveChangesContext �?context passed to interceptor hooks
29// ---------------------------------------------------------------------------
30
31/// Read-only snapshot of the save operation, passed to interceptors.
32///
33/// Built from the actual pending entries across all `DbSet`s (the real save
34/// data source), so interceptors see a snapshot consistent with what
35/// `save_changes()` will commit. This is an owned snapshot so interceptors
36/// cannot mutate the pending changes, and the borrow does not block
37/// subsequent mutating operations on `DbContext`.
38#[derive(Debug, Clone)]
39pub struct SaveChangesContext {
40    /// All pending entries before the save.
41    entries: Vec<EntityEntryView>,
42    /// Number of entries that will be added.
43    added_count: usize,
44    /// Number of entries that will be modified.
45    modified_count: usize,
46    /// Number of entries that will be deleted.
47    deleted_count: usize,
48}
49
50impl SaveChangesContext {
51    /// Creates a context from the pending entries aggregated across all
52    /// `DbSet`s. Counts are derived from each entry's `state`.
53    pub fn from_views(entries: Vec<EntityEntryView>) -> Self {
54        let added_count = entries
55            .iter()
56            .filter(|e| e.state == crate::entity::EntityState::Added)
57            .count();
58        let modified_count = entries
59            .iter()
60            .filter(|e| e.state == crate::entity::EntityState::Modified)
61            .count();
62        let deleted_count = entries
63            .iter()
64            .filter(|e| e.state == crate::entity::EntityState::Deleted)
65            .count();
66
67        Self {
68            entries,
69            added_count,
70            modified_count,
71            deleted_count,
72        }
73    }
74
75    /// Returns all pending entity entries at the time of interception.
76    pub fn entries(&self) -> &[EntityEntryView] {
77        &self.entries
78    }
79
80    /// Number of entities that will be / were added.
81    pub fn added_count(&self) -> usize {
82        self.added_count
83    }
84
85    /// Number of entities that will be / were modified.
86    pub fn modified_count(&self) -> usize {
87        self.modified_count
88    }
89
90    /// Number of entities that will be / were deleted.
91    pub fn deleted_count(&self) -> usize {
92        self.deleted_count
93    }
94
95    /// Total number of tracked entries.
96    pub fn total_count(&self) -> usize {
97        self.entries.len()
98    }
99}
100
101// ---------------------------------------------------------------------------
102// SaveChangesResultContext �?post-save context
103// ---------------------------------------------------------------------------
104
105/// Context passed to interceptors after a successful save.
106#[derive(Debug, Clone)]
107pub struct SaveChangesResultContext {
108    /// Summary of the save operation.
109    pub added: usize,
110    pub updated: usize,
111    pub deleted: usize,
112}
113
114impl SaveChangesResultContext {
115    pub fn total(&self) -> usize {
116        self.added + self.updated + self.deleted
117    }
118}
119
120// ---------------------------------------------------------------------------
121// ISaveChangesInterceptor �?the interceptor trait
122// ---------------------------------------------------------------------------
123
124/// Interface for intercepting `save_changes()` operations.
125///
126/// All methods have default no-op implementations so users only
127/// override what they need.
128///
129/// # Lifecycle
130///
131/// ```text
132/// on_saving(ctx) �?[execute SQL] �?on_saved(ctx, result)
133///                                    on_save_failed(ctx, error)  (if error)
134/// ```
135#[async_trait::async_trait]
136pub trait ISaveChangesInterceptor: Send + Sync {
137    /// Called **before** SQL commands are executed.
138    ///
139    /// Return `Err(...)` to abort the save. The transaction will be
140    /// rolled back and `on_save_failed` will NOT be called (this is
141    /// a pre-commit rejection, not a database failure).
142    async fn on_saving(&self, _ctx: &SaveChangesContext) -> EFResult<()> {
143        Ok(())
144    }
145
146    /// Called **after** a successful save (transaction committed).
147    async fn on_saved(
148        &self,
149        _ctx: &SaveChangesContext,
150        _result: &SaveChangesResultContext,
151    ) -> EFResult<()> {
152        Ok(())
153    }
154
155    /// Called when the save **fails** with a database error.
156    ///
157    /// The transaction has already been rolled back at this point.
158    /// The error is passed by reference for inspection; the interceptor
159    /// cannot suppress it.
160    async fn on_save_failed(&self, _ctx: &SaveChangesContext, _error: &EFError) {
161        // default: no-op
162    }
163}
164
165// ---------------------------------------------------------------------------
166// InterceptorPipeline �?ordered chain of interceptors
167// ---------------------------------------------------------------------------
168
169/// An ordered, immutable chain of save-changes interceptors.
170///
171/// Created by `DbContextOptionsBuilder` and invoked by `DbContext::save_changes()`.
172pub(crate) struct InterceptorPipeline {
173    interceptors: Vec<Arc<dyn ISaveChangesInterceptor>>,
174}
175
176impl InterceptorPipeline {
177    pub fn new(interceptors: Vec<Arc<dyn ISaveChangesInterceptor>>) -> Self {
178        Self { interceptors }
179    }
180
181    /// Runs all `on_saving` hooks in registration order.
182    /// Aborts early if any interceptor returns an error.
183    pub async fn on_saving(&self, ctx: &SaveChangesContext) -> EFResult<()> {
184        for interceptor in &self.interceptors {
185            interceptor.on_saving(ctx).await?;
186        }
187        Ok(())
188    }
189
190    /// Runs all `on_saved` hooks in registration order.
191    pub async fn on_saved(
192        &self,
193        ctx: &SaveChangesContext,
194        result: &SaveChangesResultContext,
195    ) -> EFResult<()> {
196        for interceptor in &self.interceptors {
197            interceptor.on_saved(ctx, result).await?;
198        }
199        Ok(())
200    }
201
202    /// Runs all `on_save_failed` hooks. Errors from these hooks are
203    /// intentionally swallowed �?they must not mask the original error.
204    pub async fn on_save_failed(&self, ctx: &SaveChangesContext, error: &EFError) {
205        for interceptor in &self.interceptors {
206            interceptor.on_save_failed(ctx, error).await;
207        }
208    }
209}