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///
20/// A save that also writes a [`Relation::has_one`](crate::Relation::has_one)'s child reports the
21/// owner here, and the child to the related resource's hooks, as its own pages would.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Change<M> {
24 /// A create wrote this row.
25 Create(M),
26 /// An update wrote `after` over `before`, the snapshot the handler loaded and authorized
27 /// inside the transaction. A submission that names no field writes nothing and still
28 /// reports an update, with `before == after`.
29 Update { before: M, after: M },
30 /// A delete or a bulk delete removed these rows, or trashed them on a soft-deleting resource,
31 /// as they stood before the delete.
32 Delete(Vec<M>),
33 /// A restore brought back these trashed rows, as they stood before it: their deleted-at
34 /// column still holds the time they were trashed.
35 Restore(Vec<M>),
36 /// A force delete removed these trashed rows for good, as they stood before it.
37 ForceDelete(Vec<M>),
38 /// The custom [`Action`] `name` ran on these rows, as the handler loaded them before it ran.
39 Action { name: &'static str, records: Vec<M> },
40 /// The [`HeaderAction`](crate::HeaderAction) `name` ran, on no record: its audit can record
41 /// who ran it, through the context, and which action.
42 HeaderAction { name: &'static str },
43 /// A many-to-many relation's table linked `owner` to the related record `key` names.
44 ///
45 /// `relation` is the owner's `#[has_many(via = ..)]` field and `key` the related resource's
46 /// record key, as its URLs spell it whatever spelling the request posted. Attaching a linked
47 /// record writes nothing and still reports the attach.
48 Attach {
49 owner: M,
50 relation: String,
51 key: String,
52 },
53 /// A many-to-many relation's table unlinked `owner` from the related records `keys` name, as
54 /// their URLs spell them.
55 Detach {
56 owner: M,
57 relation: String,
58 keys: Vec<String>,
59 },
60}
61
62impl<M> Change<M> {
63 /// The rows of this resource the change names: the created row and the updated row as the
64 /// write left them, the deleted, restored or force-deleted rows and the rows an action ran on
65 /// as they stood before it, the owner of an attach or a detach, and none for a header action.
66 pub fn records(&self) -> &[M] {
67 match self {
68 Self::Create(record)
69 | Self::Update { after: record, .. }
70 | Self::Attach { owner: record, .. }
71 | Self::Detach { owner: record, .. } => std::slice::from_ref(record),
72 Self::Delete(records)
73 | Self::Restore(records)
74 | Self::ForceDelete(records)
75 | Self::Action { records, .. } => records,
76 Self::HeaderAction { .. } => &[],
77 }
78 }
79
80 /// The same change, each record read through `f`.
81 pub(crate) fn map<N>(&self, f: impl Fn(&M) -> N) -> Change<N> {
82 match self {
83 Self::Create(record) => Change::Create(f(record)),
84 Self::Update { before, after } => Change::Update {
85 before: f(before),
86 after: f(after),
87 },
88 Self::Delete(records) => Change::Delete(records.iter().map(f).collect()),
89 Self::Restore(records) => Change::Restore(records.iter().map(f).collect()),
90 Self::ForceDelete(records) => Change::ForceDelete(records.iter().map(f).collect()),
91 Self::Action { name, records } => Change::Action {
92 name,
93 records: records.iter().map(f).collect(),
94 },
95 Self::HeaderAction { name } => Change::HeaderAction { name },
96 Self::Attach {
97 owner,
98 relation,
99 key,
100 } => Change::Attach {
101 owner: f(owner),
102 relation: relation.clone(),
103 key: key.clone(),
104 },
105 Self::Detach {
106 owner,
107 relation,
108 keys,
109 } => Change::Detach {
110 owner: f(owner),
111 relation: relation.clone(),
112 keys: keys.clone(),
113 },
114 }
115 }
116
117 /// Whether the change is a run of the custom action `A`.
118 ///
119 /// It compares against `A::NAME`, as [`Ability::is_action`](crate::Ability::is_action) does,
120 /// and a type that is not an action of a resource over `M` does not compile. `R` is inferred
121 /// when `A` is an action of one resource.
122 ///
123 /// ```rust
124 /// # use tablo_core::{Action, Change, NoForm, Resource};
125 /// # use topcoat::{Result, context::Cx};
126 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
127 /// # struct Post { #[key] #[auto] id: uuid::Uuid, published: bool }
128 /// # struct PostResource;
129 /// # impl Resource for PostResource {
130 /// # type Model = Post;
131 /// # type Form = NoForm<Post>;
132 /// # }
133 /// # struct Publish;
134 /// # impl Action<PostResource> for Publish {
135 /// # type Input = ();
136 /// # const NAME: &'static str = "publish";
137 /// # async fn run(_: &Cx, _: &[Post], _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError<std::convert::Infallible>> { Ok(tablo_core::Outcome::done()) }
138 /// # }
139 /// fn published(change: &Change<Post>) -> usize {
140 /// if change.is_action::<Publish, _>() {
141 /// change.records().len()
142 /// } else {
143 /// 0
144 /// }
145 /// }
146 /// ```
147 pub fn is_action<A, R>(&self) -> bool
148 where
149 A: Action<R>,
150 R: Resource<Model = M>,
151 {
152 matches!(self, Self::Action { name, .. } if *name == A::NAME)
153 }
154
155 /// Whether the change is a run of the header action `A`.
156 ///
157 /// It compares against `A::NAME`, as
158 /// [`Ability::is_header_action`](crate::Ability::is_header_action) does.
159 ///
160 /// ```rust
161 /// # use tablo_core::{Change, HeaderAction};
162 /// # use topcoat::{Result, context::Cx};
163 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
164 /// # struct Post { #[key] #[auto] id: uuid::Uuid }
165 /// # struct RecountTags;
166 /// # impl HeaderAction for RecountTags {
167 /// # type Input = ();
168 /// # const NAME: &'static str = "recount-tags";
169 /// # async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError<std::convert::Infallible>> { Ok(tablo_core::Outcome::done()) }
170 /// # }
171 /// fn audited(change: &Change<Post>) -> bool {
172 /// !change.is_header_action::<RecountTags>()
173 /// }
174 /// ```
175 pub fn is_header_action<A: HeaderAction>(&self) -> bool {
176 matches!(self, Self::HeaderAction { name } if *name == A::NAME)
177 }
178}
179
180/// The change a run of the custom action `A` made to `records`.
181pub(crate) fn acted<R: Resource, A: Action<R>>(records: Vec<R::Model>) -> Change<R::Model> {
182 Change::Action {
183 name: A::NAME,
184 records,
185 }
186}
187
188/// Hand a committed change to the app.
189///
190/// A hook returning `Err` is logged and ignored: the write stays committed.
191pub(crate) async fn run_after_commit<R: Resource>(cx: &Cx, change: Change<R::Model>) {
192 if let Err(error) = R::after_commit(cx, change).await {
193 tracing::error!(
194 error = %error,
195 resource = std::any::type_name::<R>(),
196 "after_commit failed; the write stays committed"
197 );
198 }
199}