--- name: ecto-changeset-patterns description: Use when a resource needs multiple changesets (registration vs update), conditional validation, field transforms, or uniqueness validation — changeset composition. file_patterns: - "**/schemas/**/*.ex" - "**/accounts/*.ex" - "**/*_schema.ex" auto_suggest: true --- # Ecto Changeset Patterns ## RULES — Follow these with no exceptions 1. **Create separate named changesets per operation** — `registration_changeset`, `email_changeset`, `password_changeset`; never overload a single `changeset/2` 2. **Never require foreign key fields in `cast_assoc` child changesets** — the parent sets them automatically; requiring them causes "can't be blank" errors 3. **Compose changesets with pipes** — each validation step is a separate function for reuse and clarity 4. **Use `unsafe_validate_unique` paired with `unique_constraint`** — never one without the other; `unsafe_validate_unique` gives fast UI feedback, `unique_constraint` handles race conditions 5. **Use `update_change/3` for field transformations** — trimming, downcasing, slugifying happen in the changeset, never in the controller or context 6. **Accept `opts \\ []` for conditional validation** — allows callers to toggle validation rules without creating yet another changeset function 7. **Validate at the changeset level, not in context functions** — context functions should be thin wrappers around `Repo` calls --- ## Separate Changesets Per Operation Different operations need different validation rules. Don't overload `changeset/2`. ```elixir defmodule MyApp.Accounts.User do use Ecto.Schema import Ecto.Changeset schema "users" do field :email, :string field :username, :string field :password, :string, virtual: true, redact: true field :hashed_password, :string, redact: true field :bio, :string timestamps() end # Registration — all fields, password hashing def registration_changeset(user, attrs, opts \\ []) do user |> cast(attrs, [:email, :username, :password]) |> validate_email(opts) |> validate_username() |> validate_password(opts) end # Email change — only email, requires reconfirmation def email_changeset(user, attrs, opts \\ []) do user |> cast(attrs, [:email]) |> validate_email(opts) end # Password change — only password def password_changeset(user, attrs, opts \\ []) do user |> cast(attrs, [:password]) |> validate_password(opts) |> put_password_hash() end # Profile update — non-sensitive fields only def profile_changeset(user, attrs) do user |> cast(attrs, [:username, :bio]) |> validate_username() end end ``` --- ## cast_assoc — Critical Pitfall The most common source of "can't be blank" errors. Foreign keys are set automatically by the parent — never require them in the child changeset. ```elixir # Parent schema defmodule MyApp.Blog.Post do schema "posts" do field :title, :string has_many :ingredients, MyApp.Blog.Ingredient timestamps() end def changeset(post, attrs) do post |> cast(attrs, [:title]) |> validate_required([:title]) |> cast_assoc(:ingredients, with: &MyApp.Blog.Ingredient.changeset/2) end end # Child schema — DO NOT require :post_id defmodule MyApp.Blog.Ingredient do schema "ingredients" do field :name, :string field :quantity, :string belongs_to :post, MyApp.Blog.Post timestamps() end # Bad — :post_id is required but set automatically by cast_assoc def changeset(ingredient, attrs) do ingredient |> cast(attrs, [:name, :quantity, :post_id]) |> validate_required([:name, :post_id]) # Fails! end # Good — only require user-provided fields def changeset(ingredient, attrs) do ingredient |> cast(attrs, [:name, :quantity]) |> validate_required([:name]) end end ``` --- ## Changeset Composition Break validation into small, reusable functions. Compose with pipes. ```elixir defmodule MyApp.Accounts.User do # Reusable validation components defp validate_email(changeset, opts) do changeset |> validate_required([:email]) |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") |> validate_length(:email, max: 160) |> maybe_validate_unique_email(opts) end defp validate_username(changeset) do changeset |> validate_required([:username]) |> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "only letters, numbers, and underscores") |> validate_length(:username, min: 3, max: 30) |> unsafe_validate_unique(:username, MyApp.Repo) |> unique_constraint(:username) end defp validate_password(changeset, opts) do changeset |> validate_required([:password]) |> validate_length(:password, min: 8, max: 72) |> maybe_hash_password(opts) end defp maybe_validate_unique_email(changeset, opts) do if Keyword.get(opts, :validate_email, true) do changeset |> unsafe_validate_unique(:email, MyApp.Repo) |> unique_constraint(:email) else changeset end end defp maybe_hash_password(changeset, opts) do if Keyword.get(opts, :hash_password, true) && changeset.valid? do changeset |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(get_change(changeset, :password))) |> delete_change(:password) else changeset end end end ``` --- ## Conditional Validation with opts Use `opts` to toggle validation behavior from the caller. This avoids creating a new changeset function for every variation. ```elixir # In the schema module def registration_changeset(user, attrs, opts \\ []) do user |> cast(attrs, [:email, :username, :password]) |> validate_email(opts) |> validate_password(opts) end # In the context — normal registration def register_user(attrs) do %User{} |> User.registration_changeset(attrs) |> Repo.insert() end # In tests — skip hashing for speed def register_user_for_test(attrs) do %User{} |> User.registration_changeset(attrs, hash_password: false, validate_email: false) |> Repo.insert() end ``` --- ## Field Transformations with update_change Transform field values in the changeset, not in the controller or LiveView. ```elixir def changeset(user, attrs) do user |> cast(attrs, [:email, :username]) |> update_change(:email, &String.downcase/1) |> update_change(:username, &String.trim/1) |> update_change(:username, &String.downcase/1) end # For slugs def changeset(post, attrs) do post |> cast(attrs, [:title]) |> validate_required([:title]) |> generate_slug() end defp generate_slug(changeset) do case get_change(changeset, :title) do nil -> changeset title -> slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") |> String.trim("-") put_change(changeset, :slug, slug) end end ``` --- ## Uniqueness Validation Always pair `unsafe_validate_unique` with `unique_constraint`. They serve different purposes. ```elixir def changeset(user, attrs) do user |> cast(attrs, [:email, :username]) # Fast check — queries DB, gives immediate UI feedback # "unsafe" because another insert could happen between check and insert |> unsafe_validate_unique(:email, MyApp.Repo) |> unsafe_validate_unique(:username, MyApp.Repo) # Constraint check — catches race conditions at insert time # Requires a matching unique index in the database |> unique_constraint(:email) |> unique_constraint(:username) end ``` --- ## Testing Changesets ```elixir describe "registration_changeset/2" do test "valid with all required fields" do changeset = User.registration_changeset(%User{}, %{ email: "test@example.com", username: "testuser", password: "validpassword123" }) assert changeset.valid? end test "invalid without email" do changeset = User.registration_changeset(%User{}, %{ username: "testuser", password: "validpassword123" }) refute changeset.valid? assert "can't be blank" in errors_on(changeset).email end test "transforms email to lowercase" do changeset = User.email_changeset(%User{}, %{email: "TEST@Example.COM"}) assert get_change(changeset, :email) == "test@example.com" end end ``` --- See `ecto-essentials` skill for schema and migration patterns. See `ecto-nested-associations` skill for `cast_assoc` with nested data. See `testing-essentials` skill for comprehensive testing patterns.