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, FormScalar, 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};
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>(Comment::fields().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/// Mounting refuses a relation to a resource the panel does not register, and two relations of
74/// one resource to the same related resource.
75pub struct Relation<P> {
76    pub(crate) child: TypeId,
77    pub(crate) child_name: &'static str,
78    pub(crate) label: Option<crate::Text>,
79    pub(crate) kind: RelationKind<P>,
80    bind: BindFn<P>,
81    /// Why the relation's field binds nothing, when it does not.
82    pub(crate) misdeclared: Option<crate::DeclarationErrorKind>,
83}
84
85/// How a relation's child rows belong to the owner.
86pub(crate) enum RelationKind<P> {
87    /// Each child row holds the owner's key in this column, which a create from the table seeds.
88    HasMany { foreign_key: String },
89    /// The owner links child records through a join model, which the table attaches and detaches.
90    ManyToMany(Links<P>),
91}
92
93/// An owner's rows filter on the child.
94type BindFn<P> = Arc<dyn Fn(&P) -> (Expr<bool>, String) + Send + Sync>;
95
96/// A many-to-many relation's writes: link the child record a key names to an owner, or unlink
97/// the ones keys name.
98pub(crate) struct Links<P> {
99    /// The owner's `#[has_many(via = ..)]` field.
100    pub(crate) field: String,
101    /// The attach dialog's input: a choice of one child record.
102    pub(crate) input: InputSpec,
103    /// Links the record the key names, which the input's re-check proved a child record in
104    /// scope that the user may view; linking a linked record changes nothing. Answers the key as
105    /// the child's URLs spell it.
106    pub(crate) attach: LinkFn<P, String>,
107    /// Unlinks the records the keys name: 404 unless each is linked and in scope, 403 unless the
108    /// user may view each. Answers the keys as the children's URLs spell them.
109    pub(crate) detach: LinkFn<P, Vec<String>>,
110}
111
112/// A write of an owner's links, given what names the related records; answers those names as
113/// the related resource's URLs spell them.
114type LinkFn<P, K> = Arc<
115    dyn for<'a> Fn(&'a Cx, &'a P, K, &'a mut dyn toasty::Executor) -> BoxFuture<'a, Result<K>>
116        + Send
117        + Sync,
118>;
119
120/// The input key the attach dialog's choice posts.
121const ATTACH_KEY: &str = "record";
122
123/// A foreign-key column type referencing a primary key of type `K`: `K` itself, or `Option<K>`
124/// for an optional reference. Sealed: these are the only two.
125pub trait ForeignKey<K>:
126    sealed::Sealed<K> + IntoExpr<Self> + FormScalar + Send + Sync + 'static
127{
128}
129
130impl<K> ForeignKey<K> for K where K: IntoExpr<K> + FormScalar + Send + Sync + 'static {}
131
132impl<K> ForeignKey<K> for Option<K> where
133    Option<K>: IntoExpr<Self> + FormScalar + Send + Sync + 'static
134{
135}
136
137mod sealed {
138    pub trait Sealed<K> {}
139
140    impl<K> Sealed<K> for K {}
141
142    impl<K> Sealed<K> for Option<K> {}
143}
144
145impl<P> Relation<P>
146where
147    P: toasty::schema::Model + IntoExpr<P> + 'static,
148{
149    /// Declare a `has_many` child resource whose `foreign_key` holds the owner's primary key, a
150    /// single column.
151    ///
152    /// ```rust
153    /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
154    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
155    /// # struct Post {
156    /// #     #[key] #[auto] id: uuid::Uuid,
157    /// #     #[has_many]
158    /// #     comments: toasty::Deferred<Vec<Comment>>,
159    /// # }
160    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
161    /// # struct Comment {
162    /// #     #[key] #[auto] id: uuid::Uuid,
163    /// #     #[index]
164    /// #     post_id: uuid::Uuid,
165    /// #     #[belongs_to(key = post_id, references = id)]
166    /// #     post: toasty::Deferred<Post>,
167    /// # }
168    /// # struct PostResource;
169    /// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
170    /// # struct CommentResource;
171    /// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
172    /// let comments: Relation<Post> = Relation::has_many::<CommentResource>(Comment::fields().post_id());
173    /// ```
174    pub fn has_many<C>(foreign_key: Path<C::Model, impl ForeignKey<P::PrimaryKey>>) -> Self
175    where
176        C: Resource,
177    {
178        Self::bound_by::<C, _>(foreign_key)
179    }
180
181    fn bound_by<C, T>(foreign_key: Path<C::Model, T>) -> Self
182    where
183        C: Resource,
184        T: ForeignKey<P::PrimaryKey>,
185    {
186        // A foreign key is one column of the child: it binds against the child's model alone.
187        let (column, misdeclared) = match model::field::<C::Model>(&ModelPath::of(&foreign_key)) {
188            Ok(field) => (field.name, None),
189            Err(error) => (String::new(), Some(error)),
190        };
191        Self {
192            child: TypeId::of::<C>(),
193            child_name: std::any::type_name::<C>(),
194            label: None,
195            kind: RelationKind::HasMany {
196                foreign_key: column,
197            },
198            misdeclared,
199            bind: Arc::new(move |owner| {
200                let scope = foreign_key.clone().eq(pk::pk_expr::<P, T>(owner));
201                (scope, pk::pk_text(owner))
202            }),
203        }
204    }
205
206    /// Declare a many-to-many child resource whose records the owner links through `via`, its
207    /// `#[has_many(via = joins.target)]` field: the owner's linked records, which the table
208    /// attaches and detaches as join rows.
209    ///
210    /// ```rust
211    /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
212    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
213    /// # struct Post {
214    /// #     #[key] #[auto] id: uuid::Uuid,
215    /// #     #[has_many]
216    /// #     taggings: toasty::Deferred<Vec<Tagging>>,
217    /// #     #[has_many(via = taggings.tag)]
218    /// #     tags: toasty::Deferred<Vec<Tag>>,
219    /// # }
220    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
221    /// # struct Tag { #[key] #[auto] id: uuid::Uuid, name: String }
222    /// # #[derive(Debug, Clone, toasty::Model)]
223    /// # struct Tagging {
224    /// #     #[key] #[auto] id: uuid::Uuid,
225    /// #     #[index] post_id: uuid::Uuid,
226    /// #     #[belongs_to(key = post_id, references = id)]
227    /// #     post: toasty::Deferred<Post>,
228    /// #     #[index] tag_id: uuid::Uuid,
229    /// #     #[belongs_to(key = tag_id, references = id)]
230    /// #     tag: toasty::Deferred<Tag>,
231    /// # }
232    /// # struct TagResource;
233    /// # impl Resource for TagResource { type Model = Tag; type Form = NoForm<Tag>; }
234    /// let tags: Relation<Post> = Relation::belongs_to_many::<TagResource>(Post::fields().tags());
235    /// ```
236    ///
237    /// The section's header carries Attach, whose dialog asks for one record through a
238    /// searchable choice offered as a relationship's options are; attaching a linked record
239    /// changes nothing. Each row and the bulk bar carry Detach, which deletes the join rows and
240    /// leaves the records. Both post under the owner's record:
241    /// `{list}/{key}/-/relations/{slug}/-/actions/attach`, `…/-/actions/detach` with the
242    /// selection's `ids`, and `…/{related}/-/actions/detach` for one row. The owner's hooks
243    /// receive [`Change::Attach`](crate::Change::Attach) or
244    /// [`Change::Detach`](crate::Change::Detach).
245    ///
246    /// Attaching and detaching ask the owner's policy for `Update`, as editing it does: no button
247    /// renders without it, and the routes answer 403. A record the related resource's policy
248    /// refuses `View` is neither offered nor detached. Mounting refuses a join model a link
249    /// cannot write.
250    pub fn belongs_to_many<C>(via: impl Into<Path<P, List<C::Model>>>) -> Self
251    where
252        C: Resource,
253        P: Send + Sync,
254    {
255        let via: Path<P, List<C::Model>> = via.into();
256        let (field, misdeclared) = match model::field::<P>(&ModelPath::of(&via)) {
257            Ok(field) => (field.name, None),
258            Err(error) => (String::new(), Some(error)),
259        };
260        let attach_via = via.clone();
261        let attach_field = field.clone();
262        let detach_via = via.clone();
263        let detach_field = field.clone();
264        Self {
265            child: TypeId::of::<C>(),
266            child_name: std::any::type_name::<C>(),
267            label: None,
268            kind: RelationKind::ManyToMany(Links {
269                field,
270                input: InputSpec {
271                    schema: attach_schema::<C>,
272                    takes_input: true,
273                    parse: parse_attach,
274                },
275                attach: Arc::new(move |cx, owner, key, ex| {
276                    let (via, field) = (attach_via.clone(), attach_field.clone());
277                    Box::pin(async move { attach::<P, C>(cx, owner, via, &field, key, ex).await })
278                }),
279                detach: Arc::new(move |cx, owner, keys, ex| {
280                    let (via, field) = (detach_via.clone(), detach_field.clone());
281                    Box::pin(async move { detach::<P, C>(cx, owner, via, &field, keys, ex).await })
282                }),
283            }),
284            misdeclared,
285            bind: Arc::new(move |owner| (linked_to(owner, via.clone()), pk::pk_text(owner))),
286        }
287    }
288}
289
290/// The attach dialog's input: one searchable choice of `C`'s records.
291fn attach_schema<C: Resource>() -> Schema {
292    Schema::new(
293        Field::choice_input(ATTACH_KEY)
294            .label(crate::Text::model::<C::Model>())
295            .relationship::<C>()
296            .searchable(),
297    )
298}
299
300/// The key the attach dialog's choice posts: the schema's check proved it one of the choice's
301/// records.
302fn parse_attach(_cx: &Cx, values: &FormValues) -> InputResult {
303    match values.text(ATTACH_KEY).map(str::trim) {
304        Some(key) if !key.is_empty() => Ok(Box::new(key.to_string()) as ErasedInput),
305        _ => Err(vec![FieldError::required(ATTACH_KEY)]),
306    }
307}
308
309/// The join model behind `owner`'s `field`.
310fn join_table<P: toasty::schema::Model>(cx: &Cx, field: &str) -> Result<JoinTable> {
311    let schema = AppSchema::of(cx).ok_or_else(|| {
312        crate::error::declaration(crate::DeclarationErrorKind::MissingDb.to_string())
313    })?;
314    JoinTable::of::<P>(&schema, field).map_err(|fault| {
315        crate::error::declaration(
316            crate::DeclarationErrorKind::ManyToMany {
317                field: field.to_string(),
318                fault,
319            }
320            .to_string(),
321        )
322    })
323}
324
325/// Links `owner` to the record `key` names, unless they are linked already.
326async fn attach<P, C>(
327    cx: &Cx,
328    owner: &P,
329    via: Path<P, List<C::Model>>,
330    field: &str,
331    key: String,
332    ex: &mut dyn toasty::Executor,
333) -> Result<String>
334where
335    P: toasty::schema::Model + IntoExpr<P>,
336    C: Resource,
337{
338    let join = join_table::<P>(cx, field)?;
339    let (Some(key), Some(record)) = (
340        pk::canonical_key::<C::Model>(&key),
341        pk::pk_eq_expr::<C::Model>(&key),
342    ) else {
343        return Err(topcoat::router::error::not_found().into());
344    };
345    let linked = scoped_query::<C>(cx)?
346        .filter(record)
347        .filter(linked_to(owner, via))
348        .first()
349        .exec(&mut *ex)
350        .await
351        .map_err(crate::error::unavailable)?;
352    if linked.is_none() {
353        join.link(owner, std::slice::from_ref(&key), ex)
354            .await
355            .map_err(|error| -> topcoat::Error { error.into() })?;
356    }
357    Ok(key)
358}
359
360/// Unlinks `owner` from the records `keys` names.
361async fn detach<P, C>(
362    cx: &Cx,
363    owner: &P,
364    via: Path<P, List<C::Model>>,
365    field: &str,
366    keys: Vec<String>,
367    ex: &mut dyn toasty::Executor,
368) -> Result<Vec<String>>
369where
370    P: toasty::schema::Model + IntoExpr<P>,
371    C: Resource,
372{
373    let join = join_table::<P>(cx, field)?;
374    let ids: Vec<&str> = keys.iter().map(String::as_str).collect();
375    let Some(records) = pk::pk_in_expr::<C::Model>(&ids) else {
376        return Err(topcoat::router::error::not_found().into());
377    };
378    // A trashed record stays linked, and the relation table offers to unlink it.
379    let linked = scoped_query_in::<C>(cx, Trashed::With)?
380        .filter(records)
381        .filter(linked_to(owner, via))
382        .exec(&mut *ex)
383        .await
384        .map_err(crate::error::unavailable)?;
385    if linked.len() != keys.len() {
386        return Err(topcoat::router::error::not_found().into());
387    }
388    // A record the user cannot view cannot be unlinked by guessing its key.
389    if linked
390        .iter()
391        .any(|record| !can::<C>(cx, Ability::View(record)))
392    {
393        return Err(topcoat::router::error::forbidden().into());
394    }
395    join.unlink(owner, &keys, ex)
396        .await
397        .map_err(|error| -> topcoat::Error { error.into() })?;
398    Ok(linked.iter().map(pk::pk_text).collect())
399}
400
401impl<P> Relation<P> {
402    /// Titles the section `label` instead of the related resource's plural label.
403    #[must_use]
404    pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
405        self.label = Some(label.into());
406        self
407    }
408
409    /// The child's rows that belong to `owner`, and the owner's primary key as text.
410    pub(crate) fn bind(&self, owner: &P) -> (Expr<bool>, String) {
411        (self.bind)(owner)
412    }
413}
414
415impl<P> Relation<P> {
416    /// The relation's join-row writes, when it is a many-to-many one.
417    pub(crate) fn links(&self) -> Option<&Links<P>> {
418        match &self.kind {
419            RelationKind::ManyToMany(links) => Some(links),
420            RelationKind::HasMany { .. } => None,
421        }
422    }
423}
424
425impl<P> std::fmt::Debug for Relation<P> {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        let mut debug = f.debug_struct("Relation");
428        debug
429            .field("child", &self.child_name)
430            .field("label", &self.label);
431        match &self.kind {
432            RelationKind::HasMany { foreign_key } => debug.field("foreign_key", foreign_key),
433            RelationKind::ManyToMany(links) => debug.field("via", &links.field),
434        };
435        debug.finish_non_exhaustive()
436    }
437}