Skip to main content

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