DEV Community

Lubien
Lubien

Posted on

Where are all these points coming from?

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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{}
  ...
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
  }
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_bya 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
ericchuawc profile image
Eric Chua

Thanks for the tutorials, really good for beginners. Hope you can have more articles soon.