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
74impl<F: RecordForm> crate::schema::EditsRecords for F {}
75
76/// The hidden control the edit form carries the record's [`ModelColumns::version`] in.
77///
78/// [`ModelColumns::version`]: crate::ModelColumns::version
79pub(crate) const VERSION_FIELD: &str = "record_version";
80
81/// The name of the create form's second submit button, which saves and lands on a fresh create
82/// form rather than the list.
83pub(crate) const ANOTHER_FIELD: &str = "create_another";
84
85/// The form key of the many-to-many field `path`, a `#[has_many(via = ..)]` list of `R`'s
86/// records, holding their keys of type `K`: the derive calls it, so a field whose type is not
87/// `Vec<K>` of the related model's key, or whose source is not that model's, fails to compile.
88#[doc(hidden)]
89pub fn links_key<M, R, K>(
90 path: impl Into<toasty::stmt::Path<M, toasty::stmt::List<R::Model>>>,
91) -> String
92where
93 M: Model,
94 R: OptionSource,
95 R::Model: Model<PrimaryKey = K>,
96 K: FormScalar,
97{
98 crate::schema::form_key(path.into())
99}
100
101/// `includes` and the include loading the many-to-many field `path`, which
102/// [`RecordForm::hydrate`] reads.
103#[doc(hidden)]
104pub fn links_include<M, R>(
105 includes: Includes<M>,
106 path: impl Into<toasty::stmt::Path<M, toasty::stmt::List<R::Model>>>,
107) -> Includes<M>
108where
109 M: Model,
110 R: OptionSource,
111{
112 includes.with(path.into())
113}
114
115/// The keys of the records a many-to-many field holds, as its form key carries them, or `None`
116/// when the record was loaded without them.
117#[doc(hidden)]
118pub fn write_links<T>(records: &toasty::Deferred<Vec<T>>) -> Option<FormValue>
119where
120 T: Model + toasty::stmt::IntoExpr<T>,
121{
122 (!records.is_unloaded()).then(|| {
123 FormValue::List(
124 records
125 .get()
126 .iter()
127 .map(crate::toasty_compat::pk::pk_text)
128 .collect(),
129 )
130 })
131}
132
133/// The typed value a resource's form submission parses into.
134///
135/// Derive it from a model with [`tablo_core::ModelForm`](crate::ModelForm) or from a struct with
136/// [`tablo_core::RecordForm`](crate::RecordForm); a hand-written impl is what the derives expand
137/// to.
138pub trait RecordForm: Sized + Send + 'static {
139 /// The model the form writes.
140 type Model: Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
141
142 /// One variant per form field: the key [`Posted`] uses. Its bound form keys
143 /// are [`Self::fields`]'.
144 type Field: Copy + Eq + Hash + Debug + Send + Sync + 'static;
145
146 /// Every field, in declaration order, with the keys it binds.
147 fn fields() -> Vec<FormField<Self::Field>>;
148
149 /// The control the form renders `field` with when its [`form`](crate::ResourceDef::form)
150 /// does not place it: what `controls()` hands over for the field, as a one-control schema.
151 ///
152 /// A resource's form renders every field it does not place after the ones it does, in
153 /// declaration order, so a resource that declares no form renders one control per field.
154 fn control(field: Self::Field) -> Schema<Self>;
155
156 /// The relations [`hydrate`](Self::hydrate) reads off the record: each many-to-many field's,
157 /// which the edit page and the edit's write load the record with.
158 fn includes() -> Includes<Self::Model> {
159 Includes::new()
160 }
161
162 /// The stored record as the form spells it.
163 fn hydrate(cx: &Cx, record: &Self::Model) -> FormValues;
164
165 /// The records each many-to-many field holds, as their keys' form spelling: what
166 /// [`write_create`](crate::resource::write_create) links a new record to, and what
167 /// [`write_update`](crate::resource::write_update) leaves a posted field's record linked to.
168 fn links(&self) -> Vec<(Self::Field, Vec<String>)> {
169 Vec::new()
170 }
171
172 /// Parse a completed submission: the form's only presence and type check.
173 ///
174 /// # Errors
175 ///
176 /// Every key that failed, each once, under one of [`Self::fields`]' keys.
177 fn parse(cx: &Cx, values: &FormValues) -> std::result::Result<Self, Vec<FieldError>>;
178
179 /// The create builder with every field set.
180 fn into_create(self) -> <Self::Model as Model>::Create;
181
182 /// The instance update builder with one assignment per named field, or
183 /// `None` when no field is named.
184 ///
185 /// `None` is what keeps a submission that names no field from building an
186 /// empty update, which toasty refuses with an assertion.
187 fn into_update<'a>(
188 self,
189 record: &'a mut Self::Model,
190 named: &HashSet<Self::Field>,
191 ) -> Option<<Self::Model as Model>::Update<'a>>;
192
193 /// Execute a builder [`Self::into_update`] returned.
194 ///
195 /// Toasty's instance update builder implements no trait that carries
196 /// `exec`, so the generic write reaches it through the form.
197 fn exec_update<'a>(
198 update: <Self::Model as Model>::Update<'a>,
199 ex: &'a mut dyn Executor,
200 ) -> impl Future<Output = toasty::Result<()>> + Send + 'a;
201
202 /// Whether the resource naming this form has create and edit pages.
203 ///
204 /// [`Panel::resource`](crate::Panel::resource) registers the create, edit,
205 /// and options routes only when this holds. [`NoForm`] sets it to `false`.
206 const HAS_FORM: bool = true;
207}
208
209/// The record form of a resource with no create or edit page.
210///
211/// A list-only resource names it as [`Resource::Form`](crate::Resource::Form):
212/// `type Form = NoForm<Self::Model>;`. [`fields`](RecordForm::fields) and
213/// [`hydrate`](RecordForm::hydrate) are empty and
214/// [`into_update`](RecordForm::into_update) answers `None`. No route parses or
215/// writes it, so [`parse`](RecordForm::parse) and
216/// [`into_create`](RecordForm::into_create) panic naming the model, and the
217/// future [`exec_update`](RecordForm::exec_update) returns panics when polled.
218pub struct NoForm<M>(std::marker::PhantomData<fn() -> M>);
219
220impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> NoForm<M> {
221 fn unreachable() -> ! {
222 panic!("`NoForm<{}>` has no form", std::any::type_name::<M>())
223 }
224}
225
226impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> RecordForm for NoForm<M> {
227 type Model = M;
228 type Field = std::convert::Infallible;
229
230 const HAS_FORM: bool = false;
231
232 fn fields() -> Vec<FormField<Self::Field>> {
233 Vec::new()
234 }
235
236 fn control(field: Self::Field) -> Schema<Self> {
237 match field {}
238 }
239
240 fn hydrate(_cx: &Cx, _record: &M) -> FormValues {
241 FormValues::new()
242 }
243
244 fn parse(_cx: &Cx, _values: &FormValues) -> std::result::Result<Self, Vec<FieldError>> {
245 Self::unreachable()
246 }
247
248 fn into_create(self) -> M::Create {
249 Self::unreachable()
250 }
251
252 fn into_update<'a>(
253 self,
254 _record: &'a mut M,
255 _named: &HashSet<Self::Field>,
256 ) -> Option<M::Update<'a>> {
257 None
258 }
259
260 fn exec_update<'a>(
261 update: M::Update<'a>,
262 ex: &'a mut dyn Executor,
263 ) -> impl Future<Output = toasty::Result<()>> + Send + 'a {
264 // `into_update` answers `None`, so no builder reaches here. The
265 // builder is not known to be `Send`, so the future must not hold it.
266 drop((update, ex));
267 async { Self::unreachable() }
268 }
269}
270
271/// An edit submission: the parsed form, and the fields the submission named.
272///
273/// Derefs to the form, so a record fn reads `posted.author_id` — the posted
274/// value, or the stored one when the submission did not name the field.
275pub struct Posted<F: RecordForm> {
276 form: F,
277 named: HashSet<F::Field>,
278}
279
280impl<F: RecordForm> Posted<F> {
281 /// A posted form that names `named`.
282 pub fn new(form: F, named: impl IntoIterator<Item = F::Field>) -> Self {
283 Self {
284 form,
285 named: named.into_iter().collect(),
286 }
287 }
288
289 /// Whether the submission named `field`.
290 pub fn named(&self, field: F::Field) -> bool {
291 self.named.contains(&field)
292 }
293
294 /// The update builder assigning every named field, or `None` when the
295 /// submission named none. See [`RecordForm::into_update`].
296 pub fn into_update(self, record: &mut F::Model) -> Option<<F::Model as Model>::Update<'_>> {
297 self.form.into_update(record, &self.named)
298 }
299}
300
301impl<F: RecordForm> std::ops::Deref for Posted<F> {
302 type Target = F;
303
304 fn deref(&self) -> &F {
305 &self.form
306 }
307}