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;