Where's the fun of winning if people can't know who lost to you? With sportsmanship in our best interest, let's start recording players and outcomes for matches and in the meantime, we will learn some interesting stuff from Ecto and LiveView.
Storing new stuff means we need a new model
Currently, we have a User model and two other related models that Phoenix created for auth stuff. I just told you we need to store match outcomes so that means we need to create a new model. Remember I told you that to change a model we need a migration? The same goes to create a new one.
We are going to create a table called matches
that will contain 3 pieces of information: who's player one, who's player two and what's the result of that match. We start that by generating the migration file:
$ mix ecto.gen.migration create_matches
* creating priv/repo/migrations/20230702143738_create_matches.exs
Now we need to edit that to add the information we want:
defmodule Champions.Repo.Migrations.CreateMatches do
use Ecto.Migration
def change do
create table(:matches) do
add :result, :string
add :user_a_id, references(:users, on_delete: :delete_all)
add :user_b_id, references(:users, on_delete: :delete_all)
timestamps()
end
create index(:matches, [:user_a_id])
create index(:matches, [:user_b_id])
end
end
Just a quick reminder: the change
method means Ecto knows how to run both the migration and rollback methods for this. Since we are using create table(:matches)
ecto knows that migrating means create table matches…
and rolling back means drop table matches
.
As for the fields, the first one will be called :result
which we will talk more about later. As for the other fields we have :user_a_id
and :user_b_id
both using the function references/2 to link those IDs to the users
table. Let's talk about these.
First of all the naming choice. I've been calling our users as 'players' sometimes and it definitely would make sense to call those player_a_id
and player_b_id
. I just think it's easier to reason about this code if the field is called user
since the referenced table is called users
.
The other thing you might have paid attention to is the keyword on_delete: :delete_all
. That means if that specific user were to be deleted this match will also be deleted. This might sound scary right now but I'm making a decision of making user records not delectable. That would cause issues with things like GDPR but since I won't be risking making my database invalid, when the time comes for the app to need users to be able to delete all their data I'll go for the path of obfuscating all Personal Identifiable Information (PII) so John Doe would become 'Unknown User ABC'.
We also add indexes to the foreign keys so it's faster to search for them and trust me we are going to be a lot of listing on those. Don't forget to run mix ecto.migrate
. If you do it's no big issue, Phoenix will show a big error message with a button to run this the next time you go to your app.
Now there are 3 implicit columns being created there. First of all, when you use table/2 on an Ecto migration, you're by default creating a field called id
. You can opt-out of that but in this case, we want this so we can easily sort, locate, delete, and update them. Next, there's timestamps/1 which adds inserted_at
and updated_at
fields. inserted_at
will be great to know when a match was declared and updated_at
could be useful in the future to track updates when this table gets more features.
Contexts strike again
In the Phoenix world, context modules dictate the business rules of our app. Up until today we only used the generated Champions.Accounts
module because we only edited things on our users
table. That made sense at that time but we are outgrowing the Accounts module and starting something bigger and more and more not so related to the main goal of Accounts which is managing users.
We will start a new context called Ranking that will contain things related to matches and points. All you need to do to create a new module is create a new file under lib/champions
.
# lib/champions/ranking.ex
defmodule Champions.Ranking do
@moduledoc """
The Ranking context.
"""
end
Later on, we will reason about moving functions from Accounts to here but let's focus on the new code first.
An Ecto migration needs an Ecto model
Ecto migrations only change how data would be stored in your database, you can say it uses Data Definition Language (DDL) if you're into details. But after that, you need to prepare our Elixir app to manage that data, which you can call Data Manipulation Language (DML) if you want to be fancy.
The first step to creating a model is to define the module. Since we are working under the Champions.Ranking
module it makes sense for our model to live under Champions.Ranking.Match
and of course, the folder structure should match so the file will live under lib/champions/ranking/
.
# lib/champions/ranking/match.ex
defmodule Champions.Ranking.Match do
use Ecto.Schema
import Ecto.Changeset
schema "matches" do
field :result, Ecto.Enum, values: [:winner_a, :winner_b, :draw]
field :user_a_id, :integer
field :user_b_id, :integer
timestamps()
end
@doc false
def changeset(match, attrs) do
match
|> cast(attrs, [:result, :user_a_id, :user_b_id])
|> validate_required([:result, :user_a_id, :user_b_id])
|> foreign_key_constraint(:user_a_id)
|> foreign_key_constraint(:user_b_id)
end
end
There's a lot to unpack here, some of those things you've already seen before but lets take our time to analyze everything bit by bit here.
First of all, this is an elixir module. There's no magic here, defmodule Champions.Ranking.Match
says it all. The fun begins in line 3, the use Ecto.Schema
means we are using a macro. I promise we will look into how use works later but for now, trust me that use Ecto.Schema
makes your module Champions.Ranking.Match
also be available as a struct like %Champions.Ranking.Match{user_a_id: 1, user_b_id: 2, result: :winner_a}
.
In the next line, we import Ecto.Changeset
so we have neat features for data validation. If we didn't import we could be still using all those functions but we'd need to type Ecto.Changeset.cast
and Ecto.Changeset.validate_required
and that takes soo long we might as well import all functions already. And you just learned how Elixir's import keyword works.
Lines 6 to 12 are where the fun begins. The schema/2 macro receives first the table name followed by a do
block. We need the table name to be the same as our migration so since we did create table(:users)
here we say schema "users" do
. Now let's talk about the do
block. Inside it, we do something very similar to our migration except we define fields that will be available inside our struct. The user IDs are very simple since Ecto will map Postgres IDs to Elixir integers we just say they're of the type :integer
. And at the end, you will notice there is timestamps/1 again. The last time we saw that function it was from Ecto.Migration
now this one is from Ecto.Schema
but as you guessed it, it adds inserted_at
and updated_at
to your model.
The new type here you should be curious about is Ecto.Enum. So far you've only seen types in the form of atom such as :integer
and :string
but this time the type is a module. This is an Ecto custom type. More specifically, this is a built-in custom type so you don't have to install anything. What it does is help us validate values users try to place on :result
and remember you, the programmer, is also an user so whatever validation we can get we take it.
Remember that our migration only said :result
is a :string
so your database doesn't know anything about enums, this is all in the Ecto.Model
. If we ever need to stop using Ecto.Enum
all we need to do is change this type to :string
and maybe fix some tests.
Last but not the least, when we define an Ecto.Model
we need to create at least one changeset to control how the outside world manipulates this data. Ours is pretty simple, we cast
and validate_required
all fields then do something you haven't seen so far: foreign_key_constraint/3. This validation is a delayed check on the foreign keys. If you try to Repo.insert
or Repo.update
this changeset, if the foreign keys don't match, Ecto will gracefully handle the error.
Our database knows about our table, we have a model to manipulate such table. The next step is to create functions in our module to make this process easier for anyone who needs.
Adding functions to our Ranking context
defmodule Champions.Ranking do
@moduledoc """
The Ranking context.
"""
import Ecto.Query, warn: false
alias Champions.Repo
alias Champions.Ranking.Match
@doc """
Creates a match.
## Examples
iex> create_match(%{field: value})
{:ok, %Match{}}
iex> create_match(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_match(attrs \\ %{}) do
%Match{}
|> Match.changeset(attrs)
|> Repo.insert()
end
end
To start we can be simple, we need only a create_match/1
function. After that we need to teach the places that generate matches how to save them. Let's start with our Champions.Accounts.declare_draw_match/2
.
defmodule Champions.Accounts do
alias Champions.Ranking
# a lot of things
def declare_draw_match(user_a, user_b) do
+ {:ok, _match} = Ranking.create_match(%{
+ user_a_id: user_a.id,
+ user_b_id: user_b.id,
+ result: :draw
+ })
{:ok, updated_user_a} = increment_user_points(user_a, 1)
{:ok, updated_user_b} = increment_user_points(user_b, 1)
{:ok, updated_user_a, updated_user_b}
end
# a lot of things
end
Don't forget to alias Champions.Ranking
in the top otherwise you're going to get an error. There's no mystery here, we just used the create_match/2
function to start recording those matches. Now let's go see what it needs to be done for conceding losses:
defmodule Champions.Accounts do
alias Champions.Ranking
# a lot of things
- def concede_loss_to(winner) do
+ def concede_loss_to(loser, winner) do
+ {:ok, _match} = Ranking.create_match(%{
+ user_a_id: loser.id,
+ user_b_id: winner.id,
+ result: :winner_b
+ })
increment_user_points(winner, 3)
end
# a lot of things
end
This time we need to do something to the function signature. concede_loss_to/1
became concede_loss_to/2
because we also need to know who is losing here for the record. Needless to say, you'll need to look for all places that use Accounts.concede_loss_to/1
and change. You could do that by global searching "concede_loss_to("
but if you run mix test
right now you will see a ton of errors, some from LiveViews and some from tests itself too. Let's quickly address those so we can move on:
# lib/champions_web/live/user_live/show.ex
- def handle_event("concede_loss", _value, %{assigns: %{user: user}} = socket) do
+ def handle_event("concede_loss", _value, %{assigns: %{current_user: current_user, user: user}} = socket) do
- {:ok, updated_user} = Accounts.concede_loss_to(current_user, user)
+ {:ok, updated_user} = Accounts.concede_loss_to(user)
{:noreply, assign(socket, :user, updated_user)}
end
Fixing that LiveView will also fix its test.
# test/champions/accounts_test.exs
- describe "concede_loss_to/1" do
+ describe "concede_loss_to/2" do
test "adds 3 points to the winner" do
+ loser = user_fixture()
user = user_fixture()
assert user.points == 0
- assert {:ok, %User{points: 3}} = Accounts.concede_loss_to(user)
+ assert {:ok, %User{points: 3}} = Accounts.concede_loss_to(loser, user)
end
end
For now, we just fixed this test error but we will come back to also test that the Match was created. Now your tests should be green.
How do I see data when there's no UI?
If you have a Postgres client such as DBeaver or Postico you probably don't need this right now but in case you don't know about IEx I'll show you something very interesting in Elixir right now.
iex
(Interactive Elixir) is a command that comes with Elixir by default that lets enter a REPL where you can quickly test Elixir code just as you would do with JavaScript's console in your browser. If you are inside a Mix project folder and run iex -S mix
you tell IEx that you also want to be able to do things using code from your project. Let's do this so we can list all our Matches.
$ iex -S mix
Erlang/OTP 24 [erts-12.3.2] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1]
Interactive Elixir (1.14.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Champions.Repo.all(Champions.Ranking.Match)
[debug] QUERY OK source="matches" db=23.5ms decode=1.3ms queue=15.4ms idle=717.2ms
SELECT m0."id", m0."result", m0."user_a_id", m0."user_b_id", m0."inserted_at", m0."updated_at" FROM "matches" AS m0 []
↳ :erl_eval.do_apply/6, at: erl_eval.erl:685
[
%Champions.Ranking.Match{
__meta__: #Ecto.Schema.Metadata<:loaded, "matches">,
id: 1,
result: :draw,
user_a_id: 1,
user_b_id: 2,
inserted_at: ~N[2023-07-02 14:43:08],
updated_at: ~N[2023-07-02 14:43:08]
},
%Champions.Ranking.Match{}
...
]
The trick is on line 5: Champions.Repo.all(Champions.Ranking.Match)
. I've just used our Repo.all
on our Ranking.Match
model to see the results. But right now this is obnoxious to read, let's alias those:
iex(2)> alias Champions.Repo
Champions.Repo
iex(3)> alias Champions.Ranking.Match
Champions.Ranking.Match
iex(4)> Repo.all(Match)
[debug] QUERY OK source="matches" db=23.5ms queue=0.1ms idle=1778.9ms
SELECT m0."id", m0."result", m0."user_a_id", m0."user_b_id", m0."inserted_at", m0."updated_at" FROM "matches" AS m0 []
↳ :erl_eval.do_apply/6, at: erl_eval.erl:685
Now it's more nice right? But what if you wanted to see the lastest ones? I'll explain those functions in detail later but you can copy this so you can reuse it later when you need:
iex(5)> import Ecto.Query
Ecto.Query
iex(6)> Match |> order_by(desc: :id) |> limit(3) |> Repo.all
[debug] QUERY OK source="matches" db=22.0ms queue=18.3ms idle=1938.1ms
SELECT m0."id", m0."result", m0."user_a_id", m0."user_b_id", m0."inserted_at", m0."updated_at" FROM "matches" AS m0 ORDER BY m0."id" DESC LIMIT 3 []
↳ :erl_eval.do_apply/6, at: erl_eval.erl:685
[
%Champions.Ranking.Match{
__meta__: #Ecto.Schema.Metadata<:loaded, "matches">,
id: 31,
result: :draw,
user_a_id: 1,
user_b_id: 2,
inserted_at: ~N[2023-07-02 18:29:05],
updated_at: ~N[2023-07-02 18:29:05]
},
%Champions.Ranking.Match{
__meta__: #Ecto.Schema.Metadata<:loaded, "matches">,
id: 30,
result: :draw,
user_a_id: 1,
user_b_id: 2,
inserted_at: ~N[2023-07-02 18:29:04],
updated_at: ~N[2023-07-02 18:29:04]
},
%Champions.Ranking.Match{
__meta__: #Ecto.Schema.Metadata<:loaded, "matches">,
id: 29,
result: :draw,
user_a_id: 1,
user_b_id: 2,
inserted_at: ~N[2023-07-02 18:29:04],
updated_at: ~N[2023-07-02 18:29:04]
}
]
We've imported Ecto.query
to get functions like order_by
and limit
and just piped our query into Repo.all. Feel free to use it whenever you need.
Where should functions related to points live?
As mentioned previously, these functions were added to the Accounts
context for the sake of simplicity and since points are stored in the User
model which is managed by Accounts
. There's no exact answer here but my gut say that for this project the Ranking
module should take care and know about everything related to points so I'm making a choice of moving those.
# lib/champions/ranking.ex
defmodule Champions.Ranking do
@moduledoc """
The Ranking context.
"""
import Ecto.Query, warn: false
alias Champions.Repo
+ alias Champions.Accounts
alias Champions.Ranking.Match
+ alias Champions.Accounts.User
@doc """
Creates a match.
## Examples
iex> create_match(%{field: value})
{:ok, %Match{}}
iex> create_match(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_match(attrs \\ %{}) do
%Match{}
|> Match.changeset(attrs)
|> Repo.insert()
end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user changes.
+
+ ## Examples
+
+ iex> change_user_points(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_points(%User{} = user, attrs \\ %{}) do
+ User.points_changeset(user, attrs)
+ end
+
+ @doc """
+ Updates the current number of points of a user
+
+ ## Examples
++ iex> update_user_points(user, 10)
+ {:ok, %User{points: 10}}
+
+ """
+ def update_user_points(%User{} = user, points) do
+ user
+ |> change_user_points(%{"points" => points})
+ |> Repo.update()
+ end
+
+ @doc """
+ Adds 3 points to the winning user
+
+ ## Examples
++ iex> concede_loss_to(%User{points: 0})
+ {:ok, %User{points: 3}}
+
+ """
+ def concede_loss_to(loser, winner) do
+ {:ok, _match} = create_match(%{
+ user_a_id: loser.id,
+ user_b_id: winner.id,
+ result: :winner_b
+ })
+ increment_user_points(winner, 3)
+ end
+
+ @doc """
+ Adds 1 point to each user
+
+ ## Examples
+
+ iex> declare_draw_match(%User{points: 0}, %User{points: 0})
+ {:ok, %User{points: 1}, %User{points: 1}}
+
+ """
+ def declare_draw_match(user_a, user_b) do
+ {:ok, _match} = create_match(%{
+ user_a_id: user_a.id,
+ user_b_id: user_b.id,
+ result: :draw
+ })
+ {:ok, updated_user_a} = increment_user_points(user_a, 1)
+ {:ok, updated_user_b} = increment_user_points(user_b, 1)
+ {:ok, updated_user_a, updated_user_b}
+ end
+
+ @doc """
+ Increments `amount` points to the user and returns its updated model
+
+ ## Examples
+
+ iex> increment_user_points(%User{points: 0}, 1)
+ {:ok, %User{points: 1}}
+
+ """
+ def increment_user_points(user, amount) do
+ {1, nil} =
+ User
+ |> where(id: ^user.id)
+ |> Repo.update_all(inc: [points: amount])
+
+ {:ok, Accounts.get_user!(user.id)}
+ end
end
There's a lot of additions plus note one function from Accounts
is not there, get_user!
so we aliased that model and changed the code to be Accounts.get_user!
. Similarly we need to remove those from Accounts
. Just remove those functions from there:
# lib/champions/accounts.ex
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Champions.Repo
- alias Champions.Ranking
# a ton of code
- @doc """
- Returns an `%Ecto.Changeset{}` for tracking user changes.
-
- ## Examples
-
- iex> change_user_points(user)
- %Ecto.Changeset{data: %User{}}
-
- """
- def change_user_points(%User{} = user, attrs \\ %{}) do
- User.points_changeset(user, attrs)
- end
-
- @doc """
- Updates the current number of points of a user
-
- ## Examples
-
- iex> update_user_points(user, 10)
- {:ok, %User{points: 10}}
-
- """
- def update_user_points(%User{} = user, points) do
- user
- |> change_user_points(%{"points" => points})
- |> Repo.update()
- end
-
- @doc """
- Adds 3 points to the winning user
-
- ## Examples
-
- iex> concede_loss_to(%User{points: 0})
- {:ok, %User{points: 3}}
-
- """
- def concede_loss_to(loser, winner) do
- {:ok, _match} = Ranking.create_match(%{
- user_a_id: loser.id,
- user_b_id: winner.id,
- result: :winner_b
- })
- increment_user_points(winner, 3)
- end
-
- @doc """
- Adds 1 point to each user
-
- ## Examples
-
- iex> declare_draw_match(%User{points: 0}, %User{points: 0})
- {:ok, %User{points: 1}, %User{points: 1}}
-
- """
- def declare_draw_match(user_a, user_b) do
- {:ok, _match} = Ranking.create_match(%{
- user_a_id: user_a.id,
- user_b_id: user_b.id,
- result: :draw
- })
- {:ok, updated_user_a} = increment_user_points(user_a, 1)
- {:ok, updated_user_b} = increment_user_points(user_b, 1)
- {:ok, updated_user_a, updated_user_b}
- end
-
- @doc """
- Increments `amount` points to the user and returns its updated model
-
- ## Examples
-
- iex> increment_user_points(%User{points: 0}, 1)
- {:ok, %User{points: 1}}
-
- """
- def increment_user_points(user, amount) do
- {1, nil} =
- User
- |> where(id: ^user.id)
- |> Repo.update_all(inc: [points: amount])
-
- {:ok, get_user!(user.id)}
- end
Don't forget to remove the alias Champions.Ranking
so you wont be seeing warnings in your terminal. Time to do a similar thing with tests. We will use this as an opportunity to get started on our Ranking
tests.
# test/champions/ranking_test.exs
defmodule Champions.RankingTest do
use Champions.DataCase
alias Champions.Ranking
alias Champions.Accounts.User
import Champions.AccountsFixtures
describe "change_user_points/2" do
test "accepts non-negative integers" do
assert %Ecto.Changeset{} = changeset = Ranking.change_user_points(%User{}, %{"points" => -1})
refute changeset.valid?
assert %Ecto.Changeset{} = changeset = Ranking.change_user_points(%User{}, %{"points" => 0})
assert changeset.valid?
assert %Ecto.Changeset{} = changeset = Ranking.change_user_points(%User{}, %{"points" => 10})
assert changeset.valid?
end
end
describe "set_user_points/2" do
setup do
%{user: user_fixture()}
end
test "updates the amounts of points of an existing user", %{user: user} do
{:ok, updated_user} = Ranking.update_user_points(user, 10)
assert updated_user.points == 10
end
end
describe "concede_loss_to/2" do
test "adds 3 points to the winner" do
loser = user_fixture()
user = user_fixture()
assert user.points == 0
assert {:ok, %User{points: 3}} = Ranking.concede_loss_to(loser, user)
end
end
describe "declare_draw_match/2" do
test "adds 1 point to each user" do
user_a = user_fixture()
user_b = user_fixture()
assert user_a.points == 0
assert user_b.points == 0
assert {:ok, %User{points: 1}, %User{points: 1}} = Ranking.declare_draw_match(user_a, user_b)
end
end
describe "increment_user_points/2" do
test "performs an atomic increment on a single user points amount" do
user = user_fixture()
assert user.points == 0
assert {:ok, %User{points: 10}} = Ranking.increment_user_points(user, 10)
assert {:ok, %User{points: 5}} = Ranking.update_user_points(user, 5)
assert {:ok, %User{points: 15}} = Ranking.increment_user_points(user, 10)
end
end
end
We pretty much copy-pasted the functions from Account
tests and renamed the context name. Feel free to copy the code above under test/champions/ranking_test.exs
or challenge yourself to create the file and copy your current test/champions/accounts_test.exs
and fix the differences, that's going to be interesting. It's worth mentioning your LiveView will be erroring right now so to focus only on this file run mix test test/champions/ranking_test.exs
.
defmodule Champions.AccountsTest do
# a lot of code
- describe "change_user_points/2" do
- test "accepts non-negative integers" do
- assert %Ecto.Changeset{} = changeset = Accounts.change_user_points(%User{}, %{"points" => -1})
- refute changeset.valid?
-
- assert %Ecto.Changeset{} = changeset = Accounts.change_user_points(%User{}, %{"points" => 0})
- assert changeset.valid?
-
- assert %Ecto.Changeset{} = changeset = Accounts.change_user_points(%User{}, %{"points" => 10})
- assert changeset.valid?
- end
- end
-
- describe "set_user_points/2" do
- setup do
- %{user: user_fixture()}
- end
-
- test "updates the amounts of points of an existing user", %{user: user} do
- {:ok, updated_user} = Accounts.update_user_points(user, 10)
- assert updated_user.points == 10
- end
- end
describe "list_users/0" do
test "show all users on our system" do
user = user_fixture()
assert [^user] = Accounts.list_users()
end
end
-
- describe "concede_loss_to/2" do
- test "adds 3 points to the winner" do
- loser = user_fixture()
- user = user_fixture()
- assert user.points == 0
- assert {:ok, %User{points: 3}} = Accounts.concede_loss_to(loser, user)
- end
- end
-
- describe "declare_draw_match/2" do
- test "adds 1 point to each user" do
- user_a = user_fixture()
- user_b = user_fixture()
- assert user_a.points == 0
- assert user_b.points == 0
- assert {:ok, %User{points: 1}, %User{points: 1}} = Accounts.declare_draw_match(user_a, user_b)
- end
- end
-
- describe "increment_user_points/2" do
- test "performs an atomic increment on a single user points amount" do
- user = user_fixture()
- assert user.points == 0
- assert {:ok, %User{points: 10}} = Accounts.increment_user_points(user, 10)
- assert {:ok, %User{points: 5}} = Accounts.update_user_points(user, 5)
- assert {:ok, %User{points: 15}} = Accounts.increment_user_points(user, 10)
- end
- end
end
I haven't realized before by list_users/0
test was in the middle of that mess but it doesn't matter now.
defmodule ChampionsWeb.UserLive.Show do
# some code
def handle_event("concede_loss", _value, %{assigns: %{current_user: current_user, user: user}} = socket) do
- {:ok, updated_user} = Accounts.concede_loss_to(current_user, user)
+ {:ok, updated_user} = Ranking.concede_loss_to(current_user, user)
{:noreply, assign(socket, :user, updated_user)}
end
def handle_event("concede_draw", _value, %{assigns: %{current_user: current_user, user: user}} = socket) do
- {:ok, updated_my_user, updated_user} = Accounts.declare_draw_match(current_user, user)
+ {:ok, updated_my_user, updated_user} = Ranking.declare_draw_match(current_user, user)
{:noreply,
socket
|> assign(:user, updated_user)
|> assign(:current_user, updated_my_user)
}
end
There's no tricky here, this fixes our LiveView and consequently all our tests. Your mix test
should be all green now!
Testing the new things
We so far only focused on refactoring code and got some tests on ranking_tests.exs
but those are all for old things, we need to ensure the new code works and will keep working. The easiest test we can do is just check if we can create a match:
defmodule Champions.RankingTest do
use Champions.DataCase
alias Champions.Ranking
+ alias Champions.Ranking.Match
alias Champions.Accounts.User
import Champions.AccountsFixtures
+ describe "create_match/1" do
+ test "create_match/1 with valid data creates a match" do
+ loser = user_fixture()
+ winner = user_fixture()
+ valid_attrs = %{result: :winner_a, user_a_id: loser.id, user_b_id: winner.id}
+
+ assert {:ok, %Match{} = match} = Ranking.create_match(valid_attrs)
+ assert match.result == :winner_a
+ end
+
+ test "create_match/1 with an invalid user ID fails" do
+ loser = user_fixture()
+ valid_attrs = %{result: :winner_a, user_a_id: loser.id, user_b_id: 10_000_000}
+
+ assert {:error, %Ecto.Changeset{} = changeset} = Ranking.create_match(valid_attrs)
+ assert {"does not exist", _rest} = Keyword.fetch!(changeset.errors, :user_b_id)
+ end
+ end
# more code
end
Now let's talk about the functions that generate matches: Ranking.concede_loss_to/2
and Ranking.declare_draw_match/2
. Their signature is to always return the users that were updated so our LiveView can update them. Doesn't seems we have a reason to change those functions specifically now so we need a different way to test that matches where created. Since Mix tests use a sandboxed Postgres adapter we can simply trust that the last Match will be the one just created.
describe "concede_loss_to/2" do
test "adds 3 points to the winner" do
loser = user_fixture()
user = user_fixture()
assert user.points == 0
assert {:ok, %User{points: 3}} = Ranking.concede_loss_to(loser, user)
+ match = get_last_match!()
+ assert match.user_a_id == loser.id
+ assert match.user_b_id == user.id
+ assert match.result == :winner_b
end
end
describe "declare_draw_match/2" do
test "adds 1 point to each user" do
user_a = user_fixture()
user_b = user_fixture()
assert user_a.points == 0
assert user_b.points == 0
assert {:ok, %User{points: 1}, %User{points: 1}} = Ranking.declare_draw_match(user_a, user_b)
+ match = get_last_match!()
+ assert match.user_a_id == user_a.id
+ assert match.user_b_id == user_b.id
+ assert match.result == :draw
end
end
# put this in the end:
+ def get_last_match!() do
+ Match
+ |> order_by(desc: :id)
+ |> limit(1)
+ |> Repo.one!()
+ end
end
We created a simple get_last_match!
helper function so we wont duplicate code here. After getting the last match all we need is to check if the data is set according to our test and we are good. But now, assuming you never did an Ecto.Query before, you could be confused about that and that's already the second time in this post I use it so let's talk about that.
A 3 minutes introduction to Ecto.Query
Whenever we need to query data from our database, in the end, we use Repo
with functions such as Repo.exists?/2 to check if the query contains any results, Repo.all/2 to get all results that fit the query or Repo.one/2 that will get a single result and error if there's none or more than one possible result. What those functions don't do is tell "what am I looking for". Ecto.Query
and Ecto.Repo
combine themselves to be more powerful: Ecto.Query
scope things and Ecto.Repo
executes the database call.
What's the most basic Ecto.Query possible? The answer is simple and you've been seeing me do that all the time: all Ecto.Model
are queries. If you go to iex -S mix
and do Repo.all(Match)
(don't forget the aliases) you will see a list of all queries. So Ecto.Model
is a query with no boundaries.
What if I want to get more specific results? That's were Ecto.Query jumps in. It comes with very useful functions. They're so useful that, I don't know if you paid attention, all contexts contain import Ecto.Query, warn: false
at the top because we all know we will be using them at some point so we ignore the warn until them, Phoenix will generate that for you in every single context. If you don't import it's also fine but you'll quickly see yourself writing things like Ecto.Query.where
and Ecto.Query.order_by
a lot so why not import as soon as needed.
Now how we scope things down with queries? We first start with a query that knows no boundaries: a model. After that we just keep adding constraints. Let's dive down into get_last_match!/0
so we have an example.
def get_last_match!() do
# The query starts with `Match` so it reads as 'give me all matches'
# or if you like SQL: `select * from matches`
Match
# As soon as we use order_by/3 we add a new constraint saying exactly what the
# function name days. SQL: `select * from matches order by id desc`
|> order_by(desc: :id)
# Needless to say, limit/3 does the same.
# SQL: `select * from matches order by id desc limit 1`
|> limit(1)
# As for Repo.one!/2 it will run the query and error if the count is not
# exactly 1. In this case, it only can be 0 or 1 because of our limit.
|> Repo.one!()
end
Summary
- We learned how to create a new Ecto model from scratch, including migrations and why those two correlate.
- We created our first context by hand and reasoned about when functions should be moved from one to another
- We quickly glanced at how to see data on IEx also using
Ecto.Query
- We also learned that Postgres tests live on a sandbox environment so things like getting the last row can be relied on for tests and we also created a helper function for our tests
- We quickly talked about how Ecto queries work scoping things down.
To prevent this post from becoming much longer I will stop it here and next time we will learn interesting stuff related to how to show data on LiveView and some Ecto tricks to make getting related models easier. See you next time.
Top comments (1)
Thanks for the tutorials, really good for beginners. Hope you can have more articles soon.