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:
name
presentRead 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. :)
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.
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.
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 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
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.