Ecto: Basic schemas and changesets
This post is part of the Ecto Series.
Ecto is a database wrapper and query generator for Elixir. It allows you to define schemas that map to database tables, validate and manipulate data with changesets, and interact with the database in a functional and composable way.
Defining a Schema
Ecto schema basics:
- Defines the module’s struct: Specifies the fields, their types, and default values.
- Maps the struct to a database table: Connects the struct to a corresponding database table.
- Defines relationships: Establishes connections between structs, such as
has_many
,belongs_to
, andhas_one
.
defmodule GameApp.Accounts.Player do
use Ecto.Schema
import Ecto.Changeset
schema "players" do
field :name, :string
field :email, :string
field :score, :integer, default: 0
timestamps()
end
end
In this schema, a player has fields for name
and email
(both strings) and a score
(an integer initialized to zero). Note that while these fields relate to the application code, the database can enforce additional restrictions and validations.
Changesets
A changeset is how the application checks if a version of a record can be modified into a new version. The basic changeset function takes a record and a map of values, merging them and checking if the updated record is valid.
Modifying a Player Record
The Ecto.Changeset
module includes functions such as cast
, validate_required
, and validate_format
.
defmodule GameApp.Accounts.Player do
import Ecto.Changeset
def changeset(player, attrs) do
player
|> cast(attrs, [:name, :score, :email])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
end
end
The changeset
function starts with the player record, merges it with the name
, score
, and email
keys from attrs
, checks if the name
and email
fields are present, and validates that the email
contains an @
sign.
Tailoring Changesets
While it’s possible to manage all modifications with a single changeset, you’ll likely want changesets tailored for specific tasks:
def create_changeset(attrs) do
%__MODULE__{}
|> cast(attrs, [:name, :email, :score])
|> validate_required([:name, :email])
|> validate_email()
|> unique_constraint(:email)
|> put_change_if_nil(:score, 0)
|> validate_score_non_negative()
end
def update_changeset(player, attrs \\ %{}) do
player
|> cast(attrs, [:name, :score])
|> validate_required([:name, :email, :score])
|> validate_score_not_decrease()
end
- create_changeset/1: This changeset handles the specific logic for creating a new record:
- Starts with the module’s struct
%__MODULE__{}
. - Merges the new
name
,email
, andscore
attributes. - Ensures that
name
andemail
are provided. - Validates the
email
and checks that no other person is using it. - Sets the score to
0
if it was initiallynil
. - Confirms that the score is non-negative.
- Starts with the module’s struct
- update_changeset/2: This changeset applies business logic for updating a Player:
- Starts with an existing Player record.
- Merges only the
name
and/orscore
attributes. - Ensures the record includes
name
,email
, andscore
after merging. - Validates that the updated record does not decrease the score.
Validators
The changeset functions as a pipeline, starting with cast
, which takes a struct and returns an Ecto changeset. Validators are designed to be piped together, taking a changeset as the first argument and returning a changeset.
-
validate_email/1: Uses Ecto’s
validate_format/4
to check if the email contains an “@” symbol and adds an error message if not.defp validate_email(changeset) do validate_format(changeset, :email, ~r/@/, message: "must contain @") end
-
validate_score_non_negative/1: Uses Ecto’s
validate_number/4
to ensure the score is greater than or equal to zero.defp validate_score_non_negative(changeset) do validate_number(changeset, :score, greater_than_or_equal_to: 0, message: "Score must be non-negative.") end
-
validate_score_not_decrease/1: A custom function that checks if the score decreases. It takes a
changeset
and returns achangeset
, adding an error if the score decreases.defp validate_score_not_decrease(changeset) do original_score = changeset.data.score updated_score = get_change(changeset, :score, original_score) case {original_score, updated_score} do {nil, _updated_score} -> changeset {_current_score, nil} -> changeset {original, update} when update < original -> add_error(changeset, :score, "Score cannot decrease.") _ -> changeset end end
-
put_change_if_nil/2: A custom function that replaces a
nil
value with a default.defp put_change_if_nil(changeset, field, value) do current_value = get_field(changeset, field) if current_value == nil do put_change(changeset, field, value) else changeset end end
Ecto Migration
Schemas and changesets go hand-in-hand with database migrations:
defmodule GameApp.Repo.Migrations.CreatePlayers do
use Ecto.Migration
def up do
create table(:players) do
add :name, :string
add :email, :string, null: false
add :score, :integer
timestamps(type: :naive_datetime_usec)
end
create unique_index(:players, [:email])
end
def down do
drop_if_exists unique_index(:players, [:email])
drop table(:players)
end
end
Clear and Reversible Migrations
Whenever possible, explicitly define both up
and down
functions.
-
Predictability and Safety: Before applying a migration, ensure it can be reversed. This is especially crucial in production environments where writing reverse logic under pressure can lead to mistakes.
-
Granular Control: If data transformations are needed, having separate
up
anddown
functions allows you to manage them more effectively. -
Documentation: Migration functions also serve as documentation, making it easier to understand the changes and how they can be undone.
Decoupling from Application Code
Migrations should never rely on application code.
-
Stability and Consistency: Application code evolves over time, potentially breaking any migration that depends on it. Each migration should be self-contained and independent to ensure stability and reliability.
-
Simplified Testing and Debugging: Isolated migrations are easier to test and debug, making it straightforward to identify and resolve issues.
Calling the Database
By definition, interacting with a database involves I/O operations, which require control flow to handle potential failures.
In functional programming, it’s typical to include both success and failure in the result, requiring the caller to distinguish between the two cases.
-
create_player/1: Takes player attributes, creates a new player record, and applies the
create_changeset
. If successful, it returns{:ok, result}
; on failure, it returns{:error, reason}
.def create_player(attrs \\ %{}) do attrs |> Player.create_changeset() |> Repo.insert() end
Note that this function takes a
map
rather than a Playerstruct
. The struct includes internal information such as timestamps, which we do not want to set to defaults or overwrite. -
update_player!/2: Takes an existing player record and attributes to update that record.
This function uses Elixir’s bang (
!
) convention, signaling that it may fail and raise an error.def update_player!(%Player{} = player, attrs \\ %{}) do changeset = player |> Player.update_changeset(attrs) Repo.update!(changeset) end
This function takes two arguments: a Player struct and a map of attributes. It returns the updated Player on success or raises an error on failure.
Ecto changeset
functions wrap database calls with validations at the application layer. If a changeset fails, it short-circuits before calling the database
Note that the database can also fail, such as being unavailable or enforcing further validity checks.
Concurrency
The update_player!
function ensures that a player’s score can never decrease. However, if two users update the score simultaneously, the last update will override the previous one, regardless of which score is higher, creating a concurrency problem.
To mitigate this issue, you can modify update_player!
to check the current score in the database before updating it:
def update_player!(%Player{} = player, attrs) do
current_player = Repo.get!(Player, player.id)
current_player
|> Player.update_changeset(attrs)
|> Repo.update!()
end
Note: This reduces the concurrency problem but does not eliminate it. To fully address the issue, you’ll need a more sophisticated strategy, such as adding optimistic locking to the database, which allows you to verify that the Player struct you’re updating is, in fact, the latest version.