Enact.InputSchema behaviour (Enact v0.1.0)

Copy Markdown View Source

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's config[: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/2 key 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 engage cast_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

changeset(base, params, mode)

@callback changeset(base :: struct(), params :: map(), mode :: atom()) ::
  Ecto.Changeset.t()

fields(mode)

@callback fields(mode :: atom()) :: [atom()]

from_subject(subject)

(optional)
@callback from_subject(subject :: struct()) :: struct()

partial_embeds(mode)

(optional)
@callback partial_embeds(mode :: atom()) :: [atom()]

Functions

__using__(opts)

(macro)

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.

cast_input(base_or_changeset, params, fields, opts \\ [])

@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:

InputNon-string field:string field:string in keep_empty_strings:
"5", "true"coerced via Ecto.Typevalue as sentvalue as sent
"", " ""is invalid" errornil (empty → clear)"" (empty is a value)
nullnilnilnil
omitteduntoucheduntoucheduntouched

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:string fields whose empty disposition is "" rather than nil, for NOT 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: [].