elixirphoenix-frameworkecto

How to add an associated object and validate both the child, and the length of the association?


I'm pretty new to Elixir, Phoenix and Ecto (much more comfortable with Rails), and I'm finding it hard to wrap my head around changesets and validations, especially when it comes to associated objects.

Suppose I have the following Ecto schemas:

defmodule Game do
  use Ecto.Schema
  import Ecto.Changeset

  schema "games" do
    field :game_name, :string
    has_many :players, Player

    timestamps(type: :utc_datetime)
  end
end

defmodule Player do
  use Ecto.Schema
  import Ecto.Changeset

  schema "players" do
    field :player_name, :string
    belongs_to :game, Game

    timestamps(type: :utc_datetime)
  end
end

Further, it's business logic that Game should have a maximum of 4 players; and in both objects name is required.

I receive a POST with %{"game_id" => 1, "player" => %{"name" => "Alice"}}. How do I build and insert a new Player into Game 1, making sure both that:


Solution

  • Read Validating changes as an introduction to Ecto changesets and validations. Validating the presence of the player_name should not be too hard (validate_required).

    Your second question requires some more work though. :)

    How to validate that the game has no more than 4 players

    Ecto has no built-in validation to do that, you have to create a database trigger function in a migration and call check_constraint in the Player changeset.

    Create a migration with a trigger function

    First create a new migration for the trigger function with mix ecto.gen.migration CreateTriggerMaxFourPlayersPerGame and put this in:

      def change do
        # function that raises when the number of players per game is more than four
        execute(
          ~S"""
          CREATE OR REPLACE FUNCTION max_players_per_game_check()
            RETURNS TRIGGER
            LANGUAGE plpgsql
          AS $$
          DECLARE
            max_players_allowed integer = 4;
            number_of_players integer;
          BEGIN
            IF NEW.game_id IS NOT NULL THEN
              SELECT COUNT(*)
              INTO number_of_players
              FROM players
              WHERE game_id = NEW.game_id;
    
              IF number_of_players > max_players_allowed THEN
                RAISE EXCEPTION 'CUSTOM ERROR'
                USING ERRCODE = 'check_violation',
                CONSTRAINT = 'max_players_per_game';
              END IF;
            END IF;
            RETURN NEW;
          END;
          $$
          """,
    
          ~S"""
          DROP FUNCTION max_players_per_game_check;
          """
        )
    
        # run trigger on inserts and updates
        execute(
          ~S"""
          CREATE OR REPLACE TRIGGER max_players_per_game
          AFTER INSERT OR UPDATE ON players
          FOR EACH ROW
            EXECUTE PROCEDURE max_players_per_game_check();
          """,
          ~S"""
          DROP TRIGGER max_players_per_game ON players;
          """
        )
      end
    

    Run the migration with mix ecto.migrate.

    This creates a trigger that tells the database, after a player is inserted or updated, to count the number of players for that game and raise a constraint error named max_players_per_game when the player count exceeds 4. The insert/update will be rolled back when this happens.

    Add a check_constraint to Player.changeset

    To make your application aware of this constraint error, the changeset function in player.ex should look like this (the check_constraint function is where it happens):

      def changeset(player, params \\ %{}) do
        player
        |> cast(params, [:player_name, :game_id])
        |> validate_required([:player_name, :game_id])
        |> foreign_key_constraint(:game_id)
        |> check_constraint(
          :game_id,
          message: "maximum of 4 players per game is reached",
          name: :max_players_per_game
        )
      end
    

    Now, when you add a 5th player to a game, you get back an invalid changeset with an error for the game_id attribute:

    #Ecto.Changeset<
      action: :insert,
      changes: %{game_id: 48, player_name: "Player 5"},
      errors: [
        game_id: {"maximum of 4 players per game is reached",
         [constraint: :check, constraint_name: "max_players_per_game"]}
      ],
      data: #Player<>,
      valid?: false,
      ...
    >
    

    In a Phoenix application

    In a typical Phoenix application, this will be handled in a controller action where you receive the POST request with parameters:

    defmodule MyAppWeb.PlayerController do
      use MyAppWeb, :controller
    
      def create(conn, %{"player_params" => player_params} do
        case MyApp.Games.create_player(player_params) do
          {:ok, player} ->
            conn
            |> put_flash(:info, "Player created successfully.")
            |> redirect(to: ~p"/players/#{player}")
    
          {:error, %Ecto.Changeset{} = changeset} ->
            render(conn, :new, changeset: changeset)
        end
      end
    end
    

    (By the way, I'm assuming that you receive %{"player_params => %{"game_id" => 1, "player_name" => "Alice"}} here, but with some pattern matching you can also make it work for the format you described.)

    If you used the generators provided by Phoenix like mix phx.gen.html, there is a create_player function somewhere in a context module:

    def MyApp.Games do
      # ...
    
      def create_player(attrs \\ %{}) do
        %Player{}
        |> Player.changeset(attrs)
        |> Repo.insert()
      end
    end
    

    PS

    Yes, this looks a bit intimidating when you are just getting started with Elixir and Ecto, especially the migration with the trigger function. But using triggers is something you rarely do, it's just that enforcing this on the database level is the only way to prevent race conditions. For other types of validations, Ecto.Changeset has a lot of nice functions ready to use.