Skip to main content

tablo_core/resource/
commit.rs

1//! What a write changed, and the one place the framework hands it to the app's hooks.
2
3use topcoat::context::Cx;
4
5use super::{Action, HeaderAction, Resource};
6
7/// What one write of a resource changed: the mutation and the rows it wrote.
8///
9/// [`Resource::before_commit`] receives it inside the write's transaction and
10/// [`Resource::after_commit`] once the transaction commits.
11///
12/// The enum is exhaustive on purpose: an audit hook that matches every variant stops compiling
13/// when a new kind of write appears, instead of skipping it.
14///
15/// A create or an update that also links or unlinks records through a many-to-many form field
16/// reports the record only: the links are not in `M`. Read them through the hook's executor when
17/// an audit needs them; the relation table's own writes report as [`Change::Attach`] and
18/// [`Change::Detach`].
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Change<M> {
21    /// A create wrote this row.
22    Create(M),
23    /// An update wrote `after` over `before`, the snapshot the handler loaded and authorized
24    /// inside the transaction. A submission that names no field writes nothing and still
25    /// reports an update, with `before == after`.
26    Update { before: M, after: M },
27    /// A delete or a bulk delete removed these rows, or trashed them on a soft-deleting resource,
28    /// as they stood before the delete.
29    Delete(Vec<M>),
30    /// A restore brought back these trashed rows, as they stood before it: their deleted-at
31    /// column still holds the time they were trashed.
32    Restore(Vec<M>),
33    /// A force delete removed these trashed rows for good, as they stood before it.
34    ForceDelete(Vec<M>),
35    /// The custom [`Action`] `name` ran on these rows, as the handler loaded them before it ran.
36    Action { name: &'static str, records: Vec<M> },
37    /// The [`HeaderAction`](crate::HeaderAction) `name` ran, on no record: its audit can record
38    /// who ran it, through the context, and which action.
39    HeaderAction { name: &'static str },
40    /// A many-to-many relation's table linked `owner` to the related record `key` names.
41    ///
42    /// `relation` is the owner's `#[has_many(via = ..)]` field and `key` the related resource's
43    /// record key, as its URLs spell it whatever spelling the request posted. Attaching a linked
44    /// record writes nothing and still reports the attach.
45    Attach {
46        owner: M,
47        relation: String,
48        key: String,
49    },
50    /// A many-to-many relation's table unlinked `owner` from the related records `keys` name, as
51    /// their URLs spell them.
52    Detach {
53        owner: M,
54        relation: String,
55        keys: Vec<String>,
56    },
57}
58
59impl<M> Change<M> {
60    /// The rows of this resource the change names: the created row and the updated row as the
61    /// write left them, the deleted, restored or force-deleted rows and the rows an action ran on
62    /// as they stood before it, the owner of an attach or a detach, and none for a header action.
63    pub fn records(&self) -> &[M] {
64        match self {
65            Self::Create(record)
66            | Self::Update { after: record, .. }
67            | Self::Attach { owner: record, .. }
68            | Self::Detach { owner: record, .. } => std::slice::from_ref(record),
69            Self::Delete(records)
70            | Self::Restore(records)
71            | Self::ForceDelete(records)
72            | Self::Action { records, .. } => records,
73            Self::HeaderAction { .. } => &[],
74        }
75    }
76
77    /// Whether the change is a run of the custom action `A`.
78    ///
79    /// It compares against `A::NAME`, as [`Ability::is_action`](crate::Ability::is_action) does,
80    /// and a type that is not an action of a resource over `M` does not compile. `R` is inferred
81    /// when `A` is an action of one resource.
82    ///
83    /// ```rust
84    /// # use tablo_core::{Action, Change, NoForm, Resource};
85    /// # use topcoat::{Result, context::Cx};
86    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
87    /// # struct Post { #[key] #[auto] id: uuid::Uuid, published: bool }
88    /// # struct PostResource;
89    /// # impl Resource for PostResource {
90    /// #     type Model = Post;
91    /// #     type Form = NoForm<Post>;
92    /// # }
93    /// # struct Publish;
94    /// # impl Action<PostResource> for Publish {
95    /// #     type Input = ();
96    /// #     const NAME: &'static str = "publish";
97    /// #     async fn run(_: &Cx, _: &[Post], _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError> { Ok(tablo_core::Outcome::done()) }
98    /// # }
99    /// fn published(change: &Change<Post>) -> usize {
100    ///     if change.is_action::<Publish, _>() {
101    ///         change.records().len()
102    ///     } else {
103    ///         0
104    ///     }
105    /// }
106    /// ```
107    pub fn is_action<A, R>(&self) -> bool
108    where
109        A: Action<R>,
110        R: Resource<Model = M>,
111    {
112        matches!(self, Self::Action { name, .. } if *name == A::NAME)
113    }
114
115    /// Whether the change is a run of the header action `A`.
116    ///
117    /// It compares against `A::NAME`, as
118    /// [`Ability::is_header_action`](crate::Ability::is_header_action) does.
119    ///
120    /// ```rust
121    /// # use tablo_core::{Change, HeaderAction};
122    /// # use topcoat::{Result, context::Cx};
123    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
124    /// # struct Post { #[key] #[auto] id: uuid::Uuid }
125    /// # struct RecountTags;
126    /// # impl HeaderAction for RecountTags {
127    /// #     type Input = ();
128    /// #     const NAME: &'static str = "recount-tags";
129    /// #     async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError> { Ok(tablo_core::Outcome::done()) }
130    /// # }
131    /// fn audited(change: &Change<Post>) -> bool {
132    ///     !change.is_header_action::<RecountTags>()
133    /// }
134    /// ```
135    pub fn is_header_action<A: HeaderAction>(&self) -> bool {
136        matches!(self, Self::HeaderAction { name } if *name == A::NAME)
137    }
138}
139
140/// The change a run of the custom action `A` made to `records`.
141pub(crate) fn acted<R: Resource, A: Action<R>>(records: Vec<R::Model>) -> Change<R::Model> {
142    Change::Action {
143        name: A::NAME,
144        records,
145    }
146}
147
148/// Hand a committed change to the app.
149///
150/// A hook returning `Err` is logged and ignored: the write stays committed.
151pub(crate) async fn run_after_commit<R: Resource>(cx: &Cx, change: Change<R::Model>) {
152    if let Err(error) = R::after_commit(cx, change).await {
153        tracing::error!(
154            error = %error,
155            resource = std::any::type_name::<R>(),
156            "after_commit failed; the write stays committed"
157        );
158    }
159}