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}