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