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 and its policy lets the user view, as a
109/// relationship choice over `S` offers them. A related record it hides, soft-deleted or another
110/// tenant's, renders empty, and so does every record of a row loaded around the table's or the
111/// [`Detail`](crate::Detail)'s loader, which checks them. The row keeps what the include loaded,
112/// so a policy reading the relation sees it whole. [`unscoped`](Self::unscoped) shows whatever
113/// the include loads.
114///
115/// A field [`field_access`](crate::ResourceDef::field_access) restricts shows to a user only
116/// where the user may view it, on every resource the panel mounts over its model: a request that
117/// may not view a field [`new`](Self::new) reads finds no such column. A record's
118/// [`label`](OptionSource::label) is its title, which the mount keeps clear of every restricted
119/// field, or its key, which no field access may hide.
120#[derive_where(Clone, Debug)]
121pub struct RelationColumn<M> {
122    relation: Declared<M>,
123    #[derive_where(skip(Debug))]
124    project: LoadedText<M>,
125}
126
127impl<M> RelationColumn<M>
128where
129    M: toasty::schema::Model + Send + Sync + 'static,
130{
131    /// Declare a column rendering `project` of the record `relation` loads, when `S` shows it.
132    /// `reads` names the fields of the record `project` reads, a [`lens!`](crate::lens!) or a
133    /// [`Reads`]: a request `S` hides one of them from finds no such column.
134    pub fn new<S>(
135        relation: RelationLens<M, impl ToOneRelation<S::Model> + 'static>,
136        reads: impl Into<Reads<S::Model>>,
137        project: impl Fn(&S::Model) -> String + Send + Sync + 'static,
138    ) -> Self
139    where
140        S: OptionSource,
141    {
142        let read = relation.read;
143        let reads = reads.into();
144        Self {
145            relation: Declared::of(&relation, ColumnWidth::Grow)
146                .scoped::<S>(restrict_one::<M, S, _>(relation.read))
147                .masked(Arc::new(move |cx| reads.reads_any(&S::hidden_fields(cx)))),
148            project: Arc::new(move |cx, row| {
149                read(row).loaded().map(|target| {
150                    target
151                        .filter(|target| shows::<S>(cx, target))
152                        .map(&project)
153                        .unwrap_or_default()
154                })
155            }),
156        }
157    }
158
159    /// Declare a column showing the record `relation` loads by `S`'s
160    /// [`label`](OptionSource::label), when `S` shows it: for a resource, the
161    /// [`record_title`](crate::ResourceDef::record_title) its detail page is titled with.
162    ///
163    /// ```rust
164    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
165    /// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
166    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
167    /// # struct Post {
168    /// #     #[key] #[auto] id: uuid::Uuid,
169    /// #     author_id: uuid::Uuid,
170    /// #     #[belongs_to(key = author_id, references = id)]
171    /// #     author: toasty::Deferred<Author>,
172    /// # }
173    /// # struct AuthorResource;
174    /// # impl tablo_core::extend::OptionSource for AuthorResource {
175    /// #     type Model = Author;
176    /// #     fn scoped_query(_cx: &topcoat::context::Cx)
177    /// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Author>>>
178    /// #     {
179    /// #         Ok(toasty::stmt::Query::all())
180    /// #     }
181    /// #     fn label(_cx: &topcoat::context::Cx, author: &Author) -> String {
182    /// #         author.name.clone()
183    /// #     }
184    /// # }
185    /// use tablo_core::{RelationColumn, relation};
186    ///
187    /// RelationColumn::of::<AuthorResource>(relation!(Post.author));
188    /// ```
189    pub fn of<S>(relation: RelationLens<M, impl ToOneRelation<S::Model> + 'static>) -> Self
190    where
191        S: OptionSource,
192    {
193        let read = relation.read;
194        Self {
195            relation: Declared::of(&relation, ColumnWidth::Grow)
196                .scoped::<S>(restrict_one::<M, S, _>(relation.read)),
197            project: Arc::new(move |cx, row| {
198                read(row).loaded().map(|target| {
199                    target
200                        .filter(|target| shows::<S>(cx, target))
201                        .map(|target| S::label(cx, target))
202                        .unwrap_or_default()
203                })
204            }),
205        }
206    }
207
208    /// Declare a column listing the records a `has_many` `relation` loads that `S` shows, a
209    /// many-to-many `#[has_many(via = ..)]` one included, each by `S`'s
210    /// [`label`](OptionSource::label), joined by commas.
211    ///
212    /// ```rust
213    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
214    /// # struct Post {
215    /// #     #[key] #[auto] id: uuid::Uuid,
216    /// #     #[has_many]
217    /// #     taggings: toasty::Deferred<Vec<Tagging>>,
218    /// #     #[has_many(via = taggings.tag)]
219    /// #     tags: toasty::Deferred<Vec<Tag>>,
220    /// # }
221    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
222    /// # struct Tag { #[key] #[auto] id: uuid::Uuid, name: String }
223    /// # #[derive(Debug, Clone, toasty::Model)]
224    /// # struct Tagging {
225    /// #     #[key] #[auto] id: uuid::Uuid,
226    /// #     #[index] post_id: uuid::Uuid,
227    /// #     #[belongs_to(key = post_id, references = id)]
228    /// #     post: toasty::Deferred<Post>,
229    /// #     #[index] tag_id: uuid::Uuid,
230    /// #     #[belongs_to(key = tag_id, references = id)]
231    /// #     tag: toasty::Deferred<Tag>,
232    /// # }
233    /// # struct TagResource;
234    /// # impl tablo_core::extend::OptionSource for TagResource {
235    /// #     type Model = Tag;
236    /// #     fn scoped_query(_cx: &topcoat::context::Cx)
237    /// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Tag>>>
238    /// #     {
239    /// #         Ok(toasty::stmt::Query::all())
240    /// #     }
241    /// #     fn label(_cx: &topcoat::context::Cx, tag: &Tag) -> String {
242    /// #         tag.name.clone()
243    /// #     }
244    /// #     fn allows(_cx: &topcoat::context::Cx, _: tablo_core::Ability<'_, Tag>) -> bool {
245    /// #         true
246    /// #     }
247    /// # }
248    /// use tablo_core::{RelationColumn, relation};
249    ///
250    /// RelationColumn::list::<TagResource>(relation!(Post.tags));
251    /// ```
252    pub fn list<S>(relation: RelationLens<M, Deferred<Vec<S::Model>>>) -> Self
253    where
254        S: OptionSource,
255    {
256        let read = relation.read;
257        Self {
258            relation: Declared::of(&relation, ColumnWidth::Grow)
259                .scoped::<S>(restrict_many::<M, S>(relation.read)),
260            project: Arc::new(move |cx, row| {
261                let records = read(row);
262                (!records.is_unloaded()).then(|| {
263                    records
264                        .get()
265                        .iter()
266                        .filter(|record| shows::<S>(cx, record))
267                        .map(|record| S::label(cx, record))
268                        .collect::<Vec<_>>()
269                        .join(", ")
270                })
271            }),
272        }
273    }
274
275    /// Declare a column rendering `project` of whatever record `relation` loads, under no
276    /// resource's query, policy or field access: for a related model no resource administers. A
277    /// panel mounting a resource that restricts a field of the related model refuses it. A row
278    /// loaded without the column's include renders `"(unloaded)"` and fails a debug assertion.
279    pub fn unscoped<T>(
280        relation: RelationLens<M, impl ToOneRelation<T> + 'static>,
281        project: impl Fn(&T) -> String + Send + Sync + 'static,
282    ) -> Self {
283        let read = relation.read;
284        Self {
285            relation: Declared::of(&relation, ColumnWidth::Grow),
286            project: Arc::new(move |_cx, row| {
287                read(row)
288                    .loaded()
289                    .map(|target| target.map(&project).unwrap_or_default())
290            }),
291        }
292    }
293}
294
295impl<M> RelationColumn<M> {
296    /// The row's text, or `None` when its relation was not loaded.
297    fn loaded_text(&self, cx: &Cx, row: &M) -> Option<String> {
298        (self.project)(cx, row)
299    }
300}
301
302/// A column counting the records of a `Deferred` `has_many` field that a source `S` shows, which
303/// declares the field's include so every page that renders it loads the records.
304///
305/// ```rust
306/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
307/// # struct Post {
308/// #     #[key] #[auto] id: uuid::Uuid,
309/// #     #[has_many]
310/// #     comments: toasty::Deferred<Vec<Comment>>,
311/// # }
312/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
313/// # struct Comment {
314/// #     #[key] #[auto] id: uuid::Uuid,
315/// #     #[index]
316/// #     post_id: uuid::Uuid,
317/// #     #[belongs_to(key = post_id, references = id)]
318/// #     post: toasty::Deferred<Post>,
319/// # }
320/// # struct CommentResource;
321/// # impl tablo_core::extend::OptionSource for CommentResource {
322/// #     type Model = Comment;
323/// #     fn scoped_query(_cx: &topcoat::context::Cx)
324/// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Comment>>>
325/// #     {
326/// #         Ok(toasty::stmt::Query::all())
327/// #     }
328/// #     fn label(_cx: &topcoat::context::Cx, comment: &Comment) -> String {
329/// #         comment.id.to_string()
330/// #     }
331/// # }
332/// use tablo_core::{CountColumn, relation};
333///
334/// CountColumn::of::<CommentResource>(relation!(Post.comments));
335/// ```
336///
337/// The header and the column's name are the field's name, capitalized, until
338/// [`label`](Self::label) replaces both.
339///
340/// The count is how many of the loaded records `S` shows the user, which [`RelationColumn`]
341/// decides the same way: a page loads every related record of every row it counts.
342/// [`unscoped`](Self::unscoped) counts every record the include loads.
343#[derive_where(Clone, Debug)]
344pub struct CountColumn<M> {
345    relation: Declared<M>,
346    #[derive_where(skip(Debug))]
347    count: Loaded<M, usize>,
348}
349
350impl<M> CountColumn<M>
351where
352    M: toasty::schema::Model + Send + Sync + 'static,
353{
354    /// Declare a column counting the records `relation` loads that `S` shows.
355    pub fn of<S>(relation: RelationLens<M, Deferred<Vec<S::Model>>>) -> Self
356    where
357        S: OptionSource,
358    {
359        Self {
360            relation: Declared::of(&relation, ColumnWidth::Auto)
361                .scoped::<S>(restrict_many::<M, S>(relation.read))
362                .fieldless(),
363            count: count(relation.read, shows::<S>),
364        }
365    }
366
367    /// Declare a column counting every record `relation` loads, under no resource's query or
368    /// policy: for a related model no resource administers. A count shows no field of the
369    /// records.
370    pub fn unscoped<T>(relation: RelationLens<M, Deferred<Vec<T>>>) -> Self
371    where
372        T: toasty::schema::Model + 'static,
373    {
374        Self {
375            relation: Declared::of(&relation, ColumnWidth::Auto).fieldless(),
376            count: count(relation.read, |_, _| true),
377        }
378    }
379}
380
381impl<M> CountColumn<M> {
382    /// The row's count, or `None` when its relation was not loaded.
383    fn loaded_text(&self, cx: &Cx, row: &M) -> Option<String> {
384        (self.count)(cx, row).map(|count| count.to_string())
385    }
386}
387
388/// How many records of the list `read` reads off a row `shown` accepts, or `None` when it was not
389/// loaded.
390fn count<M: 'static, T: 'static>(
391    read: fn(&M) -> &Deferred<Vec<T>>,
392    shown: fn(&Cx, &T) -> bool,
393) -> Loaded<M, usize> {
394    Arc::new(move |cx, row| {
395        let records = read(row);
396        (!records.is_unloaded()).then(|| records.get().iter().filter(|r| shown(cx, r)).count())
397    })
398}
399
400/// What both relation-field columns declare: the name and label, the include and the width.
401#[derive_where(Clone, Debug)]
402struct Declared<M> {
403    name: String,
404    /// The declared label, over the field's.
405    label: Option<crate::Text>,
406    field_label: crate::Text,
407    misdeclared: Option<DeclarationErrorKind>,
408    includes: Includes<M>,
409    reads: crate::lens::Reads<M>,
410    width: ColumnWidth,
411    toggle: ColumnToggle,
412    /// The source that decides which related records show, and whether a context's panel can
413    /// load from it.
414    source: Option<LabelSource>,
415    /// Drops the related records the source does not show; `None` for an unscoped column.
416    #[derive_where(skip(Debug))]
417    restrict: Option<Restrict<M>>,
418    /// What the column shows of the related records, which a panel checks at mount.
419    related: Related,
420    /// Whether a request may not view a field the column shows; `None` for one showing only a
421    /// record's label or count.
422    #[derive_where(skip(Debug))]
423    masked: Option<Masked>,
424}
425
426impl<M> Declared<M> {
427    fn of<R>(relation: &RelationLens<M, R>, width: ColumnWidth) -> Self {
428        let (name, field_label, misdeclared) = match relation.field() {
429            Ok((name, _)) => (name.to_string(), crate::Text::field(name), None),
430            Err(error) => (String::new(), crate::Text::from(""), Some(error)),
431        };
432        Self {
433            name,
434            label: None,
435            field_label,
436            misdeclared,
437            includes: relation.includes().clone(),
438            reads: relation.reads(),
439            width,
440            toggle: ColumnToggle::Fixed,
441            source: None,
442            restrict: None,
443            related: Related::Unmasked,
444            masked: None,
445        }
446    }
447
448    /// Show only the related records `S` shows, which `restrict` keeps, by their label or count
449    /// until [`masked`](Self::masked) says what else shows.
450    fn scoped<S: OptionSource>(self, restrict: Restrict<M>) -> Self {
451        Self {
452            source: Some((std::any::type_name::<S>(), S::available)),
453            restrict: Some(restrict),
454            related: Related::Source(std::any::TypeId::of::<S>()),
455            ..self
456        }
457    }
458
459    /// Show the fields of the related records `masked` hides from a request it refuses.
460    fn masked(self, masked: Masked) -> Self {
461        Self {
462            masked: Some(masked),
463            ..self
464        }
465    }
466
467    /// Show no field of the related records.
468    fn fieldless(self) -> Self {
469        Self {
470            related: Related::None,
471            ..self
472        }
473    }
474
475    fn label(&self) -> crate::Text {
476        self.label
477            .clone()
478            .unwrap_or_else(|| self.field_label.clone())
479    }
480
481    fn relabel(&mut self, label: crate::Text) {
482        self.name = label.key();
483        self.label = Some(label);
484    }
485
486    /// The text of a row's loaded relation. A scoped column renders empty when its row holds no
487    /// record, which is how a required related record the source hides reads. An unscoped one
488    /// renders the unloaded marker, since a loader skipped the include it declares.
489    fn text(&self, loaded: Option<String>) -> String {
490        if self.restrict.is_some() {
491            return loaded.unwrap_or_default();
492        }
493        debug_assert!(
494            loaded.is_some(),
495            "the `{}` column declares its include, but its row was loaded without it",
496            self.name
497        );
498        loaded.unwrap_or_else(|| UNLOADED.to_string())
499    }
500}
501
502/// The builders and the [`Column`] impl both relation columns share.
503macro_rules! relation_column {
504    ($ty:ident) => {
505        impl<M> $ty<M> {
506            /// Replace the label the field's name gives it, and name the column after the label,
507            /// as a [`ComputedColumn`](super::ComputedColumn) is: two columns over one relation
508            /// need two labels.
509            pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
510                self.relation.relabel(label.into());
511                self
512            }
513
514            /// Declare this column's width.
515            pub fn width(mut self, width: ColumnWidth) -> Self {
516                self.relation.width = width;
517                self
518            }
519
520            toggle_builders!(relation.toggle);
521        }
522
523        impl<M> Column<M> for $ty<M>
524        where
525            M: toasty::schema::Model + Send + Sync + 'static,
526        {
527            fn name(&self) -> &str {
528                &self.relation.name
529            }
530
531            fn label(&self) -> crate::Text {
532                self.relation.label()
533            }
534
535            fn text(&self, cx: &Cx, row: &M) -> String {
536                self.relation.text(self.loaded_text(cx, row))
537            }
538
539            fn column_width(&self) -> ColumnWidth {
540                self.relation.width
541            }
542
543            fn toggle(&self) -> ColumnToggle {
544                self.relation.toggle
545            }
546
547            fn includes(&self) -> Includes<M> {
548                self.relation.includes.clone()
549            }
550
551            fn reads(&self) -> Option<crate::lens::Reads<M>> {
552                Some(self.relation.reads.clone())
553            }
554
555            fn restrict<'a>(
556                &'a self,
557                cx: &'a Cx,
558                rows: &'a [M],
559                ex: &'a mut dyn Executor,
560            ) -> BoxFuture<'a, Result<()>> {
561                match &self.relation.restrict {
562                    Some(restrict) => restrict(cx, rows, ex),
563                    None => Box::pin(async { Ok(()) }),
564                }
565            }
566
567            fn masked(&self, cx: &Cx) -> bool {
568                self.relation
569                    .masked
570                    .as_ref()
571                    .is_some_and(|masked| masked(cx))
572            }
573
574            fn related(&self) -> Related {
575                self.relation.related
576            }
577
578            fn misdeclared(&self) -> Option<DeclarationErrorKind> {
579                self.relation.misdeclared.clone()
580            }
581
582            fn unavailable_source(&self, cx: &Cx) -> Option<&'static str> {
583                self.relation
584                    .source
585                    .filter(|(_, available)| !available(cx))
586                    .map(|(source, _)| source)
587            }
588        }
589    };
590}
591
592relation_column!(RelationColumn);
593relation_column!(CountColumn);
594
595#[cfg(test)]
596mod tests;