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.

See the code

See the documentation

Defining a Schema

Ecto schema basics:

  1. Defines the module’s struct: Specifies the fields, their types, and default values.
  2. Maps the struct to a database table: Connects the struct to a corresponding database table.
  3. Defines relationships: Establishes connections between structs, such as has_many, belongs_to, and has_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:
    1. Starts with the module’s struct %__MODULE__{}.
    2. Merges the new name, email, and score attributes.
    3. Ensures that name and email are provided.
    4. Validates the email and checks that no other person is using it.
    5. Sets the score to 0 if it was initially nil.
    6. Confirms that the score is non-negative.
  • update_changeset/2: This changeset applies business logic for updating a Player:
    1. Starts with an existing Player record.
    2. Merges only the name and/or score attributes.
    3. Ensures the record includes name, email, and score after merging.
    4. 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 a changeset, 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 and down 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 Player struct. 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.