Skip to main content

tablo_core/resource/
relation.rs

1//! Relation tables: a record page's related rows through the related resource's own list table.
2
3use std::{any::TypeId, sync::Arc};
4
5use toasty::stmt::{Expr, IntoExpr, List, Path};
6use topcoat::{Result, context::Cx};
7
8use super::{
9    ErasedInput, InputResult, InputSpec, Resource, Trashed, can, scoped_query, scoped_query_in,
10};
11use crate::{
12    BoxFuture,
13    form::{FieldError, FormValues},
14    policy::Ability,
15    schema::{Field, Schema},
16    toasty_compat::{
17        join::{JoinTable, linked_to},
18        model::{self, AppSchema, ModelPath},
19        pk,
20    },
21};
22
23/// One relation of a parent resource's records.
24///
25/// Declared by [`ResourceDef::relation`](super::ResourceDef::relation); the related resource
26/// must be registered on the same panel, which keys the relation by its slug:
27///
28/// ```rust
29/// # use tablo_core::{NoForm, Relation, Resource, ResourceDef, lens};
30/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
31/// # struct Post {
32/// #     #[key] #[auto] id: uuid::Uuid,
33/// #     #[has_many]
34/// #     comments: toasty::Deferred<Vec<Comment>>,
35/// # }
36/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
37/// # struct Comment {
38/// #     #[key] #[auto] id: uuid::Uuid,
39/// #     #[index]
40/// #     post_id: uuid::Uuid,
41/// #     #[belongs_to(key = post_id, references = id)]
42/// #     post: toasty::Deferred<Post>,
43/// # }
44/// # struct PostResource;
45/// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
46/// # struct CommentResource;
47/// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
48/// let def: ResourceDef<PostResource> =
49///     ResourceDef::new().relation(Relation::has_many::<CommentResource>(lens!(Comment.post_id)));
50/// ```
51///
52/// The table is the related resource's own list table, its columns, search, sort, filters and
53/// pager, over its tenant-scoped query narrowed to the record, titled with its plural label or
54/// [`label`](Self::label):
55///
56/// - its policy applies as on its own list: no section renders when it refuses `ViewAny`, or when
57///   the resource is tenant-scoped and the request has no tenant, and each row keeps only the
58///   actions its record allows;
59/// - a [`has_many`](Self::has_many) table carries the related resource's View, Edit, Delete and
60///   custom actions and its bulk bar, and a create button when the related resource has a form and
61///   its policy allows `Create`. The button opens the create page with the owner preselected, as in
62///   `/admin/comments/-/create?post_id=…`: a default for a relationship choice only, validated like
63///   any other create;
64/// - a [`belongs_to_many`](Self::belongs_to_many) table keeps View, Edit and Delete, and offers no
65///   custom action and no create button, since a record created there would link nothing;
66/// - writes started from the table carry `?return=` and land back on the page, which the panel
67///   follows only to a path under its own prefix;
68/// - its URL parameters take the related resource's slug as a prefix, as in `?comments.q=`, so
69///   several tables share one page, and sorting, searching, filtering and paging update it in
70///   place. On the edit page that keeps the fields the user edited, but loses repeater rows added
71///   and options fetched since the page loaded. A refused save renders no related table.
72///
73/// A [`has_one`](Self::has_one) relation renders no table: the related resource's form edits the
74/// record's one child inside the record's own form.
75///
76/// [`tab`](Self::tab) moves the table off the detail and edit pages onto a page of its own.
77///
78/// Mounting refuses a relation to a resource the panel does not register, and two relations of
79/// one resource to the same related resource.
80pub struct Relation<P> {
81    pub(crate) child: TypeId,
82    pub(crate) child_name: &'static str,
83    pub(crate) label: Option<crate::Text>,
84    /// Whether [`Self::tab`] serves the table on a page of its own.
85    pub(crate) tab: bool,
86    pub(crate) kind: RelationKind<P>,
87    bind: BindFn<P>,
88    /// Why the relation's field binds nothing, when it does not.
89    pub(crate) misdeclared: Option<crate::DeclarationErrorKind>,
90}
91
92/// How a relation's child rows belong to the owner.
93pub(crate) enum RelationKind<P> {
94    /// Each child row holds the owner's key in this column, which a create from the table seeds.
95    HasMany { foreign_key: String },
96    /// The owner's one child row holds its key in this column, which the related form fills.
97    HasOne { foreign_key: String },
98    /// The owner links child records through a join model, which the table attaches and detaches.
99    ManyToMany(Links<P>),
100}
101
102/// An owner's rows filter on the child.
103type BindFn<P> = Arc<dyn Fn(&P) -> (Expr<bool>, String) + Send + Sync>;
104
105/// A many-to-many relation's writes: link the child record a key names to an owner, or unlink
106/// the ones keys name.
107pub(crate) struct Links<P> {
108    /// The owner's `#[has_many(via = ..)]` field.
109    pub(crate) field: String,
110    /// The attach dialog's input: a choice of one child record.
111    pub(crate) input: InputSpec,
112    /// Links the record the key names, which the input's re-check proved a child record in
113    /// scope; linking a linked record changes nothing. Answers the key as
114    /// the child's URLs spell it.
115    pub(crate) attach: LinkFn<P, String>,
116    /// Unlinks the records the keys name: 404 unless each is linked and in scope, 403 unless the
117    /// user may view each. Answers the keys as the children's URLs spell them.
118    pub(crate) detach: LinkFn<P, Vec<String>>,
119}
120
121/// A write of an owner's links, given what names the related records; answers those names as
122/// the related resource's URLs spell them.
123type LinkFn<P, K> = Arc<
124    dyn for<'a> Fn(&'a Cx, &'a P, K, &'a mut dyn toasty::Executor) -> BoxFuture<'a, Result<K>>
125        + Send
126        + Sync,
127>;
128
129/// The input key the attach dialog's choice posts.
130const ATTACH_KEY: &str = "record";
131
132/// A field of the child model `M` holding the owner's primary key, of type `K`: a
133/// [`lens!`](crate::lens!) or a field path over a `K` column, or an `Option<K>` one for an
134/// optional reference. Sealed.
135pub trait ForeignKey<M, K>: sealed::Field<M, K> {}
136
137impl<M, K, F: sealed::Field<M, K>> ForeignKey<M, K> for F {}
138
139mod sealed {
140    use toasty::stmt::{IntoExpr, Path};
141
142    use crate::{Lens, form::FormScalar};
143
144    pub trait Field<M, K> {
145        type Column: Column<K>;
146
147        fn into_path(self) -> Path<M, Self::Column>;
148    }
149
150    impl<M, K, T: Column<K>> Field<M, K> for Path<M, T> {
151        type Column = T;
152
153        fn into_path(self) -> Path<M, T> {
154            self
155        }
156    }
157
158    impl<M, K, V, T: Column<K>> Field<M, K> for Lens<M, V, T> {
159        type Column = T;
160
161        fn into_path(self) -> Path<M, T> {
162            self.into()
163        }
164    }
165
166    /// A column type holding a key of type `K`: `K` itself, or `Option<K>`.
167    pub trait Column<K>: IntoExpr<Self> + FormScalar + Send + Sync + 'static {}
168
169    impl<K> Column<K> for K where K: IntoExpr<K> + FormScalar + Send + Sync + 'static {}
170
171    impl<K> Column<K> for Option<K> where Option<K>: IntoExpr<Self> + FormScalar + Send + Sync + 'static {}
172}
173
174impl<P> Relation<P>
175where
176    P: toasty::schema::Model + IntoExpr<P> + 'static,
177{
178    /// Declare a `has_many` child resource whose `foreign_key` holds the owner's primary key, a
179    /// single column.
180    ///
181    /// ```rust
182    /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef, lens};
183    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
184    /// # struct Post {
185    /// #     #[key] #[auto] id: uuid::Uuid,
186    /// #     #[has_many]
187    /// #     comments: toasty::Deferred<Vec<Comment>>,
188    /// # }
189    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
190    /// # struct Comment {
191    /// #     #[key] #[auto] id: uuid::Uuid,
192    /// #     #[index]
193    /// #     post_id: uuid::Uuid,
194    /// #     #[belongs_to(key = post_id, references = id)]
195    /// #     post: toasty::Deferred<Post>,
196    /// # }
197    /// # struct PostResource;
198    /// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
199    /// # struct CommentResource;
200    /// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
201    /// let comments: Relation<Post> = Relation::has_many::<CommentResource>(lens!(Comment.post_id));
202    /// ```
203    pub fn has_many<C>(foreign_key: impl ForeignKey<C::Model, P::PrimaryKey>) -> Self
204    where
205        C: Resource,
206    {
207        Self::bound_by::<C, _>(foreign_key)
208    }
209
210    /// Declare a `has_one` child resource whose `foreign_key` holds the owner's primary key, a
211    /// single column: the record's one child, edited by the child resource's own form inside the
212    /// owner's create and edit forms rather than by a table.
213    ///
214    /// ```rust
215    /// # use tablo_core::{Relation, Resource, ResourceDef, lens};
216    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns, tablo_core::ModelForm)]
217    /// # struct User {
218    /// #     #[key] #[auto] id: uuid::Uuid,
219    /// #     name: String,
220    /// #     #[has_one]
221    /// #     profile: toasty::Deferred<Option<Profile>>,
222    /// # }
223    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns, tablo_core::ModelForm)]
224    /// # struct Profile {
225    /// #     #[key] #[auto] id: uuid::Uuid,
226    /// #     #[unique]
227    /// #     #[form(relationship = UserResource)]
228    /// #     user_id: uuid::Uuid,
229    /// #     #[belongs_to(key = user_id, references = id)]
230    /// #     user: toasty::Deferred<User>,
231    /// #     bio: String,
232    /// # }
233    /// # struct UserResource;
234    /// # impl Resource for UserResource { type Model = User; type Form = UserForm; }
235    /// # struct ProfileResource;
236    /// # impl Resource for ProfileResource { type Model = Profile; type Form = ProfileForm; }
237    /// let profile: Relation<User> = Relation::has_one::<ProfileResource>(lens!(Profile.user_id));
238    /// ```
239    ///
240    /// The related resource's form renders as a section of the owner's form, titled with its
241    /// label or [`label`](Self::label), each control posting under the child's slug, as in
242    /// `profiles.bio`; the foreign key renders no control, since the owner's key fills it. Saving
243    /// the owner writes the child in the same transaction, after the owner: a create creates it,
244    /// and an edit updates the record's child or creates one when it has none. Either way the
245    /// child goes through its own resource as on its own pages: its policy, tenancy, field access,
246    /// control rules, unique probe, `validate_record`, record fn and commit hooks. A refusal from
247    /// either form rolls both back and renders both with their errors. The owner's hooks receive
248    /// the owner's change, the child's its own.
249    ///
250    /// The section renders only while the request may write the child: `Create` on the related
251    /// resource, or `View` and `Update` on the record's child, with the foreign key neither hidden
252    /// nor locked by field access. A record with more than one child, or one the related
253    /// resource's query leaves out, renders none. A submission posting under the prefix with no
254    /// section is refused, and one posting nothing under it, such as an editable cell's or an
255    /// import row's, leaves the child alone. The relation renders no table.
256    ///
257    /// Mounting refuses a related resource with no form, one whose form binds no control for
258    /// the foreign key, a soft-deleting one, one whose form holds a control that needs its own
259    /// keys or routes (a file field, a repeater, an embedded value, a searchable or dependent
260    /// choice), an owner with no form, and an owner form key the prefix would shadow.
261    pub fn has_one<C>(foreign_key: impl ForeignKey<C::Model, P::PrimaryKey>) -> Self
262    where
263        C: Resource,
264    {
265        let mut relation = Self::bound_by::<C, _>(foreign_key);
266        if let RelationKind::HasMany { foreign_key } = relation.kind {
267            relation.kind = RelationKind::HasOne { foreign_key };
268        }
269        relation
270    }
271
272    fn bound_by<C, F>(foreign_key: F) -> Self
273    where
274        C: Resource,
275        F: ForeignKey<C::Model, P::PrimaryKey>,
276    {
277        let foreign_key = foreign_key.into_path();
278        // A foreign key is one column of the child: it binds against the child's model alone.
279        let (column, misdeclared) = match model::field::<C::Model>(&ModelPath::of(&foreign_key)) {
280            Ok(field) => (field.name, None),
281            Err(error) => (String::new(), Some(error)),
282        };
283        Self {
284            child: TypeId::of::<C>(),
285            child_name: std::any::type_name::<C>(),
286            label: None,
287            tab: false,
288            kind: RelationKind::HasMany {
289                foreign_key: column,
290            },
291            misdeclared,
292            bind: Arc::new(move |owner| {
293                let scope = foreign_key.clone().eq(pk::pk_expr::<P, F::Column>(owner));
294                (scope, pk::pk_text(owner))
295            }),
296        }
297    }
298
299    /// Declare a many-to-many child resource whose records the owner links through `via`, its
300    /// `#[has_many(via = joins.target)]` field: the owner's linked records, which the table
301    /// attaches and detaches as join rows.
302    ///
303    /// ```rust
304    /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
305    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
306    /// # struct Post {
307    /// #     #[key] #[auto] id: uuid::Uuid,
308    /// #     #[has_many]
309    /// #     taggings: toasty::Deferred<Vec<Tagging>>,
310    /// #     #[has_many(via = taggings.tag)]
311    /// #     tags: toasty::Deferred<Vec<Tag>>,
312    /// # }
313    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
314    /// # struct Tag { #[key] #[auto] id: uuid::Uuid, name: String }
315    /// # #[derive(Debug, Clone, toasty::Model)]
316    /// # struct Tagging {
317    /// #     #[key] #[auto] id: uuid::Uuid,
318    /// #     #[index] post_id: uuid::Uuid,
319    /// #     #[belongs_to(key = post_id, references = id)]
320    /// #     post: toasty::Deferred<Post>,
321    /// #     #[index] tag_id: uuid::Uuid,
322    /// #     #[belongs_to(key = tag_id, references = id)]
323    /// #     tag: toasty::Deferred<Tag>,
324    /// # }
325    /// # struct TagResource;
326    /// # impl Resource for TagResource { type Model = Tag; type Form = NoForm<Tag>; }
327    /// let tags: Relation<Post> = Relation::belongs_to_many::<TagResource>(Post::fields().tags());
328    /// ```
329    ///
330    /// The section's header carries Attach, whose dialog asks for one record through a
331    /// searchable choice offered as a relationship's options are; attaching a linked record
332    /// changes nothing. Each row and the bulk bar carry Detach, which deletes the join rows and
333    /// leaves the records. Both post under the owner's record:
334    /// `{list}/{key}/-/relations/{slug}/-/actions/attach`, `…/-/actions/detach` with the
335    /// selection's `ids`, and `…/{related}/-/actions/detach` for one row. The owner's hooks
336    /// receive [`Change::Attach`](crate::Change::Attach) or
337    /// [`Change::Detach`](crate::Change::Detach).
338    ///
339    /// Attaching and detaching ask the owner's policy for `Update`, as editing it does: no button
340    /// renders without it, and the routes answer 403. The attach dialog offers the related
341    /// resource's rows as its relationship choices do; a record its policy refuses `View` shows
342    /// without a Detach action, as it shows without its other row actions, and is not detached.
343    /// Mounting refuses a join model a link cannot write.
344    pub fn belongs_to_many<C>(via: impl Into<Path<P, List<C::Model>>>) -> Self
345    where
346        C: Resource,
347        P: Send + Sync,
348    {
349        let via: Path<P, List<C::Model>> = via.into();
350        let (field, misdeclared) = match model::field::<P>(&ModelPath::of(&via)) {
351            Ok(field) => (field.name, None),
352            Err(error) => (String::new(), Some(error)),
353        };
354        let attach_via = via.clone();
355        let attach_field = field.clone();
356        let detach_via = via.clone();
357        let detach_field = field.clone();
358        Self {
359            child: TypeId::of::<C>(),
360            child_name: std::any::type_name::<C>(),
361            label: None,
362            tab: false,
363            kind: RelationKind::ManyToMany(Links {
364                field,
365                input: InputSpec {
366                    schema: attach_schema::<C>,
367                    takes_input: true,
368                    parse: parse_attach,
369                },
370                attach: Arc::new(move |cx, owner, key, ex| {
371                    let (via, field) = (attach_via.clone(), attach_field.clone());
372                    Box::pin(async move { attach::<P, C>(cx, owner, via, &field, key, ex).await })
373                }),
374                detach: Arc::new(move |cx, owner, keys, ex| {
375                    let (via, field) = (detach_via.clone(), detach_field.clone());
376                    Box::pin(async move { detach::<P, C>(cx, owner, via, &field, keys, ex).await })
377                }),
378            }),
379            misdeclared,
380            bind: Arc::new(move |owner| (linked_to(owner, via.clone()), pk::pk_text(owner))),
381        }
382    }
383}
384
385/// The attach dialog's input: one searchable choice of `C`'s records.
386fn attach_schema<C: Resource>() -> Schema {
387    Schema::new(
388        Field::choice_input(ATTACH_KEY)
389            .label(crate::Text::model::<C::Model>())
390            .relationship::<C>()
391            .searchable(),
392    )
393}
394
395/// The key the attach dialog's choice posts: the schema's check proved it one of the choice's
396/// records.
397fn parse_attach(_cx: &Cx, values: &FormValues) -> InputResult {
398    match values.text(ATTACH_KEY).map(str::trim) {
399        Some(key) if !key.is_empty() => Ok(Box::new(key.to_string()) as ErasedInput),
400        _ => Err(vec![FieldError::required(ATTACH_KEY)]),
401    }
402}
403
404/// The join model behind `owner`'s `field`.
405fn join_table<P: toasty::schema::Model>(cx: &Cx, field: &str) -> Result<JoinTable> {
406    let schema = AppSchema::of(cx).ok_or_else(|| {
407        crate::error::declaration(crate::DeclarationErrorKind::MissingDb.to_string())
408    })?;
409    JoinTable::of::<P>(&schema, field).map_err(|fault| {
410        crate::error::declaration(
411            crate::DeclarationErrorKind::ManyToMany {
412                field: field.to_string(),
413                fault,
414            }
415            .to_string(),
416        )
417    })
418}
419
420/// Links `owner` to the record `key` names, unless they are linked already.
421async fn attach<P, C>(
422    cx: &Cx,
423    owner: &P,
424    via: Path<P, List<C::Model>>,
425    field: &str,
426    key: String,
427    ex: &mut dyn toasty::Executor,
428) -> Result<String>
429where
430    P: toasty::schema::Model + IntoExpr<P>,
431    C: Resource,
432{
433    let join = join_table::<P>(cx, field)?;
434    let (Some(key), Some(record)) = (
435        pk::canonical_key::<C::Model>(&key),
436        pk::pk_eq_expr::<C::Model>(&key),
437    ) else {
438        return Err(topcoat::router::error::not_found().into());
439    };
440    let linked = scoped_query::<C>(cx)?
441        .filter(record)
442        .filter(linked_to(owner, via))
443        .first()
444        .exec(&mut *ex)
445        .await
446        .map_err(crate::error::unavailable)?;
447    if linked.is_none() {
448        join.link(owner, std::slice::from_ref(&key), ex)
449            .await
450            .map_err(|error| -> topcoat::Error { error.into() })?;
451    }
452    Ok(key)
453}
454
455/// Unlinks `owner` from the records `keys` names.
456async fn detach<P, C>(
457    cx: &Cx,
458    owner: &P,
459    via: Path<P, List<C::Model>>,
460    field: &str,
461    keys: Vec<String>,
462    ex: &mut dyn toasty::Executor,
463) -> Result<Vec<String>>
464where
465    P: toasty::schema::Model + IntoExpr<P>,
466    C: Resource,
467{
468    let join = join_table::<P>(cx, field)?;
469    let ids: Vec<&str> = keys.iter().map(String::as_str).collect();
470    let Some(records) = pk::pk_in_expr::<C::Model>(&ids) else {
471        return Err(topcoat::router::error::not_found().into());
472    };
473    // A trashed record stays linked, and the relation table offers to unlink it.
474    let linked = scoped_query_in::<C>(cx, Trashed::With)?
475        .filter(records)
476        .filter(linked_to(owner, via))
477        .exec(&mut *ex)
478        .await
479        .map_err(crate::error::unavailable)?;
480    if linked.len() != keys.len() {
481        return Err(topcoat::router::error::not_found().into());
482    }
483    // A record the user cannot view cannot be unlinked by guessing its key.
484    if linked
485        .iter()
486        .any(|record| !can::<C>(cx, Ability::View(record)))
487    {
488        return Err(topcoat::router::error::forbidden().into());
489    }
490    join.unlink(owner, &keys, ex)
491        .await
492        .map_err(|error| -> topcoat::Error { error.into() })?;
493    Ok(linked.iter().map(pk::pk_text).collect())
494}
495
496impl<P> Relation<P> {
497    /// Titles the section `label` instead of the related resource's label: its plural one over a
498    /// table, its singular one over a `has_one` relation's form.
499    #[must_use]
500    pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
501        self.label = Some(label.into());
502        self
503    }
504
505    /// Serves the table on a page of its own, `{list}/{key}/{related slug}`, instead of under the
506    /// record's detail and edit pages, linked from the record's navigation: the tab bar its detail
507    /// page, edit page, [record pages](crate::RecordPage) and relation tabs share.
508    ///
509    /// The page loads the record as its detail page does, through the resource's query and
510    /// [`Ability::View`](crate::Ability::View), and answers 403 when the related resource's policy
511    /// refuses `ViewAny`. Mounting refuses a `has_one` relation as a tab, since its form edits
512    /// inside the record's, and a tab whose related slug is `edit`, `delete`, `-` or a record
513    /// page's.
514    #[must_use]
515    pub fn tab(mut self) -> Self {
516        self.tab = true;
517        self
518    }
519
520    /// The child's rows that belong to `owner`, and the owner's primary key as text.
521    pub(crate) fn bind(&self, owner: &P) -> (Expr<bool>, String) {
522        (self.bind)(owner)
523    }
524}
525
526impl<P> Relation<P> {
527    /// The relation's join-row writes, when it is a many-to-many one.
528    pub(crate) fn links(&self) -> Option<&Links<P>> {
529        match &self.kind {
530            RelationKind::ManyToMany(links) => Some(links),
531            RelationKind::HasMany { .. } | RelationKind::HasOne { .. } => None,
532        }
533    }
534
535    /// The child's column holding the owner's key, when the relation is a `has_one` one.
536    pub(crate) fn has_one_key(&self) -> Option<&str> {
537        match &self.kind {
538            RelationKind::HasOne { foreign_key } => Some(foreign_key),
539            RelationKind::HasMany { .. } | RelationKind::ManyToMany(_) => None,
540        }
541    }
542}
543
544impl<P> std::fmt::Debug for Relation<P> {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        let mut debug = f.debug_struct("Relation");
547        debug
548            .field("child", &self.child_name)
549            .field("label", &self.label);
550        match &self.kind {
551            RelationKind::HasMany { foreign_key } | RelationKind::HasOne { foreign_key } => {
552                debug.field("foreign_key", foreign_key)
553            }
554            RelationKind::ManyToMany(links) => debug.field("via", &links.field),
555        };
556        debug.finish_non_exhaustive()
557    }
558}