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