- name
- ecto-changesets
- user-invocable
- false
- description
- Use when validating and casting data with Ecto changesets including field validation, constraints, nested changesets, and data transformation. Use for ensuring data integrity before database operations.
- allowed-tools
- ["Bash","Read"]
# Ecto Changesets
Master Ecto changesets to validate, cast, and transform data before database operations.
This skill covers changeset creation, validation, constraints, handling associations,
and advanced patterns for maintaining data integrity.
## Basic Changeset
```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
field :age, :integer
timestamps()
end
def changeset(user, params \\ %{}) do
user
|> cast(params, [:name, :email, :age])
|> validate_required([:name, :email])
end
end
# Usage
changeset = MyApp.User.changeset(%MyApp.User{}, %{name: "John", email: "john@example.com"})
```
Changesets filter and validate parameters before they're applied to a struct.
The `cast/3` function specifies which fields can be changed, and `validate_required/2`
ensures specific fields are present.
## Creating and Validating Changesets
```elixir
defmodule MyApp.Person do
use Ecto.Schema
import Ecto.Changeset
schema "people" do
field :first_name, :string
field :last_name, :string
field :age, :integer
timestamps()
end
def changeset(person, params \\ %{}) do
person
|> cast(params, [:first_name, :last_name, :age])
|> validate_required([:first_name, :last_name])
|> validate_number(:age, greater_than_or_equal_to: 0)
end
end
# Create changeset
changeset = MyApp.Person.changeset(%MyApp.Person{}, %{first_name: "Jane"})
# Check validity
changeset.valid? # false, last_name is missing
# Access errors
changeset.errors
# [first_name: {"can't be blank", [validation: :required]},
# last_name: {"can't be blank", [validation: :required]}]
```
The `valid?` field indicates whether the changeset has any errors. The `errors`
field contains a keyword list of validation failures with error messages and metadata.
## Inserting with Changesets
```elixir
person = %MyApp.Person{}
changeset = MyApp.Person.changeset(person, %{
first_name: "John",
last_name: "Doe",
age: 30
})
case MyApp.Repo.insert(changeset) do
{:ok, person} ->
# Successfully inserted
IO.puts("Created person with ID: #{person.id}")
{:error, changeset} ->
# Validation or constraint errors
IO.inspect(changeset.errors)
end
```
The `Repo.insert/1` function accepts a changeset and returns `{:ok, struct}` on
success or `{:error, changeset}` on failure. Pattern matching makes error handling
straightforward.
## Updating with Changesets
```elixir
person = MyApp.Repo.get!(MyApp.Person, 1)
changeset = MyApp.Person.changeset(person, %{age: 31})
case MyApp.Repo.update(changeset) do
{:ok, updated_person} ->
# Successfully updated
IO.puts("Updated person age to: #{updated_person.age}")
{:error, changeset} ->
# Validation or constraint errors
IO.inspect(changeset.errors)
end
```
Updates work similarly to inserts, but start with an existing struct from the
database. The changeset tracks which fields have changed.
## Type Casting
```elixir
changeset = Ecto.Changeset.cast(%MyApp.User{}, %{"age" => "25"}, [:age])
user = MyApp.Repo.insert!(changeset)
user.age # 25 (integer, not string)
```
The `cast/3` function automatically converts parameter values to their schema-defined
types. Strings like "25" are converted to integers when the field type is `:integer`.
## Field Validations
```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :username, :string
field :age, :integer
field :bio, :string
field :website, :string
timestamps()
end
def changeset(user, params \\ %{}) do
user
|> cast(params, [:email, :username, :age, :bio, :website])
|> validate_required([:email, :username])
|> validate_format(:email, ~r/@/)
|> validate_length(:username, min: 3, max: 20)
|> validate_length(:bio, max: 500)
|> validate_number(:age, greater_than: 0, less_than: 150)
|> validate_inclusion(:age, 18..100)
|> validate_format(:website, ~r/^https?:\/\//)
end
end
```
Ecto provides many built-in validators including `validate_format/3` for regex
patterns, `validate_length/3` for string lengths, `validate_number/3` for numeric
constraints, and `validate_inclusion/3` for allowed values.
## Custom Validation Functions
```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true
field :password_hash, :string
timestamps()
end
def changeset(user, params \\ %{}) do
user
|> cast(params, [:email, :password])
|> validate_required([:email, :password])
|> validate_email_format()
|> validate_password_strength()
|> hash_password()
end
defp validate_email_format(changeset) do
changeset
|> validate_format(:email, ~r/@/, message: "must be a valid email")
|> validate_length(:email, max: 255)
end
defp validate_password_strength(changeset) do
validate_change(changeset, :password, fn :password, password ->
cond do
String.length(password) < 8 ->
[password: "must be at least 8 characters"]
not String.match?(password, ~r/[A-Z]/) ->
[password: "must contain at least one uppercase letter"]
not String.match?(password, ~r/[0-9]/) ->
[password: "must contain at least one number"]
true ->
[]
end
end)
end
defp hash_password(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, hash_password_value(password))
_ ->
changeset
end
end
defp hash_password_value(password) do
# Use a real hashing library like Argon2 or Bcrypt
:crypto.hash(:sha256, password) |> Base.encode64()
end
end
```
Custom validation functions use `validate_change/3` to add custom logic. The
`put_change/3` function modifies changeset values, useful for transformations
like password hashing.
## Constraint Validations
```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :username, :string
timestamps()
end
def changeset(user, params \\ %{}) do
user
|> cast(params, [:email, :username])
|> validate_required([:email, :username])
|> unique_constraint(:email)
|> unique_constraint(:username)
end
end
# Usage
case MyApp.Repo.insert(changeset) do
{:ok, user} ->
# Success
{:error, changeset} ->
# Will contain unique constraint error if email/username exists
changeset.errors
end
```
Constraint validations check database-level constraints like unique indexes.
They only run when the changeset is inserted or updated, not during validation.
## Unique Constraint with Custom Error Message
```elixir
def changeset(user, params \\ %{}) do
user
|> cast(params, [:email])
|> validate_required([:email])
|> unique_constraint(:email,
name: :users_email_index,
message: "has already been taken")
end
```
The `unique_constraint/3` function accepts options to specify the constraint name
and customize the error message. This maps database constraint violations to
user-friendly errors.
## Foreign Key Constraints
```elixir
defmodule MyApp.Comment do
use Ecto.Schema
import Ecto.Changeset
schema "comments" do
field :body, :string
belongs_to :post, MyApp.Post
timestamps()
end
def changeset(comment, params \\ %{}) do
comment
|> cast(params, [:body, :post_id])
|> validate_required([:body, :post_id])
|> foreign_key_constraint(:post_id)
end
end
```
The `foreign_key_constraint/3` function validates that foreign key relationships
are valid. If you try to create a comment with a non-existent post_id, the
constraint will catch it.
## Check Constraints
```elixir
def changeset(product, params \\ %{}) do
product
|> cast(params, [:price, :discount_price])
|> validate_required([:price])
|> check_constraint(:discount_price,
name: :discount_price_must_be_less_than_price,
message: "must be less than the regular price")
end
```
Check constraints validate arbitrary database-level rules. The constraint must
be defined in your migration with the same name.
## Changeset Composition
```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true
field :password_hash, :string
field :role, :string
timestamps()
end
def registration_changeset(user, params \\ %{}) do
user
|> cast(params, [:email, :password])
|> validate_required([:email, :password])
|> validate_email()
|> validate_password()
|> hash_password()
|> put_change(:role, "user")
end
def admin_changeset(user, params \\ %{}) do
user
|> cast(params, [:email, :password, :role])
|> validate_required([:email, :role])
|> validate_email()
|> validate_inclusion(:role, ["user", "admin", "moderator"])
|> maybe_hash_password()
end
defp validate_email(changeset) do
changeset
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
defp validate_password(changeset) do
validate_length(changeset, :password, min: 8)
end
defp hash_password(changeset) do
case get_change(changeset, :password) do
nil -> changeset
password -> put_change(changeset, :password_hash, hash(password))
end
end
defp maybe_hash_password(changeset) do
case get_change(changeset, :password) do
nil -> changeset
password -> hash_password(changeset)
end
end
defp hash(password), do: :crypto.hash(:sha256, password)
end
```
Different changesets can be used for different contexts (registration vs. admin
updates). This keeps validation logic focused and prevents unintended changes.
## Embedded Changeset Validation
```elixir
defmodule MyApp.UserProfile do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :online, :boolean
field :dark_mode, :boolean
field :visibility, Ecto.Enum, values: [:public, :private, :friends_only]
end
def changeset(profile, attrs \\ %{}) do
profile
|> cast(attrs, [:online, :dark_mode, :visibility])
|> validate_required([:online, :visibility])
end
end
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :full_name, :string
field :email, :string
embeds_one :profile, MyApp.UserProfile
timestamps()
end
def changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:full_name, :email])
|> cast_embed(:profile, required: true)
end
end
# Usage
changeset = MyApp.User.changeset(%MyApp.User{}, %{
full_name: "John Doe",
email: "john@example.com",
profile: %{online: true, visibility: :public}
})
changeset.valid? # true
```
The `cast_embed/3` function validates embedded schemas using their own changeset
functions. Validation errors in embedded data propagate to the parent changeset.
## Custom Embedded Changeset Function
```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :full_name, :string
field :email, :string
embeds_one :profile, Profile do
field :online, :boolean
field :dark_mode, :boolean
field :visibility, Ecto.Enum, values: [:public, :private, :friends_only]
end
timestamps()
end
def changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:full_name, :email])
|> cast_embed(:profile, required: true, with: &profile_changeset/2)
end
Auf GitHub ansehen