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;