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 check;
13mod commit;
14mod def;
15mod field_access;
16mod global_search;
17mod header_action;
18mod mounted;
19mod relation;
20mod soft_delete;
21mod write;
22mod write_error;
23
24pub use action::{Action, Places};
25pub(crate) use action::{
26    ActionEntry, Actions, ErasedInput, InputResult, InputSpec, RESERVED_KEYS, parse_validated,
27};
28pub(crate) use check::{ResourceCheck, check_inputs, check_resource};
29pub use commit::Change;
30pub(crate) use commit::{acted, run_after_commit};
31pub use def::{PublicLink, ResourceDef};
32pub use field_access::FieldAccess;
33pub(crate) use field_access::{FieldMask, FieldRule};
34pub use global_search::GlobalSearch;
35pub(crate) use global_search::{Opens, SearchHit, search, searchable};
36pub(crate) use header_action::HeaderEntry;
37pub use header_action::{HeaderAction, HeaderActions};
38pub(crate) use mounted::{
39    MountScope, Mounted, Mounts, is_mounted, is_restricted_model, is_tenant_model, model_rules,
40    mounted, require_mounted, restricts_any,
41};
42pub use relation::{ForeignKey, Relation};
43pub(crate) use relation::{Links, RelationKind};
44pub use soft_delete::Trashed;
45pub(crate) use soft_delete::{SoftDelete, TRASH_FILTER, TrashFilter};
46pub use write::{write_create, write_update};
47pub use write_error::WriteError;
48
49/// Maps one Toasty `Model` to its admin UI.
50///
51/// # Contract
52///
53/// Requires [`Model`](Self::Model) and [`Form`](Self::Form); every other item has a default.
54/// [`declare`](Self::declare) returns what the resource declares, as one [`ResourceDef`] value;
55/// the methods scope, validate and write records for a request.
56///
57/// The panel builds the def once when it mounts, binding the paths it names to the database
58/// schema, and serves the result to every request. Record fns run inside the handler transaction
59/// and fail without partial writes.
60pub trait Resource: Sized + Send + Sync + 'static {
61    /// The persisted model this resource administers. Its [`ModelColumns`](crate::ModelColumns)
62    /// give the table and detail page the resource declares none of.
63    type Model: crate::ModelColumns
64        + toasty::stmt::IntoExpr<Self::Model>
65        + Send
66        + Sync
67        + Clone
68        + 'static;
69
70    /// The typed value the create and edit forms parse into.
71    ///
72    /// A resource with create and edit pages names its
73    /// [`#[derive(RecordForm)]`](crate::RecordForm) struct; a list-only resource
74    /// names [`NoForm<Self::Model>`](crate::NoForm), and
75    /// [`Panel::resource`](crate::Panel::resource) registers no form route for
76    /// it ([`RecordForm::HAS_FORM`]).
77    type Form: RecordForm<Model = Self::Model>;
78
79    /// What the resource declares: names, navigation, policy, tenancy, table, form, detail page,
80    /// relations and actions. Defaults to [`ResourceDef::new`], whose policy denies all.
81    ///
82    /// ```rust
83    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
84    /// # struct Post {
85    /// #     #[key] #[auto] id: uuid::Uuid,
86    /// #     title: String,
87    /// #     body: String,
88    /// # }
89    /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
90    /// # #[form(model = Post)]
91    /// # struct PostForm { title: String, body: String }
92    /// # struct PostResource;
93    /// # use tablo_core::{ReadOnly, RecordForm, Resource, ResourceDef, Schema, Section};
94    /// impl Resource for PostResource {
95    ///     type Model = Post;
96    ///     type Form = PostForm;
97    ///
98    ///     fn declare() -> ResourceDef<Self> {
99    ///         let c = PostForm::controls();
100    ///         ResourceDef::new().policy(ReadOnly).form(Schema::new(
101    ///             Section::new("Content").schema((c.title, c.body.multiline(6))),
102    ///         ))
103    ///     }
104    /// }
105    /// ```
106    ///
107    /// The panel builds the def once when it mounts and serves that copy to every request, so
108    /// `declare` takes no user, tenant or query string: a request-dependent decision belongs to
109    /// the policy or to a trait method, which receives `cx`.
110    fn declare() -> ResourceDef<Self> {
111        ResourceDef::new()
112    }
113
114    /// Base query — the seam for a resource's **own** row scoping (ADR-0002):
115    /// soft deletes and row-level visibility. Every loader starts from it.
116    ///
117    /// **Relations are not this method's job.** The list, the export and the detail page load
118    /// the relations their columns declare ([`Column::includes`](crate::extend::Column::includes)):
119    /// a [`RelationColumn`](crate::RelationColumn) declares its own. Include a relation here
120    /// only when a closure no column covers reads it on every loader's rows: one the
121    /// [`policy`](ResourceDef::policy) reads, or one a table's `group_by` or row key reads without
122    /// a column including it.
123    ///
124    /// **Tenancy is not this method's job either.** For a resource whose
125    /// [`tenancy`](ResourceDef::tenancy) is scoped the framework ANDs the tenant filter
126    /// onto whatever this returns, at every loader through [`scoped_query`]. Do
127    /// not re-state it here. Code outside the framework's loaders starts from
128    /// [`scoped_query`].
129    ///
130    /// Returns the raw typed statement query, which composes generically —
131    /// `filter`, `order_by` and `Paginate::new` work on the raw form for any
132    /// `M: Model` — so one panel handler drives every resource's list page.
133    ///
134    /// A list and its export replace any ordering this sets with the table's own sort: order a
135    /// list with [`Table::default_sort`](crate::Table::default_sort).
136    ///
137    /// # Keep unique constraints in step with this scope
138    ///
139    /// The app-side unique pre-check probes through the same tenant-scoped
140    /// query, so a `#[unique]` index *broader* than the scope is invisible: the
141    /// probe misses the colliding row and the user gets a 500 instead of the
142    /// inline "has already been taken". Scope the constraint to match —
143    /// `#[unique(tenant_id, email)]` — or, when the index must stay broader, probe it in
144    /// [`create_record`](Self::create_record) and [`update_record`](Self::update_record) and refuse
145    /// with [`WriteError::Refused`]. Not checkable at declaration time, since a query's filters
146    /// are not introspectable; tablo#117 tracks the fix.
147    fn query(_cx: &Cx) -> toasty::stmt::Query<List<Self::Model>> {
148        toasty::stmt::Query::<List<Self::Model>>::all()
149    }
150
151    /// App-level rules on the parsed form. The errors render inline with a
152    /// 200 and nothing is written, so a range or cross-field rule belongs here. A rule that
153    /// reads the database belongs in [`create_record`](Self::create_record) or
154    /// [`update_record`](Self::update_record), which refuse through the transaction they write
155    /// in.
156    ///
157    /// Each error names a field of the record form (`UserFormField::Age`) and renders under its
158    /// control, or under an embedded value's first control.
159    fn validate_record(
160        _cx: &Cx,
161        _form: &Self::Form,
162    ) -> FieldErrors<<Self::Form as RecordForm>::Field> {
163        FieldErrors::new()
164    }
165
166    /// Create a record from the parsed form, inside the handler's transaction.
167    ///
168    /// Defaults to the derived write, [`write_create`]; override to check
169    /// something inside the transaction, then delegate. An override on a
170    /// [`Tenancy::column`](crate::Tenancy::column) resource stamps the tenant
171    /// only by delegating to [`write_create`]. `ex` is the open
172    /// transaction: run every statement through it. Return the created row; the
173    /// hooks receive it as [`Change::Create`].
174    ///
175    /// A rule that reads the database refuses the submission with [`WriteError::Refused`]: the
176    /// transaction rolls back and the form renders again with the errors under their fields.
177    fn create_record(
178        cx: &Cx,
179        form: Self::Form,
180        ex: &mut dyn Executor,
181    ) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
182    {
183        async move { Ok(write_create::<Self>(cx, form, ex).await?) }
184    }
185
186    /// Update the already-authorized `record` from the posted form, inside the
187    /// handler's transaction.
188    ///
189    /// Defaults to the derived write, [`write_update`]. `record` is the
190    /// snapshot the handler loaded and policy-checked inside the transaction:
191    /// use it, never re-query. Return the row as it now stands.
192    ///
193    /// Refuses the submission with [`WriteError::Refused`], as
194    /// [`create_record`](Self::create_record) does.
195    fn update_record(
196        cx: &Cx,
197        record: Self::Model,
198        posted: Posted<Self::Form>,
199        ex: &mut dyn Executor,
200    ) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
201    {
202        async move { Ok(write_update::<Self>(cx, record, posted, ex).await?) }
203    }
204
205    /// Delete the already-authorized `record`.
206    ///
207    /// The handler loads `record` through the tenancy-scoped query inside the
208    /// framework transaction and checks the policy on that snapshot, then
209    /// calls this with the same transaction as `ex`. The hooks receive the
210    /// snapshot as [`Change::Delete`].
211    ///
212    /// The default deletes the row through [`scoped_query`], filtered to the
213    /// record's primary key, or stamps its
214    /// [`soft_delete`](ResourceDef::soft_delete) column with the time when the resource declares
215    /// one. Override to delete another way.
216    fn delete_record(
217        cx: &Cx,
218        record: &Self::Model,
219        ex: &mut dyn toasty::Executor,
220    ) -> impl std::future::Future<Output = Result<()>> + Send
221    where
222        Self: Sized,
223    {
224        write::delete::<Self>(cx, record, ex)
225    }
226
227    /// Restore the already-authorized trashed `record` of a resource that
228    /// [soft-deletes](ResourceDef::soft_delete), inside the handler's transaction.
229    ///
230    /// The handler loads `record` through [`scoped_query_in`] with [`Trashed::Only`] and checks
231    /// [`Ability::Restore`](crate::Ability::Restore) on it. The default empties the soft-delete
232    /// column.
233    fn restore_record(
234        cx: &Cx,
235        record: &Self::Model,
236        ex: &mut dyn toasty::Executor,
237    ) -> impl std::future::Future<Output = Result<()>> + Send
238    where
239        Self: Sized,
240    {
241        write::restore::<Self>(cx, record, ex)
242    }
243
244    /// Delete the already-authorized trashed `record` for good, inside the handler's
245    /// transaction.
246    ///
247    /// The handler loads `record` through [`scoped_query_in`] with [`Trashed::Only`] and checks
248    /// [`Ability::ForceDelete`](crate::Ability::ForceDelete) on it. The default removes the row.
249    fn force_delete_record(
250        cx: &Cx,
251        record: &Self::Model,
252        ex: &mut dyn toasty::Executor,
253    ) -> impl std::future::Future<Output = Result<()>> + Send
254    where
255        Self: Sized,
256    {
257        write::force_delete::<Self>(cx, record, ex)
258    }
259
260    /// Bulk-delete the already-authorized `records`: the handler
261    /// fetches through the tenancy-scoped `IN` query inside the framework
262    /// transaction, checks `View` on every row, and passes the rows
263    /// [`Ability::Delete`](crate::Ability::Delete) allows, skipping the rest
264    /// as a bulk custom action does. The default deletes each record through
265    /// [`Self::delete_record`] in order, through the same `ex` — any error rolls the whole
266    /// batch back, so mid-loop failures delete zero rows. An override of `delete_record`,
267    /// such as a soft delete, therefore covers bulk delete too. Override this
268    /// for a single-statement batch.
269    fn bulk_delete_records(
270        cx: &Cx,
271        records: &[Self::Model],
272        ex: &mut dyn toasty::Executor,
273    ) -> impl std::future::Future<Output = Result<()>> + Send
274    where
275        Self: Sized,
276    {
277        async move {
278            for record in records {
279                Self::delete_record(cx, record, &mut *ex).await?;
280            }
281            Ok(())
282        }
283    }
284
285    /// Work inside the write's transaction, after the record fn or action wrote: an audit row,
286    /// a counter, a row in another table that must commit with the write or not at all.
287    ///
288    /// Called once per write, with the [`Change`] it made, through `ex`, the open transaction:
289    /// run every statement through it, and notify through
290    /// [`Notifications::send_in`](crate::Notifications::send_in), which stores the
291    /// notification only if the write commits. A second connection, through
292    /// [`db`](crate::db::db) or [`Notifications::send`](crate::Notifications::send),
293    /// would write outside the transaction, and on a pool of one wait forever for the connection
294    /// the transaction holds; either refuses instead, and the write rolls back.
295    ///
296    /// Every write this resource's panel routes make comes here: a create, once per row of a CSV
297    /// import, an update, a delete or bulk delete, a restore or force delete, a custom or header
298    /// action, and a many-to-many attach or detach. The profile and tenant-settings pages save
299    /// their user and tenant rows themselves and do not. A validation error, a policy denial or
300    /// a failed or refused record fn returns before it. An `Err` rolls the whole write back and
301    /// fails the request with the write's failure toast.
302    ///
303    /// ```rust
304    /// # use tablo_core::{Change, NoForm, Resource};
305    /// # use topcoat::{Result, context::Cx};
306    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
307    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
308    /// # #[derive(Debug, toasty::Model)]
309    /// # struct Audit { #[key] #[auto] id: uuid::Uuid, post_id: uuid::Uuid, change: String }
310    /// # struct PostResource;
311    /// # impl Resource for PostResource {
312    /// #     type Model = Post;
313    /// #     type Form = NoForm<Post>;
314    /// async fn before_commit(
315    ///     _cx: &Cx,
316    ///     change: &Change<Post>,
317    ///     ex: &mut dyn toasty::Executor,
318    /// ) -> Result<()> {
319    ///     let kind = match change {
320    ///         Change::Create(_) => "create",
321    ///         Change::Update { .. } => "update",
322    ///         Change::Delete(_) => "delete",
323    ///         Change::Restore(_) => "restore",
324    ///         Change::ForceDelete(_) => "force delete",
325    ///         Change::Action { name, .. } | Change::HeaderAction { name } => name,
326    ///         Change::Attach { .. } => "attach",
327    ///         Change::Detach { .. } => "detach",
328    ///     };
329    ///     for post in change.records() {
330    ///         toasty::create!(Audit {
331    ///             post_id: post.id,
332    ///             change: kind.to_string()
333    ///         })
334    ///         .exec(&mut *ex)
335    ///         .await?;
336    ///     }
337    ///     Ok(())
338    /// }
339    /// # }
340    /// ```
341    fn before_commit(
342        _cx: &Cx,
343        _change: &Change<Self::Model>,
344        _ex: &mut dyn Executor,
345    ) -> impl Future<Output = Result<()>> + Send
346    where
347        Self: Sized,
348    {
349        async move { Ok(()) }
350    }
351
352    /// Work after the write commits, outside the database: an email, a webhook, cache
353    /// invalidation.
354    ///
355    /// Called once per committed write, after `tx.commit()` and before the response, with the
356    /// [`Change`] [`Self::before_commit`] received. It is never called when nothing committed, so
357    /// a rollback cannot produce the effect. Nothing guarantees it runs either: an error it
358    /// returns is logged and ignored, since the write is committed, and a crash between the
359    /// commit and the hook skips it. A row that must exist whenever the write does belongs in
360    /// [`Self::before_commit`]; retries are the app's to build. A panic surfaces as Topcoat's
361    /// panic-isolated 500.
362    ///
363    /// ```rust
364    /// # use tablo_core::{Change, NoForm, Resource};
365    /// # use topcoat::{Result, context::Cx};
366    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
367    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
368    /// # async fn notify(_cx: &Cx, _post: &Post) -> Result<()> { Ok(()) }
369    /// # struct PostResource;
370    /// # impl Resource for PostResource {
371    /// #     type Model = Post;
372    /// #     type Form = NoForm<Post>;
373    /// async fn after_commit(cx: &Cx, change: Change<Post>) -> Result<()> {
374    ///     if let Change::Create(post) = &change {
375    ///         notify(cx, post).await?;
376    ///     }
377    ///     Ok(())
378    /// }
379    /// # }
380    /// ```
381    fn after_commit(
382        _cx: &Cx,
383        _change: Change<Self::Model>,
384    ) -> impl Future<Output = Result<()>> + Send
385    where
386        Self: Sized,
387    {
388        async move { Ok(()) }
389    }
390}
391
392/// The tenant-scoped base query.
393///
394/// [`Resource::query`] with the [`tenancy`](ResourceDef::tenancy) filter ANDed onto it, so a
395/// resource that overrides `query` cannot drop the tenant scope by forgetting to re-state it, and
396/// with the trashed records of a resource that [soft-deletes](ResourceDef::soft_delete) left out.
397/// Every framework loader and app code start here; [`scoped_query_in`] admits the trashed
398/// records too.
399///
400/// # Errors
401///
402/// A tenant-scoped resource and no tenant in `cx`: 403, the same answer the
403/// panel's handlers give. A declaration error when the context's panel does not mount `R`; a
404/// background job builds its context with [`PanelHandle::context`](crate::PanelHandle::context).
405///
406/// App code that loads rows itself must call this: on a scoped resource
407/// [`Resource::query`] is the *tenant-unscoped* base by design.
408pub fn scoped_query<R: Resource>(cx: &Cx) -> Result<Query<List<R::Model>>> {
409    scoped_query_in::<R>(cx, Trashed::Without)
410}
411
412/// [`scoped_query`] admitting the records `trashed` names, as a restore or a trash list loads
413/// them, under the same tenant filter.
414///
415/// On a resource that declares no [`soft_delete`](ResourceDef::soft_delete) column every record
416/// is live: [`Trashed::Only`] admits none, and the other scopes admit all.
417///
418/// # Errors
419///
420/// As [`scoped_query`].
421pub fn scoped_query_in<R: Resource>(cx: &Cx, trashed: Trashed) -> Result<Query<List<R::Model>>> {
422    require_mounted::<R>(cx)?.scoped_query_in(cx, trashed)
423}
424
425impl<R: Resource> Mounted<R> {
426    /// [`scoped_query`] for this mount.
427    pub(crate) fn scoped_query(&self, cx: &Cx) -> Result<Query<List<R::Model>>> {
428        self.scoped_query_in(cx, Trashed::Without)
429    }
430
431    /// [`scoped_query_in`] for this mount.
432    pub(crate) fn scoped_query_in(
433        &self,
434        cx: &Cx,
435        trashed: Trashed,
436    ) -> Result<Query<List<R::Model>>> {
437        let query = self.tenant_scope(cx, R::query(cx))?;
438        Ok(match &self.soft_delete {
439            Some(soft_delete) => match soft_delete.filter(trashed) {
440                Some(filter) => query.filter(filter),
441                None => query,
442            },
443            None if trashed == Trashed::Only => query.filter(crate::toasty_compat::never()),
444            None => query,
445        })
446    }
447
448    /// The records a list in `state` admits: the ones its trash control selects, else the live
449    /// ones.
450    pub(crate) fn list_trashed(&self, state: &crate::TableState) -> Trashed {
451        if self.soft_delete.is_none() {
452            return Trashed::Without;
453        }
454        Trashed::from_filter(state.filters.get(TRASH_FILTER)).unwrap_or_default()
455    }
456
457    /// [`scoped_query_in`] for the list in `state`.
458    pub(crate) fn list_query(
459        &self,
460        cx: &Cx,
461        state: &crate::TableState,
462    ) -> Result<Query<List<R::Model>>> {
463        self.scoped_query_in(cx, self.list_trashed(state))
464    }
465
466    /// Whether `record` is trashed: never on a resource that does not soft-delete.
467    pub(crate) fn is_trashed(&self, record: &R::Model) -> bool {
468        self.soft_delete
469            .as_ref()
470            .is_some_and(|soft_delete| soft_delete.is_trashed(record))
471    }
472
473    /// AND the resource's tenant filter onto `query`.
474    fn tenant_scope(&self, cx: &Cx, query: Query<List<R::Model>>) -> Result<Query<List<R::Model>>> {
475        if !self.tenancy.is_scoped() {
476            return Ok(query);
477        }
478        if let Some(Err(error)) = self.tenancy.column_field() {
479            return Err(crate::error::declaration(
480                crate::DeclarationError::of::<R>(crate::Site::Tenancy, error).to_string(),
481            ));
482        }
483        let tenant = crate::tenancy::require_tenant(cx)?;
484        Ok(match self.tenancy.filter(tenant) {
485            Some(filter) => query.filter(filter),
486            None => query,
487        })
488    }
489}
490
491/// Whether `R`'s policy, as the context's panel mounted it, allows `ability`; `false` when the
492/// panel does not mount `R`. Does not check sign-in or tenant scope.
493pub fn can<R: Resource>(cx: &Cx, ability: crate::Ability<'_, R::Model>) -> bool {
494    mounted::<R>(cx).is_some_and(|resource| resource.can(cx, ability))
495}
496
497/// Every `Resource` is an [`OptionSource`](crate::extend::OptionSource), answering from its def
498/// as the context's panel mounted it.
499///
500/// [`scoped_query`](crate::extend::OptionSource::scoped_query) forwards to
501/// [`scoped_query`], so an option load inherits the tenant check and filter
502/// exactly as every other loader does. The search expression and default ordering come from the
503/// resource's [`table`](ResourceDef::table), whose searchable columns the option search searches
504/// with the [`record_title`](ResourceDef::record_title) column, and each record's label is its
505/// title.
506impl<R: Resource> crate::schema::OptionSource for R {
507    type Model = R::Model;
508
509    fn scoped_query(cx: &Cx) -> Result<Query<List<R::Model>>> {
510        scoped_query::<R>(cx)
511    }
512
513    fn allows(cx: &Cx, ability: crate::Ability<'_, R::Model>) -> bool {
514        can::<R>(cx, ability)
515    }
516
517    fn requires_tenant(cx: &Cx) -> bool {
518        mounted::<R>(cx).is_some_and(|mounted| mounted.tenancy.is_scoped())
519    }
520
521    fn search_expr(cx: &Cx, term: &str) -> Option<toasty::stmt::Expr<bool>> {
522        mounted::<R>(cx)?.option_search_expr(cx, term)
523    }
524
525    fn order_by(cx: &Cx) -> Option<toasty::stmt::OrderByExpr> {
526        mounted::<R>(cx)?.table_for(cx).first_order_by()
527    }
528
529    /// The record's title, as its detail page shows it.
530    fn label(cx: &Cx, record: &R::Model) -> String {
531        let key = crate::toasty_compat::pk::pk_text(record);
532        match mounted::<R>(cx) {
533            Some(mounted) => mounted.record_title(cx, record, &key),
534            None => key,
535        }
536    }
537
538    fn available(cx: &Cx) -> bool {
539        mounted::<R>(cx).is_some()
540    }
541
542    fn hidden_fields(cx: &Cx) -> Vec<String> {
543        mounted::<R>(cx).map_or_else(Vec::new, |mounted| mounted.field_mask(cx).hidden().to_vec())
544    }
545}
546
547#[cfg(test)]
548mod tests;