jonnifyelixir
F6 / F6.03 / changesets

F6.03 · part 2 of 3

Changesets & validation

A changeset is the gate every write passes through, and it is a pure pipeline — data in, a changeset struct out, no side effects. You cast an untrusted map to permit and coerce only the fields you name; you validate the rules — required, length, format, number; and you declare constraints that the database enforces, like uniqueness. The result is a %Ecto.Changeset{} carrying valid? and a list of errors, computed before anything touches the database. Because it is pure, you can test a changeset without a repo and reuse it for both inserts and updates. And the engine’s boundary stays clean: a failed changeset becomes the closed %Portal.Error{} from F5.08, so the web layer sees one error shape, never raw Ecto internals.

See the three stages of the pipeline, follow a map through to a valid-or-invalid changeset, then read a real changeset and how the engine wraps its errors.

The changeset pipeline

Three stages, each a function that takes a changeset and returns one. cast decides which fields are even allowed; validate_* functions check values in Elixir; a constraint defers a check the database is best at, like uniqueness, and turns its error into a changeset error. Select a stage.

The pipeline · select a stage

cast cast(attrs, [:title, :published]) — permit & coerce fields allow-list validate validate_required / validate_length — check the rules in Elixir constraint unique_constraint(:title) — defer to the database on insert

stage: cast

does: permit & coerce fields

cast is your security boundary: a field not in its allow-list is dropped, so a client cannot set :published or an internal flag by smuggling it into the params.

Valid or invalid

The pipeline always returns a changeset; what differs is its valid? flag. A valid changeset is what you hand to Repo.insert; an invalid one carries errors and never reaches the database, so the same value drives both the write and the error message a form shows.

params → cast → validate → %Changeset{valid?}

params untrusted map cast + validate pure pipeline %Changeset{} valid? + errors valid? Repo.insert(cs) {:ok, %Course{}} invalid errors → %Portal.Error{} the pipeline is pure; only Repo.insert touches the database, and only for a valid changeset

One value, two destinations. The valid changeset is the write; the invalid one is the error report — and the engine maps it to the closed error set so the controller branches on %Portal.Error{}, not on Ecto.

A real changeset

A changeset function takes a struct and the attributes and pipes through the stages. At the engine boundary, Repo.insert returns {:ok, struct} or {:error, changeset}, and the engine translates the error case into the closed contract — the same Portal.Error the F5.08 facade already speaks.

import Ecto.Changeset

def changeset(course, attrs) do
  course
  |> cast(attrs, [:title, :published])      # permit only these fields
  |> validate_required([:title])
  |> validate_length(:title, min: 3, max: 120)
  |> unique_constraint(:title)             # enforced by the database on insert
end

# at the engine boundary: a failed changeset becomes the closed contract (F5.08)
case Repo.insert(changeset(%Course{}, attrs)) do
  {:ok, course}       -> {:ok, course}
  {:error, changeset} -> {:error, Portal.Error.from_changeset(changeset)}
end

cast & validate

A pure pipeline produces a changeset with valid? and errors.

one error shape

The engine maps a failed changeset to %Portal.Error{} — never raw Ecto.

Next: queries & the repo — the Repo.insert above is one repo call, and the repo lives behind the engine’s port.