tablo_core/resource.rs
1//! `Resource` — maps one Toasty [`Model`](toasty::schema::Model) to its admin UI.
2
3use toasty::{
4 Executor,
5 stmt::{List, Query},
6};
7use topcoat::{Result, context::Cx};
8
9use crate::form::{FieldErrors, Posted, RecordForm};
10
11mod action;
12mod commit;
13mod def;
14mod header_action;
15mod link;
16mod mounted;
17mod relation;
18mod write;
19
20#[doc(hidden)]
21pub use action::required_input;
22pub use action::{Action, ActionInput, Places};
23pub(crate) use action::{
24 ActionEntry, ActionFuture, Actions, ErasedInput, InputResult, InputSpec, RESERVED_KEYS,
25 parse_validated,
26};
27pub(crate) use commit::run_after_commit;
28pub use commit::{Committed, Mutation};
29pub use def::ResourceDef;
30pub(crate) use header_action::HeaderEntry;
31pub use header_action::{HeaderAction, HeaderActions};
32pub use link::PublicLink;
33pub(crate) use mounted::{MountScope, Mounted, Mounts, mounted, require_mounted};
34pub use relation::{ForeignKey, Relation};
35pub(crate) use relation::{Links, RelationKind};
36pub use write::{write_create, write_update};
37
38/// Maps one Toasty `Model` to its admin UI.
39///
40/// # Contract
41///
42/// Requires [`Model`](Self::Model) and [`Form`](Self::Form); every other item has a default.
43/// [`declare`](Self::declare) returns what the resource declares, as one [`ResourceDef`] value;
44/// the methods scope, validate and write records for a request.
45///
46/// The panel builds the def once when it mounts, binding the paths it names to the database
47/// schema, and serves the result to every request. Record fns run inside the handler transaction
48/// and fail without partial writes.
49pub trait Resource: Sized + Send + Sync + 'static {
50 /// The persisted model this resource administers.
51 type Model: toasty::schema::Model
52 + toasty::stmt::IntoExpr<Self::Model>
53 + Send
54 + Sync
55 + Clone
56 + 'static;
57
58 /// The typed value the create and edit forms parse into.
59 ///
60 /// A resource with create and edit pages names its
61 /// [`#[derive(RecordForm)]`](crate::RecordForm) struct; a list-only resource
62 /// names [`NoForm<Self::Model>`](crate::NoForm), and
63 /// [`Panel::resource`](crate::Panel::resource) registers no form route for
64 /// it ([`RecordForm::HAS_FORM`]).
65 type Form: RecordForm<Model = Self::Model>;
66
67 /// What the resource declares: names, navigation, policy, tenancy, table, form, detail page,
68 /// relations and actions. Defaults to [`ResourceDef::new`], whose policy denies all.
69 ///
70 /// ```rust
71 /// # #[derive(Debug, Clone, toasty::Model)]
72 /// # struct Post {
73 /// # #[key] #[auto] id: uuid::Uuid,
74 /// # title: String,
75 /// # body: String,
76 /// # }
77 /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
78 /// # #[form(model = Post)]
79 /// # struct PostForm { title: String, body: String }
80 /// # struct PostResource;
81 /// # use tablo_core::{ReadOnly, RecordForm, Resource, ResourceDef, Schema, Section};
82 /// impl Resource for PostResource {
83 /// type Model = Post;
84 /// type Form = PostForm;
85 ///
86 /// fn declare() -> ResourceDef<Self> {
87 /// let c = PostForm::controls();
88 /// ResourceDef::new().policy(ReadOnly).form(Schema::new(
89 /// Section::new("Content").schema((c.title, c.body.multiline(6))),
90 /// ))
91 /// }
92 /// }
93 /// ```
94 fn declare() -> ResourceDef<Self> {
95 ResourceDef::new()
96 }
97
98 /// Base query — the seam for a resource's **own** row scoping (ADR-0002):
99 /// soft deletes and row-level visibility. Every loader starts from it.
100 ///
101 /// **Relations are not this method's job.** The list, the export and the detail page load
102 /// the relations their columns declare ([`Column::includes`](crate::extend::Column::includes)):
103 /// a [`RelationColumn`](crate::RelationColumn) declares its own. Include a relation here
104 /// only when a closure no column covers reads it on every loader's rows: one the
105 /// [`policy`](ResourceDef::policy) reads, or one a table's `group_by` or row key reads without
106 /// a column including it.
107 ///
108 /// **Tenancy is not this method's job either.** For a resource whose
109 /// [`tenancy`](ResourceDef::tenancy) is scoped the framework ANDs the tenant filter
110 /// onto whatever this returns, at every loader through [`scoped_query`]. Do
111 /// not re-state it here. Code outside the framework's loaders starts from
112 /// [`scoped_query`].
113 ///
114 /// Returns the raw typed statement query, which composes generically —
115 /// `filter`, `order_by` and `Paginate::new` work on the raw form for any
116 /// `M: Model` — so one panel handler drives every resource's list page.
117 ///
118 /// # Keep unique constraints in step with this scope
119 ///
120 /// The app-side unique pre-check probes through the same tenant-scoped
121 /// query, so a `#[unique]` index *broader* than the scope is invisible: the
122 /// probe misses the colliding row and the user gets a 500 instead of the
123 /// inline "has already been taken". Scope the constraint to match —
124 /// `#[unique(tenant_id, email)]`. Not checkable at declaration time, since
125 /// a query's filters are not introspectable; upstream #117 is the fix.
126 fn query(_cx: &Cx) -> toasty::stmt::Query<List<Self::Model>> {
127 toasty::stmt::Query::<List<Self::Model>>::all()
128 }
129
130 /// App-level rules on the parsed form. The errors render inline with a
131 /// 200 and nothing is written; a record fn error keeps its own mapping, so
132 /// a range or cross-field rule belongs here.
133 ///
134 /// Each error names a field of the record form (`UserFormField::Age`) and renders under its
135 /// control, or under an embedded value's first control.
136 fn validate_record(
137 _cx: &Cx,
138 _form: &Self::Form,
139 ) -> FieldErrors<<Self::Form as RecordForm>::Field> {
140 FieldErrors::new()
141 }
142
143 /// Create a record from the parsed form, inside the handler's transaction.
144 ///
145 /// Defaults to the derived write, [`write_create`]; override to check
146 /// something inside the transaction, then delegate. An override on a
147 /// [`Tenancy::column`](crate::Tenancy::column) resource stamps the tenant
148 /// only by delegating to [`write_create`]. `ex` is the open
149 /// transaction: run every statement through it. Return the created row; it
150 /// is what [`Self::after_commit`] receives.
151 fn create_record(
152 cx: &Cx,
153 form: Self::Form,
154 ex: &mut dyn Executor,
155 ) -> impl Future<Output = Result<Self::Model>> + Send {
156 write_create::<Self>(cx, form, ex)
157 }
158
159 /// Update the already-authorized `record` from the posted form, inside the
160 /// handler's transaction.
161 ///
162 /// Defaults to the derived write, [`write_update`]. `record` is the
163 /// snapshot the handler loaded and policy-checked inside the transaction:
164 /// use it, never re-query. Return the row as it now stands.
165 fn update_record(
166 cx: &Cx,
167 record: Self::Model,
168 posted: Posted<Self::Form>,
169 ex: &mut dyn Executor,
170 ) -> impl Future<Output = Result<Self::Model>> + Send {
171 write_update::<Self>(cx, record, posted, ex)
172 }
173
174 /// Delete the already-authorized `record` (#86).
175 ///
176 /// The handler loads `record` through the tenancy-scoped query inside the
177 /// framework transaction and checks the policy on that snapshot, then
178 /// calls this with the same transaction as `ex`. The after-commit hook
179 /// receives the snapshot once the delete commits.
180 ///
181 /// The default deletes the row through [`scoped_query`], filtered to the
182 /// record's primary key. Override to delete another way, such as a soft
183 /// delete.
184 fn delete_record(
185 cx: &Cx,
186 record: &Self::Model,
187 ex: &mut dyn toasty::Executor,
188 ) -> impl std::future::Future<Output = Result<()>> + Send
189 where
190 Self: Sized,
191 {
192 let filter = crate::toasty_compat::pk::pk_filter(record);
193 let query = scoped_query::<Self>(cx).map(|query| query.filter(filter));
194 async move {
195 query?
196 .delete()
197 .exec(ex)
198 .await
199 .map_err(|error| -> topcoat::Error { error.into() })?;
200 Ok(())
201 }
202 }
203
204 /// Bulk-delete the already-authorized `records`: the handler
205 /// fetches through the tenancy-scoped `IN` query inside the framework
206 /// transaction, checks `View` on every row, and passes the rows
207 /// [`Ability::Delete`](crate::Ability::Delete) allows, skipping the rest
208 /// as a bulk custom action does. The default deletes each record through
209 /// [`Self::delete_record`] in order, through the same `ex` — any error rolls the whole
210 /// batch back, so mid-loop failures delete zero rows. An override of `delete_record`,
211 /// such as a soft delete, therefore covers bulk delete too. Override this
212 /// for a single-statement batch.
213 fn bulk_delete_records(
214 cx: &Cx,
215 records: &[Self::Model],
216 ex: &mut dyn toasty::Executor,
217 ) -> impl std::future::Future<Output = Result<()>> + Send
218 where
219 Self: Sized,
220 {
221 async move {
222 for record in records {
223 Self::delete_record(cx, record, &mut *ex).await?;
224 }
225 Ok(())
226 }
227 }
228
229 /// Post-commit work for a mutation this resource committed.
230 ///
231 /// The place for a side effect that must not survive a rollback: an email, a
232 /// webhook, an audit row, cache invalidation. Called once per successful
233 /// write, after `tx.commit()` and before the response — running it inside a
234 /// record fn would leak the effect on rollback, and the write handlers' pool
235 /// discipline forbids a second handle while the transaction is open.
236 ///
237 /// [`Committed`] names the mutation and the rows it wrote: the committed row
238 /// a create or update returned, the rows a delete or bulk delete removed
239 /// (one call with every row). It is never called when nothing committed: a
240 /// validation error, a policy denial, a failed record fn, or a failed commit
241 /// all leave the hook untouched, so a rollback cannot produce the effect. A
242 /// hook that returns `Err` is logged and ignored — the write is committed,
243 /// and retries are the app's to build; a panic surfaces as Topcoat's
244 /// panic-isolated 500.
245 ///
246 /// ```rust
247 /// # use tablo_core::{Committed, NoForm, Resource, db::db};
248 /// # use topcoat::{Result, context::Cx};
249 /// # #[derive(Debug, Clone, toasty::Model)]
250 /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
251 /// # async fn notify(_db: &mut toasty::Db, _post: &Post) -> Result<()> { Ok(()) }
252 /// # struct PostResource;
253 /// # impl Resource for PostResource {
254 /// # type Model = Post;
255 /// # type Form = NoForm<Post>;
256 /// async fn after_commit(cx: &Cx, committed: Committed<Post>) -> Result<()> {
257 /// let mut db = db(cx); // a fresh handle is allowed here
258 /// for post in committed.records() {
259 /// notify(&mut db, post).await?;
260 /// }
261 /// Ok(())
262 /// }
263 /// # }
264 /// ```
265 fn after_commit(
266 _cx: &Cx,
267 _committed: Committed<Self::Model>,
268 ) -> impl std::future::Future<Output = Result<()>> + Send
269 where
270 Self: Sized,
271 {
272 async move { Ok(()) }
273 }
274}
275
276/// The tenant-scoped base query.
277///
278/// [`Resource::query`] with the [`tenancy`](ResourceDef::tenancy) filter ANDed onto it, so a
279/// resource that overrides `query` for soft deletes cannot drop the tenant
280/// scope by forgetting to re-state it. Every framework loader and app code
281/// start here.
282///
283/// # Errors
284///
285/// A tenant-scoped resource and no tenant in `cx`: 403, the same answer the
286/// handler gate gives. A declaration error when the context's panel does not mount `R`; a
287/// background job builds its context with [`PanelHandle::context`](crate::PanelHandle::context).
288///
289/// App code that loads rows itself must call this: on a scoped resource
290/// [`Resource::query`] is the *tenant-unscoped* base by design.
291pub fn scoped_query<R: Resource>(cx: &Cx) -> Result<Query<List<R::Model>>> {
292 require_mounted::<R>(cx)?.scoped_query(cx)
293}
294
295impl<R: Resource> Mounted<R> {
296 /// [`scoped_query`] for this mount.
297 pub(crate) fn scoped_query(&self, cx: &Cx) -> Result<Query<List<R::Model>>> {
298 self.tenant_scope(cx, R::query(cx))
299 }
300
301 /// AND the resource's tenant filter onto `query`.
302 fn tenant_scope(&self, cx: &Cx, query: Query<List<R::Model>>) -> Result<Query<List<R::Model>>> {
303 if !self.tenancy.is_scoped() {
304 return Ok(query);
305 }
306 if let Some(Err(error)) = self.tenancy.column_field() {
307 return Err(crate::error::declaration(
308 crate::DeclarationError::of::<R>(crate::Site::Tenancy, error).to_string(),
309 ));
310 }
311 let tenant = crate::tenancy::require_tenant(cx)?;
312 Ok(match self.tenancy.filter(tenant) {
313 Some(filter) => query.filter(filter),
314 None => query,
315 })
316 }
317}
318
319/// Whether `R`'s policy, as the context's panel mounted it, allows `ability`; `false` when the
320/// panel does not mount `R`. Does not check sign-in or tenant scope.
321pub fn can<R: Resource>(cx: &Cx, ability: crate::Ability<'_, R::Model>) -> bool {
322 mounted::<R>(cx).is_some_and(|resource| resource.can(cx, ability))
323}
324
325/// Every `Resource` is an [`OptionSource`](crate::extend::OptionSource), answering from its def
326/// as the context's panel mounted it.
327///
328/// [`scoped_query`](crate::extend::OptionSource::scoped_query) forwards to
329/// [`scoped_query`], so an option load inherits the tenant gate and filter
330/// exactly as every other loader does. The search expression and default ordering come from the
331/// resource's [`table`](ResourceDef::table), whose searchable columns the option search searches
332/// with the [`record_title`](ResourceDef::record_title) column, and each record's label is its
333/// title.
334impl<R: Resource> crate::schema::OptionSource for R {
335 type Model = R::Model;
336
337 fn scoped_query(cx: &Cx) -> Result<Query<List<R::Model>>> {
338 scoped_query::<R>(cx)
339 }
340
341 fn allows(cx: &Cx, ability: crate::Ability<'_, R::Model>) -> bool {
342 can::<R>(cx, ability)
343 }
344
345 fn requires_tenant(cx: &Cx) -> bool {
346 mounted::<R>(cx).is_some_and(|mounted| mounted.tenancy.is_scoped())
347 }
348
349 fn search_expr(cx: &Cx, term: &str) -> Option<toasty::stmt::Expr<bool>> {
350 mounted::<R>(cx)?.option_search_expr(term)
351 }
352
353 fn order_by(cx: &Cx) -> Option<toasty::stmt::OrderByExpr> {
354 mounted::<R>(cx)?.table.order_by(false)
355 }
356
357 /// The record's title, as its detail page shows it.
358 fn label(cx: &Cx, record: &R::Model) -> String {
359 let key = crate::toasty_compat::pk::pk_text(record);
360 match mounted::<R>(cx) {
361 Some(mounted) => mounted.record_title(record, &key),
362 None => key,
363 }
364 }
365
366 fn available(cx: &Cx) -> bool {
367 mounted::<R>(cx).is_some()
368 }
369}
370
371#[cfg(test)]
372mod tests;