Skip to main content

tablo_core/table/column/
relation.rs

1//! [`RelationColumn`] and [`CountColumn`]: columns over a relation field, which load the relation
2//! they read, and the [`RelationLens`] that names the field, built by
3//! [`relation!`](crate::relation!).
4
5use std::sync::Arc;
6
7use toasty::{Deferred, stmt::Path};
8use topcoat::context::Cx;
9
10use super::{Column, ColumnWidth, Includes};
11use crate::{DeclarationErrorKind, policy::Ability, schema::OptionSource, toasty_compat::model};
12
13/// A relation field's include, which loads it, paired with the function that reads the loaded
14/// relation off a record.
15///
16/// [`relation!`](crate::relation!) builds one from a single field name, so the column that reads
17/// the relation always declares its include:
18///
19/// ```rust
20/// # #[derive(Debug, Clone, toasty::Model)]
21/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
22/// # #[derive(Debug, Clone, toasty::Model)]
23/// # struct Post {
24/// #     #[key] #[auto] id: uuid::Uuid,
25/// #     author_id: uuid::Uuid,
26/// #     #[belongs_to(key = author_id, references = id)]
27/// #     author: toasty::Deferred<Author>,
28/// # }
29/// # use tablo_core::{RelationColumn, relation};
30/// RelationColumn::new(relation!(Post.author), |a: &Author| a.name.clone());
31/// ```
32pub struct RelationLens<M, R> {
33    includes: Includes<M>,
34    /// The relation field's name, or why the path names no single field.
35    field: Result<(String, String), DeclarationErrorKind>,
36    read: fn(&M) -> &R,
37}
38
39impl<M, R> RelationLens<M, R>
40where
41    M: toasty::schema::Model,
42{
43    /// Pair the relation `path` with the function reading the same field;
44    /// [`relation!`](crate::relation!) writes both from one field name.
45    #[doc(hidden)]
46    pub fn new<T>(path: impl Into<Path<M, T>>, read: fn(&M) -> &R) -> Self {
47        let path = path.into();
48        let field = model::field::<M>(&model::ModelPath::of(&path)).map(|f| (f.name, f.label));
49        Self {
50            includes: Includes::new().with(path),
51            field,
52            read,
53        }
54    }
55}
56
57impl<M, R> RelationLens<M, R> {
58    /// The include that loads the relation.
59    pub fn includes(&self) -> &Includes<M> {
60        &self.includes
61    }
62
63    /// Read the relation field off `record`.
64    pub fn read<'a>(&self, record: &'a M) -> &'a R {
65        (self.read)(record)
66    }
67
68    /// The field's name and label, or why the path names no single field.
69    pub(crate) fn field(&self) -> Result<(&str, &str), DeclarationErrorKind> {
70        self.field
71            .as_ref()
72            .map(|(name, label)| (name.as_str(), label.as_str()))
73            .map_err(Clone::clone)
74    }
75}
76
77impl<M, R> Clone for RelationLens<M, R> {
78    fn clone(&self) -> Self {
79        Self {
80            includes: self.includes.clone(),
81            field: self.field.clone(),
82            read: self.read,
83        }
84    }
85}
86
87impl<M, R> std::fmt::Debug for RelationLens<M, R> {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_tuple("RelationLens").field(&self.includes).finish()
90    }
91}
92
93/// Build a [`RelationLens`] from a model and one of its relation fields.
94///
95/// `relation!(Post.author)` is the include `Post::fields().author()` paired with
96/// `|post| &post.author`.
97///
98/// ```rust
99/// # use tablo_core::{RelationLens, relation};
100/// # use toasty::Deferred;
101/// # mod blog {
102/// #     #[derive(Debug, Clone, toasty::Model)]
103/// #     pub struct Author { #[key] #[auto] pub id: uuid::Uuid, pub name: String }
104/// #     #[derive(Debug, Clone, toasty::Model)]
105/// #     pub struct Post {
106/// #         #[key] #[auto] pub id: uuid::Uuid,
107/// #         pub author_id: uuid::Uuid,
108/// #         #[belongs_to(key = author_id, references = id)]
109/// #         pub author: toasty::Deferred<Author>,
110/// #         #[has_many]
111/// #         pub comments: toasty::Deferred<Vec<Comment>>,
112/// #     }
113/// #     #[derive(Debug, Clone, toasty::Model)]
114/// #     pub struct Comment {
115/// #         #[key] #[auto] pub id: uuid::Uuid,
116/// #         #[index]
117/// #         pub post_id: uuid::Uuid,
118/// #         #[belongs_to(key = post_id, references = id)]
119/// #         pub post: toasty::Deferred<Post>,
120/// #     }
121/// # }
122/// # use blog::{Author, Comment, Post};
123/// # fn main() {
124/// let author: RelationLens<Post, Deferred<Author>> = relation!(Post.author);
125/// let comments: RelationLens<Post, Deferred<Vec<Comment>>> = relation!(Post.comments);
126/// let qualified = relation!(crate::blog::Post.author);
127/// # let _ = (author, comments, qualified);
128/// # }
129/// ```
130#[macro_export]
131macro_rules! relation {
132    ($($model:ident)::+ . $field:ident) => {
133        $crate::RelationLens::new(
134            <$($model)::+>::fields().$field(),
135            |record: &$($model)::+| &record.$field,
136        )
137    };
138}
139
140/// The text a relation column renders for a row whose relation was not loaded.
141const UNLOADED: &str = "(unloaded)";
142
143/// What a relation column reads off a row, or `None` when the row's relation was not loaded.
144type Loaded<M, T> = Arc<dyn Fn(&M) -> Option<T> + Send + Sync>;
145
146/// A label source's type name, and whether a context's panel can load from it.
147type LabelSource = (&'static str, fn(&Cx) -> bool);
148
149/// The text a relation column shows for a row, which a source's label reads in the request's
150/// context, or `None` when the row's relation was not loaded.
151type LoadedText<M> = Arc<dyn Fn(&Cx, &M) -> Option<String> + Send + Sync>;
152
153/// A to-one relation field to `T` a [`RelationColumn`] reads: `Deferred<T>`, or
154/// `Deferred<Option<T>>` for a nullable relation.
155///
156/// `T` is inferred from the field: `Deferred<X>` matches the first impl with `T = X` alone,
157/// since an `Option` is not a model, and the two impls cannot overlap, which would need
158/// `T = Option<T>`.
159pub trait ToOneRelation<T>: sealed::Sealed<T> {
160    /// The related record, `Some(None)` for a nullable relation holding none, or `None` when the
161    /// relation was not loaded.
162    #[doc(hidden)]
163    fn loaded(&self) -> Option<Option<&T>>;
164}
165
166mod sealed {
167    pub trait Sealed<T> {}
168
169    impl<T: toasty::schema::Model> Sealed<T> for toasty::Deferred<T> {}
170    impl<T: toasty::schema::Model> Sealed<T> for toasty::Deferred<Option<T>> {}
171}
172
173impl<T: toasty::schema::Model> ToOneRelation<T> for Deferred<T> {
174    fn loaded(&self) -> Option<Option<&T>> {
175        (!self.is_unloaded()).then(|| Some(self.get()))
176    }
177}
178
179impl<T: toasty::schema::Model> ToOneRelation<T> for Deferred<Option<T>> {
180    fn loaded(&self) -> Option<Option<&T>> {
181        (!self.is_unloaded()).then(|| self.get().as_ref())
182    }
183}
184
185/// A column showing the record of a `Deferred` `belongs_to` or `has_one` field as text, or the
186/// records of a `has_many` one ([`list`](Self::list)), which declares the field's include so every
187/// page that renders it loads them.
188///
189/// ```rust
190/// # #[derive(Debug, Clone, toasty::Model)]
191/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
192/// # #[derive(Debug, Clone, toasty::Model)]
193/// # struct Post {
194/// #     #[key] #[auto] id: uuid::Uuid,
195/// #     author_id: uuid::Uuid,
196/// #     #[belongs_to(key = author_id, references = id)]
197/// #     author: toasty::Deferred<Author>,
198/// # }
199/// use tablo_core::{RelationColumn, relation};
200///
201/// RelationColumn::new(relation!(Post.author), |a: &Author| a.name.clone());
202/// ```
203///
204/// The header and the column's name are the field's name, capitalized, until
205/// [`label`](Self::label) replaces both. A nullable field holding no record renders empty. The
206/// column neither searches nor sorts.
207///
208/// The include loads the related row through Toasty alone: the related resource's `query` and
209/// policy do not apply, so a soft-deleted or hidden record shows.
210pub struct RelationColumn<M> {
211    relation: Declared<M>,
212    project: LoadedText<M>,
213}
214
215impl<M> RelationColumn<M>
216where
217    M: toasty::schema::Model,
218{
219    /// Declare a column rendering `project` of the record `relation` loads.
220    pub fn new<T>(
221        relation: RelationLens<M, impl ToOneRelation<T> + 'static>,
222        project: impl Fn(&T) -> String + Send + Sync + 'static,
223    ) -> Self
224    where
225        M: 'static,
226    {
227        let read = relation.read;
228        Self {
229            relation: Declared::of(&relation, ColumnWidth::Wide),
230            project: Arc::new(move |_cx, row| {
231                read(row)
232                    .loaded()
233                    .map(|target| target.map(&project).unwrap_or_default())
234            }),
235        }
236    }
237
238    /// Declare a column showing the record `relation` loads by `S`'s
239    /// [`label`](OptionSource::label): for a resource, the
240    /// [`record_title`](crate::ResourceDef::record_title) its detail page is titled with.
241    ///
242    /// ```rust
243    /// # #[derive(Debug, Clone, toasty::Model)]
244    /// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
245    /// # #[derive(Debug, Clone, toasty::Model)]
246    /// # struct Post {
247    /// #     #[key] #[auto] id: uuid::Uuid,
248    /// #     author_id: uuid::Uuid,
249    /// #     #[belongs_to(key = author_id, references = id)]
250    /// #     author: toasty::Deferred<Author>,
251    /// # }
252    /// # struct AuthorResource;
253    /// # impl tablo_core::extend::OptionSource for AuthorResource {
254    /// #     type Model = Author;
255    /// #     fn scoped_query(_cx: &topcoat::context::Cx)
256    /// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Author>>>
257    /// #     {
258    /// #         Ok(toasty::stmt::Query::all())
259    /// #     }
260    /// #     fn label(_cx: &topcoat::context::Cx, author: &Author) -> String {
261    /// #         author.name.clone()
262    /// #     }
263    /// # }
264    /// use tablo_core::{RelationColumn, relation};
265    ///
266    /// RelationColumn::of::<AuthorResource>(relation!(Post.author));
267    /// ```
268    pub fn of<S>(relation: RelationLens<M, impl ToOneRelation<S::Model> + 'static>) -> Self
269    where
270        S: OptionSource,
271        M: 'static,
272    {
273        let read = relation.read;
274        Self {
275            relation: Declared {
276                source: Some((std::any::type_name::<S>(), S::available)),
277                ..Declared::of(&relation, ColumnWidth::Wide)
278            },
279            project: Arc::new(move |cx, row| {
280                read(row).loaded().map(|target| {
281                    target
282                        .map(|target| S::label(cx, target))
283                        .unwrap_or_default()
284                })
285            }),
286        }
287    }
288
289    /// Declare a column listing the records a `has_many` `relation` loads, a many-to-many
290    /// `#[has_many(via = ..)]` one included, each by `S`'s [`label`](OptionSource::label), joined
291    /// by commas, but the ones `S` does not let the user view. A record form's many-to-many field
292    /// shows on its derived detail page this way.
293    ///
294    /// ```rust
295    /// # #[derive(Debug, Clone, toasty::Model)]
296    /// # struct Post {
297    /// #     #[key] #[auto] id: uuid::Uuid,
298    /// #     #[has_many]
299    /// #     taggings: toasty::Deferred<Vec<Tagging>>,
300    /// #     #[has_many(via = taggings.tag)]
301    /// #     tags: toasty::Deferred<Vec<Tag>>,
302    /// # }
303    /// # #[derive(Debug, Clone, toasty::Model)]
304    /// # struct Tag { #[key] #[auto] id: uuid::Uuid, name: String }
305    /// # #[derive(Debug, Clone, toasty::Model)]
306    /// # struct Tagging {
307    /// #     #[key] #[auto] id: uuid::Uuid,
308    /// #     #[index] post_id: uuid::Uuid,
309    /// #     #[belongs_to(key = post_id, references = id)]
310    /// #     post: toasty::Deferred<Post>,
311    /// #     #[index] tag_id: uuid::Uuid,
312    /// #     #[belongs_to(key = tag_id, references = id)]
313    /// #     tag: toasty::Deferred<Tag>,
314    /// # }
315    /// # struct TagResource;
316    /// # impl tablo_core::extend::OptionSource for TagResource {
317    /// #     type Model = Tag;
318    /// #     fn scoped_query(_cx: &topcoat::context::Cx)
319    /// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Tag>>>
320    /// #     {
321    /// #         Ok(toasty::stmt::Query::all())
322    /// #     }
323    /// #     fn label(_cx: &topcoat::context::Cx, tag: &Tag) -> String {
324    /// #         tag.name.clone()
325    /// #     }
326    /// #     fn allows(_cx: &topcoat::context::Cx, _: tablo_core::Ability<'_, Tag>) -> bool {
327    /// #         true
328    /// #     }
329    /// # }
330    /// use tablo_core::{RelationColumn, relation};
331    ///
332    /// RelationColumn::list::<TagResource>(relation!(Post.tags));
333    /// ```
334    pub fn list<S>(relation: RelationLens<M, Deferred<Vec<S::Model>>>) -> Self
335    where
336        S: OptionSource,
337        M: 'static,
338    {
339        let read = relation.read;
340        Self {
341            relation: Declared {
342                source: Some((std::any::type_name::<S>(), S::available)),
343                ..Declared::of(&relation, ColumnWidth::Wide)
344            },
345            project: Arc::new(move |cx, row| {
346                let records = read(row);
347                (!records.is_unloaded()).then(|| {
348                    records
349                        .get()
350                        .iter()
351                        .filter(|record| S::allows(cx, Ability::View(record)))
352                        .map(|record| S::label(cx, record))
353                        .collect::<Vec<_>>()
354                        .join(", ")
355                })
356            }),
357        }
358    }
359
360    /// Replace the label the field's name gives it, and name the column after the label, as a
361    /// [`ComputedColumn`](super::ComputedColumn) is: two columns over one relation need two labels.
362    pub fn label(mut self, label: impl Into<String>) -> Self {
363        self.relation.relabel(label.into());
364        self
365    }
366
367    /// Declare this column's width.
368    pub fn width(mut self, width: ColumnWidth) -> Self {
369        self.relation.width = width;
370        self
371    }
372}
373
374impl<M> RelationColumn<M> {
375    /// The row's text, or `None` when its relation was not loaded.
376    fn loaded_text(&self, cx: &Cx, row: &M) -> Option<String> {
377        (self.project)(cx, row)
378    }
379}
380
381/// A column counting the records of a `Deferred` `has_many` field, which declares the field's
382/// include so every page that renders it loads the records.
383///
384/// ```rust
385/// # #[derive(Debug, Clone, toasty::Model)]
386/// # struct Post {
387/// #     #[key] #[auto] id: uuid::Uuid,
388/// #     #[has_many]
389/// #     comments: toasty::Deferred<Vec<Comment>>,
390/// # }
391/// # #[derive(Debug, Clone, toasty::Model)]
392/// # struct Comment {
393/// #     #[key] #[auto] id: uuid::Uuid,
394/// #     #[index]
395/// #     post_id: uuid::Uuid,
396/// #     #[belongs_to(key = post_id, references = id)]
397/// #     post: toasty::Deferred<Post>,
398/// # }
399/// use tablo_core::{CountColumn, relation};
400///
401/// CountColumn::new(relation!(Post.comments));
402/// ```
403///
404/// The header and the column's name are the field's name, capitalized, until
405/// [`label`](Self::label) replaces both.
406///
407/// The count is the length of the loaded list: a page loads every related record of every row it
408/// counts, and the related resource's `query` and policy do not apply, so the count includes
409/// records its own list hides.
410pub struct CountColumn<M> {
411    relation: Declared<M>,
412    count: Loaded<M, usize>,
413}
414
415impl<M> CountColumn<M>
416where
417    M: toasty::schema::Model,
418{
419    /// Declare a column counting the records `relation` loads.
420    pub fn new<T>(relation: RelationLens<M, Deferred<Vec<T>>>) -> Self
421    where
422        T: toasty::schema::Model + 'static,
423        M: 'static,
424    {
425        let read = relation.read;
426        Self {
427            relation: Declared::of(&relation, ColumnWidth::Narrow),
428            count: Arc::new(move |row| {
429                let records = read(row);
430                (!records.is_unloaded()).then(|| records.get().len())
431            }),
432        }
433    }
434
435    /// Replace the label the field's name gives it, and name the column after the label, as a
436    /// [`ComputedColumn`](super::ComputedColumn) is: two columns over one relation need two labels.
437    pub fn label(mut self, label: impl Into<String>) -> Self {
438        self.relation.relabel(label.into());
439        self
440    }
441
442    /// Declare this column's width.
443    pub fn width(mut self, width: ColumnWidth) -> Self {
444        self.relation.width = width;
445        self
446    }
447}
448
449impl<M> CountColumn<M> {
450    /// The row's count, or `None` when its relation was not loaded.
451    fn loaded_text(&self, _cx: &Cx, row: &M) -> Option<String> {
452        (self.count)(row).map(|count| count.to_string())
453    }
454}
455
456/// What both relation-field columns declare: the name and label, the include and the width.
457struct Declared<M> {
458    name: String,
459    /// The declared label, over the field's.
460    label: Option<String>,
461    field_label: String,
462    misdeclared: Option<DeclarationErrorKind>,
463    includes: Includes<M>,
464    width: ColumnWidth,
465    /// The source a [`RelationColumn::of`] labels by, and whether a context's panel can load
466    /// from it.
467    source: Option<LabelSource>,
468}
469
470impl<M> Declared<M> {
471    fn of<R>(relation: &RelationLens<M, R>, width: ColumnWidth) -> Self {
472        let (name, field_label, misdeclared) = match relation.field() {
473            Ok((name, label)) => (name.to_string(), label.to_string(), None),
474            Err(error) => (String::new(), String::new(), Some(error)),
475        };
476        Self {
477            name,
478            label: None,
479            field_label,
480            misdeclared,
481            includes: relation.includes().clone(),
482            width,
483            source: None,
484        }
485    }
486
487    fn label(&self) -> &str {
488        self.label.as_deref().unwrap_or(&self.field_label)
489    }
490
491    fn relabel(&mut self, label: String) {
492        self.name = label.to_lowercase();
493        self.label = Some(label);
494    }
495
496    /// The text of a row's loaded relation, or the unloaded marker when a loader skipped the
497    /// include this column declares.
498    fn text(&self, loaded: Option<String>) -> String {
499        debug_assert!(
500            loaded.is_some(),
501            "the `{}` column declares its include, but its row was loaded without it",
502            self.name
503        );
504        loaded.unwrap_or_else(|| UNLOADED.to_string())
505    }
506}
507
508impl<M> Clone for Declared<M> {
509    fn clone(&self) -> Self {
510        Self {
511            name: self.name.clone(),
512            label: self.label.clone(),
513            field_label: self.field_label.clone(),
514            misdeclared: self.misdeclared.clone(),
515            includes: self.includes.clone(),
516            width: self.width,
517            source: self.source,
518        }
519    }
520}
521
522/// The [`Column`], `Clone` and `Debug` impls both relation columns share.
523macro_rules! relation_column {
524    ($ty:ident, $read:ident) => {
525        impl<M> Column<M> for $ty<M>
526        where
527            M: toasty::schema::Model + Send + Sync + 'static,
528        {
529            fn name(&self) -> &str {
530                &self.relation.name
531            }
532
533            fn label(&self) -> &str {
534                self.relation.label()
535            }
536
537            fn text(&self, cx: &Cx, row: &M) -> String {
538                self.relation.text(self.loaded_text(cx, row))
539            }
540
541            fn column_width(&self) -> ColumnWidth {
542                self.relation.width
543            }
544
545            fn includes(&self) -> Includes<M> {
546                self.relation.includes.clone()
547            }
548
549            fn misdeclared(&self) -> Option<DeclarationErrorKind> {
550                self.relation.misdeclared.clone()
551            }
552
553            fn unavailable_source(&self, cx: &Cx) -> Option<&'static str> {
554                self.relation
555                    .source
556                    .filter(|(_, available)| !available(cx))
557                    .map(|(source, _)| source)
558            }
559        }
560
561        impl<M> Clone for $ty<M> {
562            fn clone(&self) -> Self {
563                Self {
564                    relation: self.relation.clone(),
565                    $read: Arc::clone(&self.$read),
566                }
567            }
568        }
569
570        impl<M> std::fmt::Debug for $ty<M> {
571            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572                f.debug_struct(stringify!($ty))
573                    .field("name", &self.relation.name)
574                    .field("label", &self.relation.label())
575                    .field("width", &self.relation.width)
576                    .finish_non_exhaustive()
577            }
578        }
579    };
580}
581
582relation_column!(RelationColumn, project);
583relation_column!(CountColumn, count);
584
585#[cfg(test)]
586mod tests;