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;