Skip to main content

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