Skip to main content

tablo_core/
form.rs

1//! Record forms: the typed value a form submission parses into, and the write
2//! that stores it.
3//!
4//! Declares one `RecordForm` struct with one field per written model column.
5//! The struct is the form's one source of truth: it parses every key, and a
6//! field's blank answer decides whether its control is required. Completes
7//! unposted keys from the stored record and writes only named fields plus model
8//! defaults.
9//!
10//! ```no_run
11//! #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
12//! struct User {
13//!     #[key]
14//!     #[auto]
15//!     id: uuid::Uuid,
16//!     name: String,
17//!     age: i64,
18//! }
19//!
20//! #[derive(tablo_core::RecordForm)]
21//! #[form(model = User)]
22//! struct UserForm {
23//!     name: String,
24//!     #[form(blank = 0)]
25//!     age: i64,
26//! }
27//!
28//! // A column type toasty stores but the form edge cannot spell.
29//! #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
30//! struct Tagged {
31//!     #[key]
32//!     #[auto]
33//!     id: uuid::Uuid,
34//!     tags: Vec<String>,
35//! }
36//! ```
37//!
38//! # Compile-time refusals
39//!
40//! The derive refuses, at compile time:
41//!
42//! - a field the model does not have;
43//! - a type the model's field does not have, such as `i32` for an `i64` column;
44//! - a bare choice, which would offer nothing: a foreign key names its source with
45//!   `#[form(relationship = ..)]`;
46//! - `relationship` and `file` on one field, which each pick the control;
47//! - a scalar type the form edge cannot spell, such as `Vec<String>`;
48//! - a many-to-many field whose keys are not its source's primary key;
49//! - `optional` on a type with no empty value;
50//! - `embed` on a type that is not an [`EmbeddedForm`](crate::EmbeddedForm).
51
52use std::{collections::HashSet, fmt::Debug, hash::Hash};
53
54use toasty::{Executor, schema::Model};
55use topcoat::context::Cx;
56
57#[doc(hidden)]
58pub use crate::schema::input::required_input;
59use crate::{
60    column::Includes,
61    schema::{OptionSource, Schema},
62};
63// A record form's values, scalars and errors sit below `schema`, which reads and renders them,
64// and a form input is a schema's parse; both are re-exported here, where a record form names
65// them.
66pub use crate::{
67    schema::input::FormInput,
68    values::{
69        FieldError, FieldErrors, FormField, FormScalar, FormValue, FormValues, NullableScalar,
70        assert_form_scalar, parse_list, parse_password, parse_scalar,
71    },
72};
73
74/// The hidden control the edit form carries the record's [`ModelColumns::version`] in.
75///
76/// [`ModelColumns::version`]: crate::ModelColumns::version
77pub(crate) const VERSION_FIELD: &str = "record_version";
78
79/// The form key of the many-to-many field `path`, a `#[has_many(via = ..)]` list of `R`'s
80/// records, holding their keys of type `K`: the derive calls it, so a field whose type is not
81/// `Vec<K>` of the related model's key, or whose source is not that model's, fails to compile.
82#[doc(hidden)]
83pub fn links_key<M, R, K>(
84    path: impl Into<toasty::stmt::Path<M, toasty::stmt::List<R::Model>>>,
85) -> String
86where
87    M: Model,
88    R: OptionSource,
89    R::Model: Model<PrimaryKey = K>,
90    K: FormScalar,
91{
92    crate::schema::form_key(path.into())
93}
94
95/// `includes` and the include loading the many-to-many field `path`, which
96/// [`RecordForm::hydrate`] reads.
97#[doc(hidden)]
98pub fn links_include<M, R>(
99    includes: Includes<M>,
100    path: impl Into<toasty::stmt::Path<M, toasty::stmt::List<R::Model>>>,
101) -> Includes<M>
102where
103    M: Model,
104    R: OptionSource,
105{
106    includes.with(path.into())
107}
108
109/// The keys of the records a many-to-many field holds, as its form key carries them, or `None`
110/// when the record was loaded without them.
111#[doc(hidden)]
112pub fn write_links<T>(records: &toasty::Deferred<Vec<T>>) -> Option<FormValue>
113where
114    T: Model + toasty::stmt::IntoExpr<T>,
115{
116    (!records.is_unloaded()).then(|| {
117        FormValue::List(
118            records
119                .get()
120                .iter()
121                .map(crate::toasty_compat::pk::pk_text)
122                .collect(),
123        )
124    })
125}
126
127/// The typed value a resource's form submission parses into.
128///
129/// Derive it from a model with [`tablo_core::ModelForm`](crate::ModelForm) or from a struct with
130/// [`tablo_core::RecordForm`](crate::RecordForm); a hand-written impl is what the derives expand
131/// to.
132pub trait RecordForm: Sized + Send + 'static {
133    /// The model the form writes.
134    type Model: Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
135
136    /// One variant per form field: the key [`Posted`] uses. Its bound form keys
137    /// are [`Self::fields`]'.
138    type Field: Copy + Eq + Hash + Debug + Send + Sync + 'static;
139
140    /// Every field, in declaration order, with the keys it binds.
141    fn fields() -> Vec<FormField<Self::Field>>;
142
143    /// The control the form renders `field` with when its [`form`](crate::ResourceDef::form)
144    /// does not place it: what `controls()` hands over for the field, as a one-control schema.
145    ///
146    /// A resource's form renders every field it does not place after the ones it does, in
147    /// declaration order, so a resource that declares no form renders one control per field.
148    fn control(field: Self::Field) -> Schema<Self>;
149
150    /// The relations [`hydrate`](Self::hydrate) reads off the record: each many-to-many field's,
151    /// which the edit page and the edit's write load the record with.
152    fn includes() -> Includes<Self::Model> {
153        Includes::new()
154    }
155
156    /// The stored record as the form spells it.
157    fn hydrate(cx: &Cx, record: &Self::Model) -> FormValues;
158
159    /// The records each many-to-many field holds, as their keys' form spelling: what
160    /// [`write_create`](crate::resource::write_create) links a new record to, and what
161    /// [`write_update`](crate::resource::write_update) leaves a posted field's record linked to.
162    fn links(&self) -> Vec<(Self::Field, Vec<String>)> {
163        Vec::new()
164    }
165
166    /// Parse a completed submission: the form's only presence and type check.
167    ///
168    /// # Errors
169    ///
170    /// Every key that failed, each once, under one of [`Self::fields`]' keys.
171    fn parse(cx: &Cx, values: &FormValues) -> std::result::Result<Self, Vec<FieldError>>;
172
173    /// The create builder with every field set.
174    fn into_create(self) -> <Self::Model as Model>::Create;
175
176    /// The instance update builder with one assignment per named field, or
177    /// `None` when no field is named.
178    ///
179    /// `None` is what keeps a submission that names no field from building an
180    /// empty update, which toasty refuses with an assertion.
181    fn into_update<'a>(
182        self,
183        record: &'a mut Self::Model,
184        named: &HashSet<Self::Field>,
185    ) -> Option<<Self::Model as Model>::Update<'a>>;
186
187    /// Execute a builder [`Self::into_update`] returned.
188    ///
189    /// Toasty's instance update builder implements no trait that carries
190    /// `exec`, so the generic write reaches it through the form.
191    fn exec_update<'a>(
192        update: <Self::Model as Model>::Update<'a>,
193        ex: &'a mut dyn Executor,
194    ) -> impl Future<Output = toasty::Result<()>> + Send + 'a;
195
196    /// Whether the resource naming this form has create and edit pages.
197    ///
198    /// [`Panel::resource`](crate::Panel::resource) registers the create, edit,
199    /// and options routes only when this holds. [`NoForm`] sets it to `false`.
200    const HAS_FORM: bool = true;
201}
202
203/// The record form of a resource with no create or edit page.
204///
205/// A list-only resource names it as [`Resource::Form`](crate::Resource::Form):
206/// `type Form = NoForm<Self::Model>;`. [`fields`](RecordForm::fields) and
207/// [`hydrate`](RecordForm::hydrate) are empty and
208/// [`into_update`](RecordForm::into_update) answers `None`. No route parses or
209/// writes it, so [`parse`](RecordForm::parse) and
210/// [`into_create`](RecordForm::into_create) panic naming the model, and the
211/// future [`exec_update`](RecordForm::exec_update) returns panics when polled.
212pub struct NoForm<M>(std::marker::PhantomData<fn() -> M>);
213
214impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> NoForm<M> {
215    fn unreachable() -> ! {
216        panic!("`NoForm<{}>` has no form", std::any::type_name::<M>())
217    }
218}
219
220impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> RecordForm for NoForm<M> {
221    type Model = M;
222    type Field = std::convert::Infallible;
223
224    const HAS_FORM: bool = false;
225
226    fn fields() -> Vec<FormField<Self::Field>> {
227        Vec::new()
228    }
229
230    fn control(field: Self::Field) -> Schema<Self> {
231        match field {}
232    }
233
234    fn hydrate(_cx: &Cx, _record: &M) -> FormValues {
235        FormValues::new()
236    }
237
238    fn parse(_cx: &Cx, _values: &FormValues) -> std::result::Result<Self, Vec<FieldError>> {
239        Self::unreachable()
240    }
241
242    fn into_create(self) -> M::Create {
243        Self::unreachable()
244    }
245
246    fn into_update<'a>(
247        self,
248        _record: &'a mut M,
249        _named: &HashSet<Self::Field>,
250    ) -> Option<M::Update<'a>> {
251        None
252    }
253
254    fn exec_update<'a>(
255        update: M::Update<'a>,
256        ex: &'a mut dyn Executor,
257    ) -> impl Future<Output = toasty::Result<()>> + Send + 'a {
258        // `into_update` answers `None`, so no builder reaches here. The
259        // builder is not known to be `Send`, so the future must not hold it.
260        drop((update, ex));
261        async { Self::unreachable() }
262    }
263}
264
265/// An edit submission: the parsed form, and the fields the submission named.
266///
267/// Derefs to the form, so a record fn reads `posted.author_id` — the posted
268/// value, or the stored one when the submission did not name the field.
269pub struct Posted<F: RecordForm> {
270    form: F,
271    named: HashSet<F::Field>,
272}
273
274impl<F: RecordForm> Posted<F> {
275    /// A posted form that names `named`.
276    pub fn new(form: F, named: impl IntoIterator<Item = F::Field>) -> Self {
277        Self {
278            form,
279            named: named.into_iter().collect(),
280        }
281    }
282
283    /// Whether the submission named `field`.
284    pub fn named(&self, field: F::Field) -> bool {
285        self.named.contains(&field)
286    }
287
288    /// The update builder assigning every named field, or `None` when the
289    /// submission named none. See [`RecordForm::into_update`].
290    pub fn into_update(self, record: &mut F::Model) -> Option<<F::Model as Model>::Update<'_>> {
291        self.form.into_update(record, &self.named)
292    }
293}
294
295impl<F: RecordForm> std::ops::Deref for Posted<F> {
296    type Target = F;
297
298    fn deref(&self) -> &F {
299        &self.form
300    }
301}