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::{ErasedInput, InputResult, InputSpec, Resource, can, scoped_query};
9use crate::{
10    BoxFuture,
11    form::{FieldError, FormScalar, FormValues},
12    naming::{sentence_case, type_short_name},
13    policy::Ability,
14    schema::{Field, Schema},
15    toasty_compat::{
16        join::{JoinTable, linked_to},
17        model::{self, AppSchema, ModelPath},
18        pk,
19    },
20};
21
22/// One relation of a parent resource's records.
23///
24/// Declared by [`ResourceDef::relation`](super::ResourceDef::relation); the related resource
25/// must be registered on the same panel, which keys the relation by its slug:
26///
27/// ```rust
28/// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
29/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
30/// # struct Post {
31/// #     #[key] #[auto] id: uuid::Uuid,
32/// #     #[has_many]
33/// #     comments: toasty::Deferred<Vec<Comment>>,
34/// # }
35/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
36/// # struct Comment {
37/// #     #[key] #[auto] id: uuid::Uuid,
38/// #     #[index]
39/// #     post_id: uuid::Uuid,
40/// #     #[belongs_to(key = post_id, references = id)]
41/// #     post: toasty::Deferred<Post>,
42/// # }
43/// # struct PostResource;
44/// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
45/// # struct CommentResource;
46/// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
47/// let def: ResourceDef<PostResource> =
48///     ResourceDef::new().relation(Relation::has_many::<CommentResource>(Comment::fields().post_id()));
49/// ```
50pub struct Relation<P> {
51    pub(crate) child: TypeId,
52    pub(crate) child_name: &'static str,
53    pub(crate) label: Option<String>,
54    pub(crate) kind: RelationKind<P>,
55    bind: BindFn<P>,
56    /// Why the relation's field binds nothing, when it does not.
57    pub(crate) misdeclared: Option<crate::DeclarationErrorKind>,
58}
59
60/// How a relation's child rows belong to the owner.
61pub(crate) enum RelationKind<P> {
62    /// Each child row holds the owner's key in this column, which a create from the table seeds.
63    HasMany { foreign_key: String },
64    /// The owner links child records through a join model, which the table attaches and detaches.
65    ManyToMany(Links<P>),
66}
67
68/// An owner's rows filter on the child.
69type BindFn<P> = Arc<dyn Fn(&P) -> (Expr<bool>, String) + Send + Sync>;
70
71/// A many-to-many relation's writes: link the child record a key names to an owner, or unlink
72/// the ones keys name.
73pub(crate) struct Links<P> {
74    /// The owner's `#[has_many(via = ..)]` field.
75    pub(crate) field: String,
76    /// The attach dialog's input: a choice of one child record.
77    pub(crate) input: InputSpec,
78    /// Links the record the key names, which the input's re-check proved a child record in
79    /// scope that the user may view; linking a linked record changes nothing.
80    pub(crate) attach: LinkFn<P, String>,
81    /// Unlinks the records the keys name: 404 unless each is linked and in scope, 403 unless the
82    /// user may view each.
83    pub(crate) detach: LinkFn<P, Vec<String>>,
84}
85
86/// A write of an owner's links, given what names the related records.
87type LinkFn<P, K> = Arc<
88    dyn for<'a> Fn(&'a Cx, &'a P, K, &'a mut dyn toasty::Executor) -> BoxFuture<'a, Result<()>>
89        + Send
90        + Sync,
91>;
92
93/// The input key the attach dialog's choice posts.
94const ATTACH_KEY: &str = "record";
95
96/// A foreign-key column type referencing a primary key of type `K`: `K` itself, or `Option<K>`
97/// for an optional reference. Sealed: these are the only two.
98pub trait ForeignKey<K>:
99    sealed::Sealed<K> + IntoExpr<Self> + FormScalar + Send + Sync + 'static
100{
101}
102
103impl<K> ForeignKey<K> for K where K: IntoExpr<K> + FormScalar + Send + Sync + 'static {}
104
105impl<K> ForeignKey<K> for Option<K> where
106    Option<K>: IntoExpr<Self> + FormScalar + Send + Sync + 'static
107{
108}
109
110mod sealed {
111    pub trait Sealed<K> {}
112
113    impl<K> Sealed<K> for K {}
114
115    impl<K> Sealed<K> for Option<K> {}
116}
117
118impl<P> Relation<P>
119where
120    P: toasty::schema::Model + IntoExpr<P> + 'static,
121{
122    /// Declare a `has_many` child resource whose `foreign_key` holds the owner's primary key, a
123    /// single column.
124    ///
125    /// ```rust
126    /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
127    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
128    /// # struct Post {
129    /// #     #[key] #[auto] id: uuid::Uuid,
130    /// #     #[has_many]
131    /// #     comments: toasty::Deferred<Vec<Comment>>,
132    /// # }
133    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
134    /// # struct Comment {
135    /// #     #[key] #[auto] id: uuid::Uuid,
136    /// #     #[index]
137    /// #     post_id: uuid::Uuid,
138    /// #     #[belongs_to(key = post_id, references = id)]
139    /// #     post: toasty::Deferred<Post>,
140    /// # }
141    /// # struct PostResource;
142    /// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
143    /// # struct CommentResource;
144    /// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
145    /// let comments: Relation<Post> = Relation::has_many::<CommentResource>(Comment::fields().post_id());
146    /// ```
147    pub fn has_many<C>(foreign_key: Path<C::Model, impl ForeignKey<P::PrimaryKey>>) -> Self
148    where
149        C: Resource,
150    {
151        Self::bound_by::<C, _>(foreign_key)
152    }
153
154    fn bound_by<C, T>(foreign_key: Path<C::Model, T>) -> Self
155    where
156        C: Resource,
157        T: ForeignKey<P::PrimaryKey>,
158    {
159        // A foreign key is one column of the child: it binds against the child's model alone.
160        let (column, misdeclared) = match model::field::<C::Model>(&ModelPath::of(&foreign_key)) {
161            Ok(field) => (field.name, None),
162            Err(error) => (String::new(), Some(error)),
163        };
164        Self {
165            child: TypeId::of::<C>(),
166            child_name: std::any::type_name::<C>(),
167            label: None,
168            kind: RelationKind::HasMany {
169                foreign_key: column,
170            },
171            misdeclared,
172            bind: Arc::new(move |owner| {
173                let scope = foreign_key.clone().eq(pk::pk_expr::<P, T>(owner));
174                (scope, pk::pk_text(owner))
175            }),
176        }
177    }
178
179    /// Declare a many-to-many child resource whose records the owner links through `via`, its
180    /// `#[has_many(via = joins.target)]` field: the owner's linked records, which the table
181    /// attaches and detaches as join rows.
182    ///
183    /// ```rust
184    /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
185    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
186    /// # struct Post {
187    /// #     #[key] #[auto] id: uuid::Uuid,
188    /// #     #[has_many]
189    /// #     taggings: toasty::Deferred<Vec<Tagging>>,
190    /// #     #[has_many(via = taggings.tag)]
191    /// #     tags: toasty::Deferred<Vec<Tag>>,
192    /// # }
193    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
194    /// # struct Tag { #[key] #[auto] id: uuid::Uuid, name: String }
195    /// # #[derive(Debug, Clone, toasty::Model)]
196    /// # struct Tagging {
197    /// #     #[key] #[auto] id: uuid::Uuid,
198    /// #     #[index] post_id: uuid::Uuid,
199    /// #     #[belongs_to(key = post_id, references = id)]
200    /// #     post: toasty::Deferred<Post>,
201    /// #     #[index] tag_id: uuid::Uuid,
202    /// #     #[belongs_to(key = tag_id, references = id)]
203    /// #     tag: toasty::Deferred<Tag>,
204    /// # }
205    /// # struct TagResource;
206    /// # impl Resource for TagResource { type Model = Tag; type Form = NoForm<Tag>; }
207    /// let tags: Relation<Post> = Relation::belongs_to_many::<TagResource>(Post::fields().tags());
208    /// ```
209    ///
210    /// Attaching and detaching ask the owner's policy for `Update`, as editing it does, and the
211    /// related resource's for `View` on each record; mounting refuses a join model a link cannot
212    /// write.
213    pub fn belongs_to_many<C>(via: impl Into<Path<P, List<C::Model>>>) -> Self
214    where
215        C: Resource,
216        P: Send + Sync,
217    {
218        let via: Path<P, List<C::Model>> = via.into();
219        let (field, misdeclared) = match model::field::<P>(&ModelPath::of(&via)) {
220            Ok(field) => (field.name, None),
221            Err(error) => (String::new(), Some(error)),
222        };
223        let attach_via = via.clone();
224        let attach_field = field.clone();
225        let detach_via = via.clone();
226        let detach_field = field.clone();
227        Self {
228            child: TypeId::of::<C>(),
229            child_name: std::any::type_name::<C>(),
230            label: None,
231            kind: RelationKind::ManyToMany(Links {
232                field,
233                input: InputSpec {
234                    schema: attach_schema::<C>,
235                    takes_input: true,
236                    parse: parse_attach,
237                },
238                attach: Arc::new(move |cx, owner, key, ex| {
239                    let (via, field) = (attach_via.clone(), attach_field.clone());
240                    Box::pin(async move { attach::<P, C>(cx, owner, via, &field, key, ex).await })
241                }),
242                detach: Arc::new(move |cx, owner, keys, ex| {
243                    let (via, field) = (detach_via.clone(), detach_field.clone());
244                    Box::pin(async move { detach::<P, C>(cx, owner, via, &field, keys, ex).await })
245                }),
246            }),
247            misdeclared,
248            bind: Arc::new(move |owner| (linked_to(owner, via.clone()), pk::pk_text(owner))),
249        }
250    }
251}
252
253/// The attach dialog's input: one searchable choice of `C`'s records.
254fn attach_schema<C: Resource>() -> Schema {
255    Schema::new(
256        Field::choice_input(ATTACH_KEY)
257            .label(sentence_case(type_short_name::<C::Model>()))
258            .relationship::<C>()
259            .searchable(),
260    )
261}
262
263/// The key the attach dialog's choice posts: the schema's check proved it one of the choice's
264/// records.
265fn parse_attach(_cx: &Cx, values: &FormValues) -> InputResult {
266    match values.text(ATTACH_KEY).map(str::trim) {
267        Some(key) if !key.is_empty() => Ok(Box::new(key.to_string()) as ErasedInput),
268        _ => Err(vec![FieldError::required(ATTACH_KEY)]),
269    }
270}
271
272/// The join model behind `owner`'s `field`.
273fn join_table<P: toasty::schema::Model>(cx: &Cx, field: &str) -> Result<JoinTable> {
274    let schema = AppSchema::of(cx).ok_or_else(|| {
275        crate::error::declaration(crate::DeclarationErrorKind::MissingDb.to_string())
276    })?;
277    JoinTable::of::<P>(&schema, field).map_err(|fault| {
278        crate::error::declaration(
279            crate::DeclarationErrorKind::ManyToMany {
280                field: field.to_string(),
281                fault,
282            }
283            .to_string(),
284        )
285    })
286}
287
288/// Links `owner` to the record `key` names, unless they are linked already.
289async fn attach<P, C>(
290    cx: &Cx,
291    owner: &P,
292    via: Path<P, List<C::Model>>,
293    field: &str,
294    key: String,
295    ex: &mut dyn toasty::Executor,
296) -> Result<()>
297where
298    P: toasty::schema::Model + IntoExpr<P>,
299    C: Resource,
300{
301    let join = join_table::<P>(cx, field)?;
302    let Some(record) = pk::pk_eq_expr::<C::Model>(&key) else {
303        return Err(topcoat::router::error::not_found().into());
304    };
305    let linked = scoped_query::<C>(cx)?
306        .filter(record)
307        .filter(linked_to(owner, via))
308        .first()
309        .exec(&mut *ex)
310        .await
311        .map_err(crate::error::unavailable)?;
312    if linked.is_none() {
313        join.link(owner, &[key], ex)
314            .await
315            .map_err(|error| -> topcoat::Error { error.into() })?;
316    }
317    Ok(())
318}
319
320/// Unlinks `owner` from the records `keys` names.
321async fn detach<P, C>(
322    cx: &Cx,
323    owner: &P,
324    via: Path<P, List<C::Model>>,
325    field: &str,
326    keys: Vec<String>,
327    ex: &mut dyn toasty::Executor,
328) -> Result<()>
329where
330    P: toasty::schema::Model + IntoExpr<P>,
331    C: Resource,
332{
333    let join = join_table::<P>(cx, field)?;
334    let ids: Vec<&str> = keys.iter().map(String::as_str).collect();
335    let Some(records) = pk::pk_in_expr::<C::Model>(&ids) else {
336        return Err(topcoat::router::error::not_found().into());
337    };
338    let linked = scoped_query::<C>(cx)?
339        .filter(records)
340        .filter(linked_to(owner, via))
341        .exec(&mut *ex)
342        .await
343        .map_err(crate::error::unavailable)?;
344    if linked.len() != keys.len() {
345        return Err(topcoat::router::error::not_found().into());
346    }
347    // A record the user cannot view cannot be unlinked by guessing its key.
348    if linked
349        .iter()
350        .any(|record| !can::<C>(cx, Ability::View(record)))
351    {
352        return Err(topcoat::router::error::forbidden().into());
353    }
354    join.unlink(owner, &keys, ex)
355        .await
356        .map_err(|error| -> topcoat::Error { error.into() })
357}
358
359impl<P> Relation<P> {
360    /// Titles the section `label` instead of the related resource's plural label.
361    #[must_use]
362    pub fn label(mut self, label: impl Into<String>) -> Self {
363        self.label = Some(label.into());
364        self
365    }
366
367    /// The child's rows that belong to `owner`, and the owner's primary key as text.
368    pub(crate) fn bind(&self, owner: &P) -> (Expr<bool>, String) {
369        (self.bind)(owner)
370    }
371}
372
373impl<P> Relation<P> {
374    /// The relation's join-row writes, when it is a many-to-many one.
375    pub(crate) fn links(&self) -> Option<&Links<P>> {
376        match &self.kind {
377            RelationKind::ManyToMany(links) => Some(links),
378            RelationKind::HasMany { .. } => None,
379        }
380    }
381}
382
383impl<P> std::fmt::Debug for Relation<P> {
384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385        let mut debug = f.debug_struct("Relation");
386        debug
387            .field("child", &self.child_name)
388            .field("label", &self.label);
389        match &self.kind {
390            RelationKind::HasMany { foreign_key } => debug.field("foreign_key", foreign_key),
391            RelationKind::ManyToMany(links) => debug.field("via", &links.field),
392        };
393        debug.finish_non_exhaustive()
394    }
395}