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 toasty::{Deferred, stmt::Path};
8use topcoat::context::Cx;
9
10use super::{Column, ColumnWidth, Includes};
11use crate::{DeclarationErrorKind, policy::Ability, schema::OptionSource, toasty_compat::model};
12
13/// A relation field's include, which loads it, paired with the function that reads the loaded
14/// relation off a record.
15///
16/// [`relation!`](crate::relation!) builds one from a single field name, so the column that reads
17/// the relation always declares its include:
18///
19/// ```rust
20/// # #[derive(Debug, Clone, toasty::Model)]
21/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
22/// # #[derive(Debug, Clone, toasty::Model)]
23/// # struct Post {
24/// # #[key] #[auto] id: uuid::Uuid,
25/// # author_id: uuid::Uuid,
26/// # #[belongs_to(key = author_id, references = id)]
27/// # author: toasty::Deferred<Author>,
28/// # }
29/// # use tablo_core::{RelationColumn, relation};
30/// RelationColumn::new(relation!(Post.author), |a: &Author| a.name.clone());
31/// ```
32pub struct RelationLens<M, R> {
33 includes: Includes<M>,
34 /// The relation field's name, or why the path names no single field.
35 field: Result<(String, String), DeclarationErrorKind>,
36 read: fn(&M) -> &R,
37}
38
39impl<M, R> RelationLens<M, R>
40where
41 M: toasty::schema::Model,
42{
43 /// Pair the relation `path` with the function reading the same field;
44 /// [`relation!`](crate::relation!) writes both from one field name.
45 #[doc(hidden)]
46 pub fn new<T>(path: impl Into<Path<M, T>>, read: fn(&M) -> &R) -> Self {
47 let path = path.into();
48 let field = model::field::<M>(&model::ModelPath::of(&path)).map(|f| (f.name, f.label));
49 Self {
50 includes: Includes::new().with(path),
51 field,
52 read,
53 }
54 }
55}
56
57impl<M, R> RelationLens<M, R> {
58 /// The include that loads the relation.
59 pub fn includes(&self) -> &Includes<M> {
60 &self.includes
61 }
62
63 /// Read the relation field off `record`.
64 pub fn read<'a>(&self, record: &'a M) -> &'a R {
65 (self.read)(record)
66 }
67
68 /// The field's name and label, or why the path names no single field.
69 pub(crate) fn field(&self) -> Result<(&str, &str), DeclarationErrorKind> {
70 self.field
71 .as_ref()
72 .map(|(name, label)| (name.as_str(), label.as_str()))
73 .map_err(Clone::clone)
74 }
75}
76
77impl<M, R> Clone for RelationLens<M, R> {
78 fn clone(&self) -> Self {
79 Self {
80 includes: self.includes.clone(),
81 field: self.field.clone(),
82 read: self.read,
83 }
84 }
85}
86
87impl<M, R> std::fmt::Debug for RelationLens<M, R> {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_tuple("RelationLens").field(&self.includes).finish()
90 }
91}
92
93/// Build a [`RelationLens`] from a model and one of its relation fields.
94///
95/// `relation!(Post.author)` is the include `Post::fields().author()` paired with
96/// `|post| &post.author`.
97///
98/// ```rust
99/// # use tablo_core::{RelationLens, relation};
100/// # use toasty::Deferred;
101/// # mod blog {
102/// # #[derive(Debug, Clone, toasty::Model)]
103/// # pub struct Author { #[key] #[auto] pub id: uuid::Uuid, pub name: String }
104/// # #[derive(Debug, Clone, toasty::Model)]
105/// # pub struct Post {
106/// # #[key] #[auto] pub id: uuid::Uuid,
107/// # pub author_id: uuid::Uuid,
108/// # #[belongs_to(key = author_id, references = id)]
109/// # pub author: toasty::Deferred<Author>,
110/// # #[has_many]
111/// # pub comments: toasty::Deferred<Vec<Comment>>,
112/// # }
113/// # #[derive(Debug, Clone, toasty::Model)]
114/// # pub struct Comment {
115/// # #[key] #[auto] pub id: uuid::Uuid,
116/// # #[index]
117/// # pub post_id: uuid::Uuid,
118/// # #[belongs_to(key = post_id, references = id)]
119/// # pub post: toasty::Deferred<Post>,
120/// # }
121/// # }
122/// # use blog::{Author, Comment, Post};
123/// # fn main() {
124/// let author: RelationLens<Post, Deferred<Author>> = relation!(Post.author);
125/// let comments: RelationLens<Post, Deferred<Vec<Comment>>> = relation!(Post.comments);
126/// let qualified = relation!(crate::blog::Post.author);
127/// # let _ = (author, comments, qualified);
128/// # }
129/// ```
130#[macro_export]
131macro_rules! relation {
132 ($($model:ident)::+ . $field:ident) => {
133 $crate::RelationLens::new(
134 <$($model)::+>::fields().$field(),
135 |record: &$($model)::+| &record.$field,
136 )
137 };
138}
139
140/// The text a relation column renders for a row whose relation was not loaded.
141const UNLOADED: &str = "(unloaded)";
142
143/// What a relation column reads off a row, or `None` when the row's relation was not loaded.
144type Loaded<M, T> = Arc<dyn Fn(&M) -> Option<T> + Send + Sync>;
145
146/// A label source's type name, and whether a context's panel can load from it.
147type LabelSource = (&'static str, fn(&Cx) -> bool);
148
149/// The text a relation column shows for a row, which a source's label reads in the request's
150/// context, or `None` when the row's relation was not loaded.
151type LoadedText<M> = Arc<dyn Fn(&Cx, &M) -> Option<String> + Send + Sync>;
152
153/// A to-one relation field to `T` a [`RelationColumn`] reads: `Deferred<T>`, or
154/// `Deferred<Option<T>>` for a nullable relation.
155///
156/// `T` is inferred from the field: `Deferred<X>` matches the first impl with `T = X` alone,
157/// since an `Option` is not a model, and the two impls cannot overlap, which would need
158/// `T = Option<T>`.
159pub trait ToOneRelation<T>: sealed::Sealed<T> {
160 /// The related record, `Some(None)` for a nullable relation holding none, or `None` when the
161 /// relation was not loaded.
162 #[doc(hidden)]
163 fn loaded(&self) -> Option<Option<&T>>;
164}
165
166mod sealed {
167 pub trait Sealed<T> {}
168
169 impl<T: toasty::schema::Model> Sealed<T> for toasty::Deferred<T> {}
170 impl<T: toasty::schema::Model> Sealed<T> for toasty::Deferred<Option<T>> {}
171}
172
173impl<T: toasty::schema::Model> ToOneRelation<T> for Deferred<T> {
174 fn loaded(&self) -> Option<Option<&T>> {
175 (!self.is_unloaded()).then(|| Some(self.get()))
176 }
177}
178
179impl<T: toasty::schema::Model> ToOneRelation<T> for Deferred<Option<T>> {
180 fn loaded(&self) -> Option<Option<&T>> {
181 (!self.is_unloaded()).then(|| self.get().as_ref())
182 }
183}
184
185/// A column showing the record of a `Deferred` `belongs_to` or `has_one` field as text, or the
186/// records of a `has_many` one ([`list`](Self::list)), which declares the field's include so every
187/// page that renders it loads them.
188///
189/// ```rust
190/// # #[derive(Debug, Clone, toasty::Model)]
191/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
192/// # #[derive(Debug, Clone, toasty::Model)]
193/// # struct Post {
194/// # #[key] #[auto] id: uuid::Uuid,
195/// # author_id: uuid::Uuid,
196/// # #[belongs_to(key = author_id, references = id)]
197/// # author: toasty::Deferred<Author>,
198/// # }
199/// use tablo_core::{RelationColumn, relation};
200///
201/// RelationColumn::new(relation!(Post.author), |a: &Author| a.name.clone());
202/// ```
203///
204/// The header and the column's name are the field's name, capitalized, until
205/// [`label`](Self::label) replaces both. A nullable field holding no record renders empty. The
206/// column neither searches nor sorts.
207///
208/// The include loads the related row through Toasty alone: the related resource's `query` and
209/// policy do not apply, so a soft-deleted or hidden record shows.
210pub struct RelationColumn<M> {
211 relation: Declared<M>,
212 project: LoadedText<M>,
213}
214
215impl<M> RelationColumn<M>
216where
217 M: toasty::schema::Model,
218{
219 /// Declare a column rendering `project` of the record `relation` loads.
220 pub fn new<T>(
221 relation: RelationLens<M, impl ToOneRelation<T> + 'static>,
222 project: impl Fn(&T) -> String + Send + Sync + 'static,
223 ) -> Self
224 where
225 M: 'static,
226 {
227 let read = relation.read;
228 Self {
229 relation: Declared::of(&relation, ColumnWidth::Wide),
230 project: Arc::new(move |_cx, row| {
231 read(row)
232 .loaded()
233 .map(|target| target.map(&project).unwrap_or_default())
234 }),
235 }
236 }
237
238 /// Declare a column showing the record `relation` loads by `S`'s
239 /// [`label`](OptionSource::label): for a resource, the
240 /// [`record_title`](crate::ResourceDef::record_title) its detail page is titled with.
241 ///
242 /// ```rust
243 /// # #[derive(Debug, Clone, toasty::Model)]
244 /// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
245 /// # #[derive(Debug, Clone, toasty::Model)]
246 /// # struct Post {
247 /// # #[key] #[auto] id: uuid::Uuid,
248 /// # author_id: uuid::Uuid,
249 /// # #[belongs_to(key = author_id, references = id)]
250 /// # author: toasty::Deferred<Author>,
251 /// # }
252 /// # struct AuthorResource;
253 /// # impl tablo_core::extend::OptionSource for AuthorResource {
254 /// # type Model = Author;
255 /// # fn scoped_query(_cx: &topcoat::context::Cx)
256 /// # -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Author>>>
257 /// # {
258 /// # Ok(toasty::stmt::Query::all())
259 /// # }
260 /// # fn label(_cx: &topcoat::context::Cx, author: &Author) -> String {
261 /// # author.name.clone()
262 /// # }
263 /// # }
264 /// use tablo_core::{RelationColumn, relation};
265 ///
266 /// RelationColumn::of::<AuthorResource>(relation!(Post.author));
267 /// ```
268 pub fn of<S>(relation: RelationLens<M, impl ToOneRelation<S::Model> + 'static>) -> Self
269 where
270 S: OptionSource,
271 M: 'static,
272 {
273 let read = relation.read;
274 Self {
275 relation: Declared {
276 source: Some((std::any::type_name::<S>(), S::available)),
277 ..Declared::of(&relation, ColumnWidth::Wide)
278 },
279 project: Arc::new(move |cx, row| {
280 read(row).loaded().map(|target| {
281 target
282 .map(|target| S::label(cx, target))
283 .unwrap_or_default()
284 })
285 }),
286 }
287 }
288
289 /// Declare a column listing the records a `has_many` `relation` loads, a many-to-many
290 /// `#[has_many(via = ..)]` one included, each by `S`'s [`label`](OptionSource::label), joined
291 /// by commas, but the ones `S` does not let the user view. A record form's many-to-many field
292 /// shows on its derived detail page this way.
293 ///
294 /// ```rust
295 /// # #[derive(Debug, Clone, toasty::Model)]
296 /// # struct Post {
297 /// # #[key] #[auto] id: uuid::Uuid,
298 /// # #[has_many]
299 /// # taggings: toasty::Deferred<Vec<Tagging>>,
300 /// # #[has_many(via = taggings.tag)]
301 /// # tags: toasty::Deferred<Vec<Tag>>,
302 /// # }
303 /// # #[derive(Debug, Clone, toasty::Model)]
304 /// # struct Tag { #[key] #[auto] id: uuid::Uuid, name: String }
305 /// # #[derive(Debug, Clone, toasty::Model)]
306 /// # struct Tagging {
307 /// # #[key] #[auto] id: uuid::Uuid,
308 /// # #[index] post_id: uuid::Uuid,
309 /// # #[belongs_to(key = post_id, references = id)]
310 /// # post: toasty::Deferred<Post>,
311 /// # #[index] tag_id: uuid::Uuid,
312 /// # #[belongs_to(key = tag_id, references = id)]
313 /// # tag: toasty::Deferred<Tag>,
314 /// # }
315 /// # struct TagResource;
316 /// # impl tablo_core::extend::OptionSource for TagResource {
317 /// # type Model = Tag;
318 /// # fn scoped_query(_cx: &topcoat::context::Cx)
319 /// # -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Tag>>>
320 /// # {
321 /// # Ok(toasty::stmt::Query::all())
322 /// # }
323 /// # fn label(_cx: &topcoat::context::Cx, tag: &Tag) -> String {
324 /// # tag.name.clone()
325 /// # }
326 /// # fn allows(_cx: &topcoat::context::Cx, _: tablo_core::Ability<'_, Tag>) -> bool {
327 /// # true
328 /// # }
329 /// # }
330 /// use tablo_core::{RelationColumn, relation};
331 ///
332 /// RelationColumn::list::<TagResource>(relation!(Post.tags));
333 /// ```
334 pub fn list<S>(relation: RelationLens<M, Deferred<Vec<S::Model>>>) -> Self
335 where
336 S: OptionSource,
337 M: 'static,
338 {
339 let read = relation.read;
340 Self {
341 relation: Declared {
342 source: Some((std::any::type_name::<S>(), S::available)),
343 ..Declared::of(&relation, ColumnWidth::Wide)
344 },
345 project: Arc::new(move |cx, row| {
346 let records = read(row);
347 (!records.is_unloaded()).then(|| {
348 records
349 .get()
350 .iter()
351 .filter(|record| S::allows(cx, Ability::View(record)))
352 .map(|record| S::label(cx, record))
353 .collect::<Vec<_>>()
354 .join(", ")
355 })
356 }),
357 }
358 }
359
360 /// Replace the label the field's name gives it, and name the column after the label, as a
361 /// [`ComputedColumn`](super::ComputedColumn) is: two columns over one relation need two labels.
362 pub fn label(mut self, label: impl Into<String>) -> Self {
363 self.relation.relabel(label.into());
364 self
365 }
366
367 /// Declare this column's width.
368 pub fn width(mut self, width: ColumnWidth) -> Self {
369 self.relation.width = width;
370 self
371 }
372}
373
374impl<M> RelationColumn<M> {
375 /// The row's text, or `None` when its relation was not loaded.
376 fn loaded_text(&self, cx: &Cx, row: &M) -> Option<String> {
377 (self.project)(cx, row)
378 }
379}
380
381/// A column counting the records of a `Deferred` `has_many` field, which declares the field's
382/// include so every page that renders it loads the records.
383///
384/// ```rust
385/// # #[derive(Debug, Clone, toasty::Model)]
386/// # struct Post {
387/// # #[key] #[auto] id: uuid::Uuid,
388/// # #[has_many]
389/// # comments: toasty::Deferred<Vec<Comment>>,
390/// # }
391/// # #[derive(Debug, Clone, toasty::Model)]
392/// # struct Comment {
393/// # #[key] #[auto] id: uuid::Uuid,
394/// # #[index]
395/// # post_id: uuid::Uuid,
396/// # #[belongs_to(key = post_id, references = id)]
397/// # post: toasty::Deferred<Post>,
398/// # }
399/// use tablo_core::{CountColumn, relation};
400///
401/// CountColumn::new(relation!(Post.comments));
402/// ```
403///
404/// The header and the column's name are the field's name, capitalized, until
405/// [`label`](Self::label) replaces both.
406///
407/// The count is the length of the loaded list: a page loads every related record of every row it
408/// counts, and the related resource's `query` and policy do not apply, so the count includes
409/// records its own list hides.
410pub struct CountColumn<M> {
411 relation: Declared<M>,
412 count: Loaded<M, usize>,
413}
414
415impl<M> CountColumn<M>
416where
417 M: toasty::schema::Model,
418{
419 /// Declare a column counting the records `relation` loads.
420 pub fn new<T>(relation: RelationLens<M, Deferred<Vec<T>>>) -> Self
421 where
422 T: toasty::schema::Model + 'static,
423 M: 'static,
424 {
425 let read = relation.read;
426 Self {
427 relation: Declared::of(&relation, ColumnWidth::Narrow),
428 count: Arc::new(move |row| {
429 let records = read(row);
430 (!records.is_unloaded()).then(|| records.get().len())
431 }),
432 }
433 }
434
435 /// Replace the label the field's name gives it, and name the column after the label, as a
436 /// [`ComputedColumn`](super::ComputedColumn) is: two columns over one relation need two labels.
437 pub fn label(mut self, label: impl Into<String>) -> Self {
438 self.relation.relabel(label.into());
439 self
440 }
441
442 /// Declare this column's width.
443 pub fn width(mut self, width: ColumnWidth) -> Self {
444 self.relation.width = width;
445 self
446 }
447}
448
449impl<M> CountColumn<M> {
450 /// The row's count, or `None` when its relation was not loaded.
451 fn loaded_text(&self, _cx: &Cx, row: &M) -> Option<String> {
452 (self.count)(row).map(|count| count.to_string())
453 }
454}
455
456/// What both relation-field columns declare: the name and label, the include and the width.
457struct Declared<M> {
458 name: String,
459 /// The declared label, over the field's.
460 label: Option<String>,
461 field_label: String,
462 misdeclared: Option<DeclarationErrorKind>,
463 includes: Includes<M>,
464 width: ColumnWidth,
465 /// The source a [`RelationColumn::of`] labels by, and whether a context's panel can load
466 /// from it.
467 source: Option<LabelSource>,
468}
469
470impl<M> Declared<M> {
471 fn of<R>(relation: &RelationLens<M, R>, width: ColumnWidth) -> Self {
472 let (name, field_label, misdeclared) = match relation.field() {
473 Ok((name, label)) => (name.to_string(), label.to_string(), None),
474 Err(error) => (String::new(), String::new(), Some(error)),
475 };
476 Self {
477 name,
478 label: None,
479 field_label,
480 misdeclared,
481 includes: relation.includes().clone(),
482 width,
483 source: None,
484 }
485 }
486
487 fn label(&self) -> &str {
488 self.label.as_deref().unwrap_or(&self.field_label)
489 }
490
491 fn relabel(&mut self, label: String) {
492 self.name = label.to_lowercase();
493 self.label = Some(label);
494 }
495
496 /// The text of a row's loaded relation, or the unloaded marker when a loader skipped the
497 /// include this column declares.
498 fn text(&self, loaded: Option<String>) -> String {
499 debug_assert!(
500 loaded.is_some(),
501 "the `{}` column declares its include, but its row was loaded without it",
502 self.name
503 );
504 loaded.unwrap_or_else(|| UNLOADED.to_string())
505 }
506}
507
508impl<M> Clone for Declared<M> {
509 fn clone(&self) -> Self {
510 Self {
511 name: self.name.clone(),
512 label: self.label.clone(),
513 field_label: self.field_label.clone(),
514 misdeclared: self.misdeclared.clone(),
515 includes: self.includes.clone(),
516 width: self.width,
517 source: self.source,
518 }
519 }
520}
521
522/// The [`Column`], `Clone` and `Debug` impls both relation columns share.
523macro_rules! relation_column {
524 ($ty:ident, $read:ident) => {
525 impl<M> Column<M> for $ty<M>
526 where
527 M: toasty::schema::Model + Send + Sync + 'static,
528 {
529 fn name(&self) -> &str {
530 &self.relation.name
531 }
532
533 fn label(&self) -> &str {
534 self.relation.label()
535 }
536
537 fn text(&self, cx: &Cx, row: &M) -> String {
538 self.relation.text(self.loaded_text(cx, row))
539 }
540
541 fn column_width(&self) -> ColumnWidth {
542 self.relation.width
543 }
544
545 fn includes(&self) -> Includes<M> {
546 self.relation.includes.clone()
547 }
548
549 fn misdeclared(&self) -> Option<DeclarationErrorKind> {
550 self.relation.misdeclared.clone()
551 }
552
553 fn unavailable_source(&self, cx: &Cx) -> Option<&'static str> {
554 self.relation
555 .source
556 .filter(|(_, available)| !available(cx))
557 .map(|(source, _)| source)
558 }
559 }
560
561 impl<M> Clone for $ty<M> {
562 fn clone(&self) -> Self {
563 Self {
564 relation: self.relation.clone(),
565 $read: Arc::clone(&self.$read),
566 }
567 }
568 }
569
570 impl<M> std::fmt::Debug for $ty<M> {
571 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572 f.debug_struct(stringify!($ty))
573 .field("name", &self.relation.name)
574 .field("label", &self.relation.label())
575 .field("width", &self.relation.width)
576 .finish_non_exhaustive()
577 }
578 }
579 };
580}
581
582relation_column!(RelationColumn, project);
583relation_column!(CountColumn, count);
584
585#[cfg(test)]
586mod tests;