# `Enact.InputSchema`
[🔗](https://github.com/svycal/enact/blob/v0.1.0/lib/enact/input_schema.ex#L1)

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](change-detection.html) 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.

# `changeset`

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

# `fields`

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

# `from_subject`
*optional* 

```elixir
@callback from_subject(subject :: struct()) :: struct()
```

# `partial_embeds`
*optional* 

```elixir
@callback partial_embeds(mode :: atom()) :: [atom()]
```

# `__using__`
*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`

```elixir
@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` — `: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: []`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
