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)]
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)]
30//! struct Tagged {
31//! #[key]
32//! #[auto]
33//! id: uuid::Uuid,
34//! tags: Vec<String>,
35//! }
36//! ```
37//!
38//! # Compile-time refusals
39//!
40//! A field the model does not have:
41//!
42//! ```compile_fail
43//! # #[derive(Debug, Clone, toasty::Model)]
44//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
45//! #[derive(tablo_core::RecordForm)]
46//! #[form(model = User)]
47//! struct UserForm {
48//! nickname: String,
49//! }
50//! ```
51//!
52//! A type the model's field does not have:
53//!
54//! ```compile_fail
55//! # #[derive(Debug, Clone, toasty::Model)]
56//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
57//! #[derive(tablo_core::RecordForm)]
58//! #[form(model = User)]
59//! struct UserForm {
60//! age: i32,
61//! }
62//! ```
63//!
64//! A scalar type the form edge cannot spell:
65//!
66//! ```compile_fail
67//! #[derive(Debug, Clone, toasty::Model)]
68//! struct Tagged {
69//! #[key]
70//! #[auto]
71//! id: uuid::Uuid,
72//! tags: Vec<String>,
73//! }
74//!
75//! #[derive(tablo_core::RecordForm)]
76//! #[form(model = Tagged)]
77//! struct TaggedForm {
78//! tags: Vec<String>,
79//! }
80//! ```
81//!
82//! `optional` on a type with no empty value:
83//!
84//! ```compile_fail
85//! # #[derive(Debug, Clone, toasty::Model)]
86//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
87//! #[derive(tablo_core::RecordForm)]
88//! #[form(model = User)]
89//! struct UserForm {
90//! #[form(optional)]
91//! age: i64,
92//! }
93//! ```
94//!
95//! `embed` on a type that is not an [`EmbeddedForm`](crate::EmbeddedForm):
96//!
97//! ```compile_fail
98//! # #[derive(Debug, Clone, toasty::Model)]
99//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
100//! #[derive(tablo_core::RecordForm)]
101//! #[form(model = User)]
102//! struct UserForm {
103//! #[form(embed)]
104//! name: String,
105//! }
106//! ```
107
108use std::{
109 collections::{HashMap, HashSet},
110 fmt::Debug,
111 hash::Hash,
112};
113
114use toasty::{Executor, schema::Model};
115use topcoat::context::Cx;
116
117use crate::{
118 schema::{FieldResolver, Schema, TypedValue},
119 table::Table,
120};
121
122/// A type one form key reads and writes: `String`, every [`TypedValue`] type,
123/// and an `Option` of either.
124///
125/// The value the parse sees is trimmed and non-empty; an empty submission is the
126/// record-form field's **blank answer** instead, and a field with none is required.
127///
128/// A text field binds a path of any `FormScalar` type
129/// ([`Field::text`](crate::Field::text)), and the form derives read and write
130/// every field that is not `#[form(embed)]` through it.
131#[diagnostic::on_unimplemented(
132 message = "`{Self}` is not a form scalar",
133 label = "a form field of this type has no text spelling",
134 note = "a form scalar is `String`, a `TypedValue` type, or an `Option` of one; implement \
135 `TypedValue` for an app type, or mark an `EmbeddedForm` value `#[form(embed)]`"
136)]
137pub trait FormScalar: Sized {
138 /// The `type` attribute of the text control that edits it.
139 const INPUT_TYPE: &'static str = "text";
140
141 /// Parse a trimmed, non-empty submission, or return the error message.
142 fn parse_form(value: &str) -> std::result::Result<Self, String>;
143
144 /// The form spelling of a stored value.
145 fn to_form(&self) -> String;
146}
147
148impl FormScalar for String {
149 fn parse_form(value: &str) -> std::result::Result<Self, String> {
150 Ok(value.to_string())
151 }
152
153 fn to_form(&self) -> String {
154 self.clone()
155 }
156}
157
158impl<T: TypedValue> FormScalar for T {
159 const INPUT_TYPE: &'static str = T::INPUT_TYPE;
160
161 fn parse_form(value: &str) -> std::result::Result<Self, String> {
162 T::parse_input(value).ok_or_else(|| format!("`{value}` is not a valid {}", T::NOUN))
163 }
164
165 fn to_form(&self) -> String {
166 self.to_string()
167 }
168}
169
170impl<T: TypedValue> FormScalar for Option<T> {
171 const INPUT_TYPE: &'static str = T::INPUT_TYPE;
172
173 fn parse_form(value: &str) -> std::result::Result<Self, String> {
174 T::parse_form(value).map(Some)
175 }
176
177 fn to_form(&self) -> String {
178 self.as_ref().map(T::to_form).unwrap_or_default()
179 }
180}
181
182impl FormScalar for Option<String> {
183 fn parse_form(value: &str) -> std::result::Result<Self, String> {
184 Ok(Some(value.to_string()))
185 }
186
187 fn to_form(&self) -> String {
188 self.clone().unwrap_or_default()
189 }
190}
191
192/// Compiles only for a form scalar: the form derives call it, spanned on a
193/// field's type, so a field of another type fails there.
194#[doc(hidden)]
195pub fn assert_form_scalar<T: FormScalar>() {}
196
197/// Why a key was refused.
198#[derive(Debug, Clone, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum FieldErrorKind {
201 /// Posted empty, and the field has no blank answer. The form renders
202 /// "{label} is required" under the control.
203 Required,
204 /// Posted a value the field refuses, with the sentence the form renders.
205 Invalid(String),
206}
207
208/// One key a submission was refused under: a form key, or a record form's
209/// field enum in [`Resource::validate_record`](crate::Resource::validate_record).
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct FieldError<K = String> {
212 /// What the error renders under.
213 pub key: K,
214 /// Why the key was refused.
215 pub kind: FieldErrorKind,
216}
217
218impl<K> FieldError<K> {
219 /// `key` was posted empty and has no blank answer.
220 pub fn required(key: impl Into<K>) -> Self {
221 Self {
222 key: key.into(),
223 kind: FieldErrorKind::Required,
224 }
225 }
226
227 /// `key` carried a value it refuses; `message` says why.
228 pub fn invalid(key: impl Into<K>, message: impl Into<String>) -> Self {
229 Self {
230 key: key.into(),
231 kind: FieldErrorKind::Invalid(message.into()),
232 }
233 }
234
235 /// The sentence the form renders under the control labelled `label`.
236 pub fn message(&self, label: &str) -> String {
237 match &self.kind {
238 FieldErrorKind::Required => format!("{label} is required"),
239 FieldErrorKind::Invalid(message) => message.clone(),
240 }
241 }
242}
243
244/// Read one scalar from a completed submission.
245///
246/// Trims the value; an empty one is `blank`, the field's blank answer, or
247/// refuses as [`FieldErrorKind::Required`] when it has none.
248pub fn parse_scalar<T: FormScalar>(
249 key: &str,
250 values: &HashMap<String, String>,
251 blank: Option<T>,
252) -> std::result::Result<T, FieldError> {
253 let raw = values.get(key).map(|value| value.trim()).unwrap_or("");
254 if raw.is_empty() {
255 return blank.ok_or_else(|| FieldError::required(key));
256 }
257 T::parse_form(raw).map_err(|message| FieldError::invalid(key, message))
258}
259
260/// One record-form field and the form keys it binds.
261#[derive(Debug, Clone)]
262pub struct FormField<K> {
263 /// The field, as the form's field enum names it.
264 pub field: K,
265 /// The Rust field name, for build-time refusals.
266 pub name: &'static str,
267 /// The keys the field binds: one for a scalar, every key of an embedded
268 /// value (an enum's discriminant first). An error on the field renders
269 /// under the first.
270 pub keys: Vec<String>,
271 /// The keys with no blank answer, which an empty submission refuses: the
272 /// panel renders their controls required.
273 pub required: Vec<String>,
274}
275
276/// The typed value a resource's form submission parses into.
277///
278/// Derive it with [`tablo_core::RecordForm`](crate::RecordForm); a
279/// hand-written impl is what the derive expands to.
280pub trait RecordForm: Sized + Send + 'static {
281 /// The model the form writes.
282 type Model: Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
283
284 /// One variant per form field: the key [`Posted`] uses. Its bound form keys
285 /// are [`Self::fields`]'.
286 type Field: Copy + Eq + Hash + Debug + Send + Sync + 'static;
287
288 /// Every field, in declaration order, with the keys it binds through `resolver`'s app schema.
289 fn fields(resolver: &FieldResolver) -> Vec<FormField<Self::Field>>;
290
291 /// The form's default schema: one control per field, in declaration
292 /// order. A [`ResourceDef`](crate::ResourceDef) without a [`form`](crate::ResourceDef::form)
293 /// renders it.
294 ///
295 /// The derive chooses each control from the field (see
296 /// [`RecordForm`](derive@crate::RecordForm)); the default here declares
297 /// none, which is what [`NoForm`] wants.
298 fn schema() -> Schema {
299 Schema::empty()
300 }
301
302 /// The form's default table: one column per field a column can show, in declaration order.
303 /// A [`ResourceDef`](crate::ResourceDef) without a [`table`](crate::ResourceDef::table) lists
304 /// it.
305 ///
306 /// The derive lists each text field in a sortable column, searchable over a `String` or
307 /// `Option<String>`, an options field by its option's label, and a toggle as yes or no (see
308 /// [`RecordForm`](derive@crate::RecordForm)). The default here lists none, so a resource
309 /// whose form lists nothing, such as one naming [`NoForm`], declares its own.
310 fn table() -> Table<Self::Model> {
311 Table::new(())
312 }
313
314 /// The stored record as the form spells it.
315 fn hydrate(cx: &Cx, record: &Self::Model) -> HashMap<String, String>;
316
317 /// Parse a completed submission: the form's only presence and type check.
318 ///
319 /// # Errors
320 ///
321 /// Every key that failed, each once, under one of [`Self::fields`]' keys.
322 fn parse(
323 cx: &Cx,
324 values: &HashMap<String, String>,
325 ) -> std::result::Result<Self, Vec<FieldError>>;
326
327 /// The create builder with every field set.
328 fn into_create(self) -> <Self::Model as Model>::Create;
329
330 /// The instance update builder with one assignment per named field, or
331 /// `None` when no field is named.
332 ///
333 /// `None` is what keeps a submission that names no field from building an
334 /// empty update, which toasty refuses with an assertion.
335 fn into_update<'a>(
336 self,
337 record: &'a mut Self::Model,
338 named: &HashSet<Self::Field>,
339 ) -> Option<<Self::Model as Model>::Update<'a>>;
340
341 /// Execute a builder [`Self::into_update`] returned.
342 ///
343 /// Toasty's instance update builder implements no trait that carries
344 /// `exec`, so the generic write reaches it through the form.
345 fn exec_update<'a>(
346 update: <Self::Model as Model>::Update<'a>,
347 ex: &'a mut dyn Executor,
348 ) -> impl Future<Output = toasty::Result<()>> + Send + 'a;
349
350 /// Whether the resource naming this form has create and edit pages.
351 ///
352 /// [`Panel::resource`](crate::Panel::resource) registers the create, edit,
353 /// and options routes only when this holds. [`NoForm`] sets it to `false`.
354 const HAS_FORM: bool = true;
355}
356
357/// The record form of a resource with no create or edit page.
358///
359/// A list-only resource names it as [`Resource::Form`](crate::Resource::Form):
360/// `type Form = NoForm<Self::Model>;`. [`fields`](RecordForm::fields) and
361/// [`hydrate`](RecordForm::hydrate) are empty and
362/// [`into_update`](RecordForm::into_update) answers `None`. No route parses or
363/// writes it, so [`parse`](RecordForm::parse) and
364/// [`into_create`](RecordForm::into_create) panic naming the model, and the
365/// future [`exec_update`](RecordForm::exec_update) returns panics when polled.
366pub struct NoForm<M>(std::marker::PhantomData<fn() -> M>);
367
368impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> NoForm<M> {
369 fn unreachable() -> ! {
370 panic!("`NoForm<{}>` has no form", std::any::type_name::<M>())
371 }
372}
373
374impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> RecordForm for NoForm<M> {
375 type Model = M;
376 type Field = std::convert::Infallible;
377
378 const HAS_FORM: bool = false;
379
380 fn fields(_resolver: &FieldResolver) -> Vec<FormField<Self::Field>> {
381 Vec::new()
382 }
383
384 fn hydrate(_cx: &Cx, _record: &M) -> HashMap<String, String> {
385 HashMap::new()
386 }
387
388 fn parse(
389 _cx: &Cx,
390 _values: &HashMap<String, String>,
391 ) -> std::result::Result<Self, Vec<FieldError>> {
392 Self::unreachable()
393 }
394
395 fn into_create(self) -> M::Create {
396 Self::unreachable()
397 }
398
399 fn into_update<'a>(
400 self,
401 _record: &'a mut M,
402 _named: &HashSet<Self::Field>,
403 ) -> Option<M::Update<'a>> {
404 None
405 }
406
407 fn exec_update<'a>(
408 update: M::Update<'a>,
409 ex: &'a mut dyn Executor,
410 ) -> impl Future<Output = toasty::Result<()>> + Send + 'a {
411 // `into_update` answers `None`, so no builder reaches here. The
412 // builder is not known to be `Send`, so the future must not hold it.
413 drop((update, ex));
414 async { Self::unreachable() }
415 }
416}
417
418/// An edit submission: the parsed form, and the fields the submission named.
419///
420/// Derefs to the form, so a record fn reads `posted.author_id` — the posted
421/// value, or the stored one when the submission did not name the field.
422pub struct Posted<F: RecordForm> {
423 form: F,
424 named: HashSet<F::Field>,
425}
426
427impl<F: RecordForm> Posted<F> {
428 /// A posted form that names `named`.
429 pub fn new(form: F, named: impl IntoIterator<Item = F::Field>) -> Self {
430 Self {
431 form,
432 named: named.into_iter().collect(),
433 }
434 }
435
436 /// Whether the submission named `field`.
437 pub fn named(&self, field: F::Field) -> bool {
438 self.named.contains(&field)
439 }
440
441 /// The update builder assigning every named field, or `None` when the
442 /// submission named none. See [`RecordForm::into_update`].
443 pub fn into_update(self, record: &mut F::Model) -> Option<<F::Model as Model>::Update<'_>> {
444 self.form.into_update(record, &self.named)
445 }
446}
447
448impl<F: RecordForm> std::ops::Deref for Posted<F> {
449 type Target = F;
450
451 fn deref(&self) -> &F {
452 &self.form
453 }
454}
455
456/// A refused submission.
457///
458/// [`Resource::validate_record`](crate::Resource::validate_record) keys it by the record form's
459/// field enum (`FieldErrors<UserFormField>`), so an error always names a field the form renders;
460/// one on an embedded value renders under its first control. The panel keys every error by the
461/// form key it renders under (`FieldErrors`, keyed by `String`), and [`Source::form`] reads them
462/// back when a schema renders.
463///
464/// [`Source::form`]: crate::Source::form
465#[derive(Debug)]
466pub struct FieldErrors<K = String> {
467 errors: Vec<FieldError<K>>,
468}
469
470impl<K> Default for FieldErrors<K> {
471 fn default() -> Self {
472 Self { errors: Vec::new() }
473 }
474}
475
476impl<K> FieldErrors<K> {
477 /// No errors.
478 pub fn new() -> Self {
479 Self::default()
480 }
481
482 /// Refuse `key` with `message`.
483 pub fn add(&mut self, key: impl Into<K>, message: impl Into<String>) {
484 self.errors.push(FieldError::invalid(key, message));
485 }
486
487 /// Refuse `error`'s key with `error`.
488 pub fn push(&mut self, error: FieldError<K>) {
489 self.errors.push(error);
490 }
491
492 /// Whether nothing was refused.
493 pub fn is_empty(&self) -> bool {
494 self.errors.is_empty()
495 }
496
497 /// Every error, in the order it was added.
498 pub fn iter(&self) -> impl Iterator<Item = &FieldError<K>> {
499 self.errors.iter()
500 }
501
502 /// Append `other`'s errors after these.
503 pub fn extend(&mut self, other: Self) {
504 self.errors.extend(other.errors);
505 }
506}
507
508impl<K> IntoIterator for FieldErrors<K> {
509 type Item = FieldError<K>;
510 type IntoIter = std::vec::IntoIter<FieldError<K>>;
511
512 fn into_iter(self) -> Self::IntoIter {
513 self.errors.into_iter()
514 }
515}
516
517impl FieldErrors {
518 /// Whether any error renders under `key`.
519 pub fn contains_key(&self, key: &str) -> bool {
520 self.errors.iter().any(|error| error.key == key)
521 }
522
523 /// The error `key` renders: the first one added.
524 pub fn first(&self, key: &str) -> Option<&FieldError> {
525 self.errors.iter().find(|error| error.key == key)
526 }
527
528 /// Take `other`'s errors, dropping this collection's own errors under every
529 /// key `other` names.
530 ///
531 /// A source that owns a key answers for it: a rejected upload replaces the
532 /// error the emptied control would otherwise report.
533 pub(crate) fn replace(&mut self, other: Self) {
534 let owned: HashSet<&str> = other
535 .errors
536 .iter()
537 .map(|error| error.key.as_str())
538 .collect();
539 self.errors
540 .retain(|kept| !owned.contains(kept.key.as_str()));
541 self.errors.extend(other.errors);
542 }
543}