Rustango docs
← Cookbook

Chapter 7 — Forms + serializer

6 tests covering ModelFormFor<T> parse/from_json/error aggregation/ bound-validation/null handling/insert-query emission. No DB needed. Run with cargo test --test cookbook_chapter07_forms.

The forms core, the serializer derive, and each serializer extension (nested / method / many / validators / ViewSet wiring) all ship with passing tests:

$ cargo test --test cookbook_chapter07_forms \
             --test cookbook_chapter07b_serializer \
             --test cookbook_chapter07f_serializer_method_and_validators \
             --test cookbook_chapter07g_nested_serializer \
             --test cookbook_chapter07h_many_serializer

cookbook_chapter07_forms .................................. ok (6)
cookbook_chapter07b_serializer ............................ ok (6)
cookbook_chapter07f_serializer_method_and_validators ...... ok (4)
cookbook_chapter07g_nested_serializer ..................... ok (4)
cookbook_chapter07h_many_serializer ....................... ok (4)

Three further Chapter 7 suites need a live database, so they are not in the block above — run them with DATABASE_URL set and --test-threads=1.

7c / 7d — the non-admin, user-facing form route

/authors/new and its edit counterpart are tenant-aware form routes outside the admin: they boot a real server, submit real form bodies over HTTP, and assert the redirect and the persisted row. This is the path an end user walks, as opposed to the admin's generated form.

Verified by: tests/cookbook_chapter07c_browser_form.rs (create), tests/cookbook_chapter07d_edit_form.rs (open + edit an existing record).

7e validate_unique_together — friendly errors for a composite UNIQUE

What: The composite UNIQUE index from §2.18b rejects a duplicate (org_id, user_id) pair at the database, and without a pre-check the form re-render carries the raw duplicate key value violates unique constraint "…" string. ModelFormFor::validate_unique_together(&pool, pk_value) walks every composite-unique index on the model and SELECTs the conflicting tuple before the INSERT/UPDATE, turning a hit into per-field FormErrors keyed by every column in the conflict.

When: Any form over a model carrying unique_together. This is the call §2.18b's caveat sends you here for.

pk_value is what excludes the row being edited from its own conflict check — pass the current PK on update, and None on create.

Verified by: tests/cookbook_chapter07e_unique_together_validator.rs — validator_accepts_when_no_existing_pair, validator_rejects_with_per_field_errors_on_create, validator_accepts_different_pair, validator_excludes_own_row_on_update.

  • §7.95 ModelFormFor::<T>::parse(&HashMap<String,String>) — form- encoded payload → (columns, values) with per-field bound validation. Auto PK and auto_now_add columns are skipped (DB fills them). → modelform_parses_form_encoded_into_typed_values
  • §7.95 missing required fields aggregate into FormErrors, one entry per field. → modelform_missing_required_fields_aggregate_errors
  • §7.96 ModelFormFor::<T>::from_json(&serde_json::Value) — JSON request body. Null values on Option<T> write explicit SqlValue::Null. → modelform_from_json_parses_object, modelform_from_json_null_writes_explicit_null
  • §7.98 min/max bounds run at parse time. Out-of-range values land in FormErrors keyed by field. → modelform_bound_violation_lands_in_form_errors
  • §7.99 into_insert_query() emits an InsertQuery against the model's table. → modelform_into_insert_query_targets_model_table

Note: ModelFormFor::parse skips every auto-populated field — auto_now_add timestamps like joined_at: Auto<DateTime<Utc>> and Auto<T> primary keys — because the database fills them on INSERT. Your form only has to carry the fields a user actually enters.

7.99b #[derive(Serializer)] — a JSON façade over a model

6 tests in tests/cookbook_chapter07b_serializer.rs exercise the serializer derive against the cookbook's Author model. Run with cargo test --test cookbook_chapter07b_serializer.

  • from_model + to_value round-trip — every field maps from the model and lands in the JSON output. → serializer_from_model_then_to_value_round_trip
  • #[serializer(read_only)] — excluded from writable_fields(), still appears in JSON output. → read_only_field_omitted_from_writable_fields
  • #[serializer(write_only)] — excluded from JSON output, accepted on input. → write_only_field_excluded_from_json_output
  • #[serializer(source = "x")] — renames the JSON key to a different model field. → source_attribute_renames_json_key
  • #[serializer(skip)] — uses Default::default(), leaves the field in JSON but excludes it from writable_fields(). → skip_field_uses_default_and_appears_in_json_unchanged
  • many_to_value — batches a Vec<Model> into a JSON array. → many_to_value_batches_into_json_array

Beyond the core derive, #[derive(Serializer)] also supports:

  1. Nested FK — #[serializer(nested)] on a field whose type is itself a serializer and whose model source is a ForeignKey<Parent> emits the nesting glue. The FK must be loaded first (.get(&pool) or .select_related(...)). → tests/cookbook_chapter07g_nested_serializer.rs
  2. Computed fields — #[serializer(method = "fn_name")] calls an inherent fn(model: &T) -> FieldType during from_model. → tests/cookbook_chapter07f_serializer_method_and_validators.rs
  3. ViewSet ↔ Serializer wiring — ViewSet::for_model(...).serializer::<S>() runs list/detail output through the serializer instead of the bare model. → tests/cookbook_chapter09b_viewset_serializer.rs
  4. Collections (M2M / one-to-many) — #[serializer(many = ChildSerializer)] emits a typed set_<field>(&mut self, &[ChildModel]) setter; fetch the children (accessors are async), call the setter, then serialize. → tests/cookbook_chapter07h_many_serializer.rs
  5. Per-field validators — #[serializer(validate = "fn_name")] runs a per-field validator, on top of model-level min/max/max_length. → tests/cookbook_chapter07f_serializer_method_and_validators.rs