The contract for top-level input-schema modules — plain Ecto embedded
schemas that adopt this behaviour via use Enact.InputSchema, which
sets @behaviour and imports cast_input/4 and nothing more.
use Ecto.Schema, import Ecto.Changeset, and @primary_key false
stay explicit in the module. Plain @behaviour + import remains
equivalent.
The callbacks
changeset/3—(base, params, mode); the runner passes the action'sconfig[:mode]verbatim. Write one head per mode; per-mode deltas are data (cast lists / required lists), not conditionals.fields/1— the mode-specific castable field list. This is the introspection surface (__schema__(:fields)cannot distinguish create-castable from patch-castable);Enact.updates/2key selection and host-app reconciliation tests read it.from_subject/1— required iff a patch-mode action uses the module; create-only modules may omit it.
The validation base
The base argument exists only so validations see result-state;
extraction (Enact.updates/2) ignores the changeset's diff entirely and
keys off presence in raw params. (The system-level walkthrough of how
base, cast, presence, and extraction interact lives in the
Change Detection guide.)
In create mode the base is the empty struct. In patch mode it is
from_subject(ctx.subject) — an explicit, total projection of the loaded
subject into the input representation (public-ID rendering, renames,
representation conversions live here and nowhere else). With the base in
place, validate_required and get_field/2-based cross-field rules work
unmodified on PATCH: an omitted-but-populated required field passes, an
explicit nil-clear fails, and get_field returns the value the record
will have.
from_subject/1 must guarantee:
- totality over scalar fields — every scalar field gets a non-nil
value when the subject has one; a forgotten field silently revives
the nil-clear-vs-omitted ambiguity for that field (host apps test
this via projection-completeness, and blind struct-copying like
Map.take(subject, fields)is forbidden as an implementation) - embeds are never seeded — they stay at structural defaults
(
[]/nil); array semantics are replace-wholesale, and seeding would engagecast_embed's diff-by-identity machinery
Why no field defaults
Omitted fields are excluded from extraction by presence, so a schema
default never persists — it would only mislead validations into seeing a
value the write will not contain. Defaults live in the DB column (or
persistence schema); Enact.Guardrails enforces this.
Nested item schemas
Deliberate asymmetry: nested item modules (invoked by cast_embed, not
the runner) are mode-blind and implement Ecto's native changeset/2 —
they do not adopt this behaviour. They take the bare
import Enact.InputSchema and still cast with cast_input/4, since
item fields have the same empty-string concerns as top-level fields. If
an item schema is later promoted to a top-level input for some action,
it gains a changeset/3 alongside its changeset/2 — same module,
both contracts, no conflict.
Casting
cast_input/4, defined on this module and imported by input modules, is
the casting entry for scalar fields: Ecto.Changeset.cast/4 with JSON
API empty-string semantics derived from field types. See its
documentation for the exact behavior table.
Partial embeds
The default embed contract is replace-wholesale. The optional
partial_embeds/1 manifest declares embeds_one fields that accept
partial objects instead: Enact.updates/2 filters their sub-keys by
presence in raw params, so the updates map (and therefore previews and
confirmation digests) contains exactly the sub-keys the caller sent. An
explicitly-null sub-key is present as nil (a clear); an omitted
sub-key is absent (untouched), matching the omitted-vs-null handling of
top-level fields. execute/2 merges the partial object over the
subject's current value via Enact.merged/4.
embeds_many fields cannot be declared partial — merging arrays
requires item identity, which is a different contract. Validations still
see the partially-cast object (the base never seeds embeds); rules about
the merged result use Enact.merged/4 in the action's validate/2,
and the execute-side merge uses the same function.
Summary
Functions
Adopts the contract: sets @behaviour Enact.InputSchema and imports
cast_input/4, nothing more.
Casts scalar params with JSON API semantics.
Callbacks
Functions
Adopts the contract: sets @behaviour Enact.InputSchema and imports
cast_input/4, nothing more.
Expands to exactly:
@behaviour Enact.InputSchema
import Enact.InputSchema, only: [cast_input: 3, cast_input: 4]use Ecto.Schema, import Ecto.Changeset, and @primary_key false
stay explicit in the module.
@spec cast_input(struct() | Ecto.Changeset.t(), map(), [atom()], keyword()) :: Ecto.Changeset.t()
Casts scalar params with JSON API semantics.
A replacement for Ecto.Changeset.cast/4 in input-module changeset
heads. Ecto's stock cast implements HTML-form semantics: "" on any
field means "no value" and silently becomes nil, regardless of field
type. cast_input/4 derives empty-string handling from each field's
schema type instead:
| Input | Non-string field | :string field | :string in keep_empty_strings: |
|---|---|---|---|
"5", "true" | coerced via Ecto.Type | value as sent | value as sent |
"", " " | "is invalid" error | nil (empty → clear) | "" (empty is a value) |
null | nil | nil | nil |
| omitted | untouched | untouched | untouched |
Values are never modified: casting interprets input, it does not
transform it. A :string value is checked for emptiness with a trimmed
test (matching Ecto's own default, so " " counts as empty), but a
non-empty value passes through exactly as sent — " hi " keeps its
whitespace. Data hygiene such as trimming is a separate, explicit
concern for changeset heads or validations, not a cast side effect.
One option (anything else raises):
:keep_empty_strings—:stringfields whose empty disposition is""rather thannil, forNOT NULL DEFAULT ''columns where the empty string is a value
Option fields must be :string fields on the schema; listing a field
outside a given head's cast list is allowed and inert, so option
attributes can be shared across mode heads with differing cast lists.
Normalization applies only to fields whose schema type is literally
:string and whose param value is a binary; custom types receive the
raw value and apply their own cast/1. :binary fields keep "" as a
value (it is a valid binary). Array fields are untouched: elements are
neither coalesced nor dropped — a "" element in an
{:array, :string} field passes through, so validate against empty
elements where they matter. Embed fields are not accepted — cast them
with Ecto.Changeset.cast_embed/3 as usual. Presence semantics are
unchanged: normalization rewrites values, never adds or removes keys,
so an empty string coalescing to nil still records a presence-visible
nil-clear.
Because validate_required/2 consults the changeset's stored
empty_values, a literal "" value satisfies required-ness when
cast_input/4 created the changeset (the documented pattern — base as
the first argument). For plain :string fields this changes nothing
(empties are already nil before validation); for
keep_empty_strings: fields it means validate_required expresses
"never nil, "" allowed". Note the stored empty_values comes from
whichever cast created the changeset — piping a stock cast into
cast_input keeps the stock default, under which "" fails
required-ness.
Required-ness, defaults, and null-rejection are out of scope; they stay
in changeset heads, DB columns, and action validate/2 respectively.
Enact.Test.assert_rejects_empty_strings/3 verifies the empty-string
outcome regardless of whether a module uses cast_input/4 or a stock
cast with empty_values: [].