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}