tablo_core/resource/relation.rs
1//! Relation tables: a record page's related rows through the related resource's own list table.
2
3use std::{any::TypeId, sync::Arc};
4
5use toasty::stmt::{Expr, IntoExpr, List, Path};
6use topcoat::{Result, context::Cx};
7
8use super::{ErasedInput, InputResult, InputSpec, Resource, can, scoped_query};
9use crate::{
10 BoxFuture,
11 form::{FieldError, FormScalar, FormValues},
12 naming::{sentence_case, type_short_name},
13 policy::Ability,
14 schema::{Field, Schema},
15 toasty_compat::{
16 join::{JoinTable, linked_to},
17 model::{self, AppSchema, ModelPath},
18 pk,
19 },
20};
21
22/// One relation of a parent resource's records.
23///
24/// Declared by [`ResourceDef::relation`](super::ResourceDef::relation); the related resource
25/// must be registered on the same panel, which keys the relation by its slug:
26///
27/// ```rust
28/// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
29/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
30/// # struct Post {
31/// # #[key] #[auto] id: uuid::Uuid,
32/// # #[has_many]
33/// # comments: toasty::Deferred<Vec<Comment>>,
34/// # }
35/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
36/// # struct Comment {
37/// # #[key] #[auto] id: uuid::Uuid,
38/// # #[index]
39/// # post_id: uuid::Uuid,
40/// # #[belongs_to(key = post_id, references = id)]
41/// # post: toasty::Deferred<Post>,
42/// # }
43/// # struct PostResource;
44/// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
45/// # struct CommentResource;
46/// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
47/// let def: ResourceDef<PostResource> =
48/// ResourceDef::new().relation(Relation::has_many::<CommentResource>(Comment::fields().post_id()));
49/// ```
50pub struct Relation<P> {
51 pub(crate) child: TypeId,
52 pub(crate) child_name: &'static str,
53 pub(crate) label: Option<String>,
54 pub(crate) kind: RelationKind<P>,
55 bind: BindFn<P>,
56 /// Why the relation's field binds nothing, when it does not.
57 pub(crate) misdeclared: Option<crate::DeclarationErrorKind>,
58}
59
60/// How a relation's child rows belong to the owner.
61pub(crate) enum RelationKind<P> {
62 /// Each child row holds the owner's key in this column, which a create from the table seeds.
63 HasMany { foreign_key: String },
64 /// The owner links child records through a join model, which the table attaches and detaches.
65 ManyToMany(Links<P>),
66}
67
68/// An owner's rows filter on the child.
69type BindFn<P> = Arc<dyn Fn(&P) -> (Expr<bool>, String) + Send + Sync>;
70
71/// A many-to-many relation's writes: link the child record a key names to an owner, or unlink
72/// the ones keys name.
73pub(crate) struct Links<P> {
74 /// The owner's `#[has_many(via = ..)]` field.
75 pub(crate) field: String,
76 /// The attach dialog's input: a choice of one child record.
77 pub(crate) input: InputSpec,
78 /// Links the record the key names, which the input's re-check proved a child record in
79 /// scope that the user may view; linking a linked record changes nothing.
80 pub(crate) attach: LinkFn<P, String>,
81 /// Unlinks the records the keys name: 404 unless each is linked and in scope, 403 unless the
82 /// user may view each.
83 pub(crate) detach: LinkFn<P, Vec<String>>,
84}
85
86/// A write of an owner's links, given what names the related records.
87type LinkFn<P, K> = Arc<
88 dyn for<'a> Fn(&'a Cx, &'a P, K, &'a mut dyn toasty::Executor) -> BoxFuture<'a, Result<()>>
89 + Send
90 + Sync,
91>;
92
93/// The input key the attach dialog's choice posts.
94const ATTACH_KEY: &str = "record";
95
96/// A foreign-key column type referencing a primary key of type `K`: `K` itself, or `Option<K>`
97/// for an optional reference. Sealed: these are the only two.
98pub trait ForeignKey<K>:
99 sealed::Sealed<K> + IntoExpr<Self> + FormScalar + Send + Sync + 'static
100{
101}
102
103impl<K> ForeignKey<K> for K where K: IntoExpr<K> + FormScalar + Send + Sync + 'static {}
104
105impl<K> ForeignKey<K> for Option<K> where
106 Option<K>: IntoExpr<Self> + FormScalar + Send + Sync + 'static
107{
108}
109
110mod sealed {
111 pub trait Sealed<K> {}
112
113 impl<K> Sealed<K> for K {}
114
115 impl<K> Sealed<K> for Option<K> {}
116}
117
118impl<P> Relation<P>
119where
120 P: toasty::schema::Model + IntoExpr<P> + 'static,
121{
122 /// Declare a `has_many` child resource whose `foreign_key` holds the owner's primary key, a
123 /// single column.
124 ///
125 /// ```rust
126 /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
127 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
128 /// # struct Post {
129 /// # #[key] #[auto] id: uuid::Uuid,
130 /// # #[has_many]
131 /// # comments: toasty::Deferred<Vec<Comment>>,
132 /// # }
133 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
134 /// # struct Comment {
135 /// # #[key] #[auto] id: uuid::Uuid,
136 /// # #[index]
137 /// # post_id: uuid::Uuid,
138 /// # #[belongs_to(key = post_id, references = id)]
139 /// # post: toasty::Deferred<Post>,
140 /// # }
141 /// # struct PostResource;
142 /// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
143 /// # struct CommentResource;
144 /// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
145 /// let comments: Relation<Post> = Relation::has_many::<CommentResource>(Comment::fields().post_id());
146 /// ```
147 pub fn has_many<C>(foreign_key: Path<C::Model, impl ForeignKey<P::PrimaryKey>>) -> Self
148 where
149 C: Resource,
150 {
151 Self::bound_by::<C, _>(foreign_key)
152 }
153
154 fn bound_by<C, T>(foreign_key: Path<C::Model, T>) -> Self
155 where
156 C: Resource,
157 T: ForeignKey<P::PrimaryKey>,
158 {
159 // A foreign key is one column of the child: it binds against the child's model alone.
160 let (column, misdeclared) = match model::field::<C::Model>(&ModelPath::of(&foreign_key)) {
161 Ok(field) => (field.name, None),
162 Err(error) => (String::new(), Some(error)),
163 };
164 Self {
165 child: TypeId::of::<C>(),
166 child_name: std::any::type_name::<C>(),
167 label: None,
168 kind: RelationKind::HasMany {
169 foreign_key: column,
170 },
171 misdeclared,
172 bind: Arc::new(move |owner| {
173 let scope = foreign_key.clone().eq(pk::pk_expr::<P, T>(owner));
174 (scope, pk::pk_text(owner))
175 }),
176 }
177 }
178
179 /// Declare a many-to-many child resource whose records the owner links through `via`, its
180 /// `#[has_many(via = joins.target)]` field: the owner's linked records, which the table
181 /// attaches and detaches as join rows.
182 ///
183 /// ```rust
184 /// # use tablo_core::{NoForm, Relation, Resource, ResourceDef};
185 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
186 /// # struct Post {
187 /// # #[key] #[auto] id: uuid::Uuid,
188 /// # #[has_many]
189 /// # taggings: toasty::Deferred<Vec<Tagging>>,
190 /// # #[has_many(via = taggings.tag)]
191 /// # tags: toasty::Deferred<Vec<Tag>>,
192 /// # }
193 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
194 /// # struct Tag { #[key] #[auto] id: uuid::Uuid, name: String }
195 /// # #[derive(Debug, Clone, toasty::Model)]
196 /// # struct Tagging {
197 /// # #[key] #[auto] id: uuid::Uuid,
198 /// # #[index] post_id: uuid::Uuid,
199 /// # #[belongs_to(key = post_id, references = id)]
200 /// # post: toasty::Deferred<Post>,
201 /// # #[index] tag_id: uuid::Uuid,
202 /// # #[belongs_to(key = tag_id, references = id)]
203 /// # tag: toasty::Deferred<Tag>,
204 /// # }
205 /// # struct TagResource;
206 /// # impl Resource for TagResource { type Model = Tag; type Form = NoForm<Tag>; }
207 /// let tags: Relation<Post> = Relation::belongs_to_many::<TagResource>(Post::fields().tags());
208 /// ```
209 ///
210 /// Attaching and detaching ask the owner's policy for `Update`, as editing it does, and the
211 /// related resource's for `View` on each record; mounting refuses a join model a link cannot
212 /// write.
213 pub fn belongs_to_many<C>(via: impl Into<Path<P, List<C::Model>>>) -> Self
214 where
215 C: Resource,
216 P: Send + Sync,
217 {
218 let via: Path<P, List<C::Model>> = via.into();
219 let (field, misdeclared) = match model::field::<P>(&ModelPath::of(&via)) {
220 Ok(field) => (field.name, None),
221 Err(error) => (String::new(), Some(error)),
222 };
223 let attach_via = via.clone();
224 let attach_field = field.clone();
225 let detach_via = via.clone();
226 let detach_field = field.clone();
227 Self {
228 child: TypeId::of::<C>(),
229 child_name: std::any::type_name::<C>(),
230 label: None,
231 kind: RelationKind::ManyToMany(Links {
232 field,
233 input: InputSpec {
234 schema: attach_schema::<C>,
235 takes_input: true,
236 parse: parse_attach,
237 },
238 attach: Arc::new(move |cx, owner, key, ex| {
239 let (via, field) = (attach_via.clone(), attach_field.clone());
240 Box::pin(async move { attach::<P, C>(cx, owner, via, &field, key, ex).await })
241 }),
242 detach: Arc::new(move |cx, owner, keys, ex| {
243 let (via, field) = (detach_via.clone(), detach_field.clone());
244 Box::pin(async move { detach::<P, C>(cx, owner, via, &field, keys, ex).await })
245 }),
246 }),
247 misdeclared,
248 bind: Arc::new(move |owner| (linked_to(owner, via.clone()), pk::pk_text(owner))),
249 }
250 }
251}
252
253/// The attach dialog's input: one searchable choice of `C`'s records.
254fn attach_schema<C: Resource>() -> Schema {
255 Schema::new(
256 Field::choice_input(ATTACH_KEY)
257 .label(sentence_case(type_short_name::<C::Model>()))
258 .relationship::<C>()
259 .searchable(),
260 )
261}
262
263/// The key the attach dialog's choice posts: the schema's check proved it one of the choice's
264/// records.
265fn parse_attach(_cx: &Cx, values: &FormValues) -> InputResult {
266 match values.text(ATTACH_KEY).map(str::trim) {
267 Some(key) if !key.is_empty() => Ok(Box::new(key.to_string()) as ErasedInput),
268 _ => Err(vec![FieldError::required(ATTACH_KEY)]),
269 }
270}
271
272/// The join model behind `owner`'s `field`.
273fn join_table<P: toasty::schema::Model>(cx: &Cx, field: &str) -> Result<JoinTable> {
274 let schema = AppSchema::of(cx).ok_or_else(|| {
275 crate::error::declaration(crate::DeclarationErrorKind::MissingDb.to_string())
276 })?;
277 JoinTable::of::<P>(&schema, field).map_err(|fault| {
278 crate::error::declaration(
279 crate::DeclarationErrorKind::ManyToMany {
280 field: field.to_string(),
281 fault,
282 }
283 .to_string(),
284 )
285 })
286}
287
288/// Links `owner` to the record `key` names, unless they are linked already.
289async fn attach<P, C>(
290 cx: &Cx,
291 owner: &P,
292 via: Path<P, List<C::Model>>,
293 field: &str,
294 key: String,
295 ex: &mut dyn toasty::Executor,
296) -> Result<()>
297where
298 P: toasty::schema::Model + IntoExpr<P>,
299 C: Resource,
300{
301 let join = join_table::<P>(cx, field)?;
302 let Some(record) = pk::pk_eq_expr::<C::Model>(&key) else {
303 return Err(topcoat::router::error::not_found().into());
304 };
305 let linked = scoped_query::<C>(cx)?
306 .filter(record)
307 .filter(linked_to(owner, via))
308 .first()
309 .exec(&mut *ex)
310 .await
311 .map_err(crate::error::unavailable)?;
312 if linked.is_none() {
313 join.link(owner, &[key], ex)
314 .await
315 .map_err(|error| -> topcoat::Error { error.into() })?;
316 }
317 Ok(())
318}
319
320/// Unlinks `owner` from the records `keys` names.
321async fn detach<P, C>(
322 cx: &Cx,
323 owner: &P,
324 via: Path<P, List<C::Model>>,
325 field: &str,
326 keys: Vec<String>,
327 ex: &mut dyn toasty::Executor,
328) -> Result<()>
329where
330 P: toasty::schema::Model + IntoExpr<P>,
331 C: Resource,
332{
333 let join = join_table::<P>(cx, field)?;
334 let ids: Vec<&str> = keys.iter().map(String::as_str).collect();
335 let Some(records) = pk::pk_in_expr::<C::Model>(&ids) else {
336 return Err(topcoat::router::error::not_found().into());
337 };
338 let linked = scoped_query::<C>(cx)?
339 .filter(records)
340 .filter(linked_to(owner, via))
341 .exec(&mut *ex)
342 .await
343 .map_err(crate::error::unavailable)?;
344 if linked.len() != keys.len() {
345 return Err(topcoat::router::error::not_found().into());
346 }
347 // A record the user cannot view cannot be unlinked by guessing its key.
348 if linked
349 .iter()
350 .any(|record| !can::<C>(cx, Ability::View(record)))
351 {
352 return Err(topcoat::router::error::forbidden().into());
353 }
354 join.unlink(owner, &keys, ex)
355 .await
356 .map_err(|error| -> topcoat::Error { error.into() })
357}
358
359impl<P> Relation<P> {
360 /// Titles the section `label` instead of the related resource's plural label.
361 #[must_use]
362 pub fn label(mut self, label: impl Into<String>) -> Self {
363 self.label = Some(label.into());
364 self
365 }
366
367 /// The child's rows that belong to `owner`, and the owner's primary key as text.
368 pub(crate) fn bind(&self, owner: &P) -> (Expr<bool>, String) {
369 (self.bind)(owner)
370 }
371}
372
373impl<P> Relation<P> {
374 /// The relation's join-row writes, when it is a many-to-many one.
375 pub(crate) fn links(&self) -> Option<&Links<P>> {
376 match &self.kind {
377 RelationKind::ManyToMany(links) => Some(links),
378 RelationKind::HasMany { .. } => None,
379 }
380 }
381}
382
383impl<P> std::fmt::Debug for Relation<P> {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 let mut debug = f.debug_struct("Relation");
386 debug
387 .field("child", &self.child_name)
388 .field("label", &self.label);
389 match &self.kind {
390 RelationKind::HasMany { foreign_key } => debug.field("foreign_key", foreign_key),
391 RelationKind::ManyToMany(links) => debug.field("via", &links.field),
392 };
393 debug.finish_non_exhaustive()
394 }
395}