Listing matches for users

lubien

Lubien

Posted on February 5, 2024

Listing matches for users

Last post we had to use IEx to see matches being inserted on the database. This time we are going to do a little bit of frontend and at the same time learn a bit more about Ecto and LiveView streams. Our goal is to make the user page list all their matches.

Reviewing some Ecto concepts

First of all, we need a method that lists matches for a certain user. Since we established last time that the Ranking module will handle those, let's go there.

@doc """
Returns the list of matches a certain user participated.

## Examples

    iex> list_matches_for_user(1)
    [%Match{}, ...]

"""
def list_matches_for_user(user_id) do
  Match
  |> or_where(user_a_id: ^user_id)
  |> or_where(user_b_id: ^user_id)
  |> order_by(desc: :id)
  |> Repo.all()
end
Enter fullscreen mode Exit fullscreen mode

This is a simple Ecto query that starts as broad as "list me all the matches" but uses or_where/3 to list only when either user_a_id or user_b_id is equal to user_id. Not only that we use order_by/3 to sort descendingly so the latest matches can shine first.

Back to the tables

Back when we needed to show a list of users we opted to use the <.table> component. We will do so again. Go to user_live/show.ex.

defmodule ChampionsWeb.UserLive.Show do
  # some stuff here

  @impl true
  def handle_params(%{"id" => id}, _session, socket) do
    user = Accounts.get_user!(id)
+   matches = Ranking.list_matches_for_user(user.id)
+
    {:noreply,
     socket
     |> assign(:page_title, "Showing user #{user.email}")
-    |> assign(:user, user)}
+    |> assign(:user, user)
+    |> stream(:matches, matches)
+   }
  end

  # some stuff here
  @impl true
  def render(assigns) do
    ~H"""
    <.header>
      User <%= @user.id %>
      <:subtitle>This is a player on this app.</:subtitle>
    </.header>

    <.list>
      <:item title="Email"><%= @user.email %></:item>
      <:item title="Points"><span data-points><%= @user.points %></span></:item>
    </.list>

    <div :if={@current_user && @current_user.id != @user.id} class="my-4">
      <.button type="button" phx-click="concede_loss">I lost to this person</.button>
      <.button type="button" phx-click="concede_draw">Declare draw match</.button>
    </div>
+
+   <.table
+     id="matches"
+     rows={@streams.matches}
+   >
+     <:col :let={{_id, match}} label="User A"><%= match.user_a_id %></:col>
+     <:col :let={{_id, match}} label="User B"><%= match.user_b_id %></:col>
+     <:col :let={{_id, match}} label="Points"><%= match.result %></:col>
+   </.table>

    <.back navigate={~p"/users"}>Back to users</.back>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

Now there you go, another boring table! Wait, it's even more boring because it doesn't even show user names! Let's fix that. I'd like to take this opportunity to teach you how NOT to do this and after show you a good way of doing this.

How NOT to load nested information on Phoenix LiveView

We could just use Accounts.get_user! inside our render function and just get users when rendering.

<.table
  id="matches"
  rows={@streams.matches}
>
- <:col :let={{_id, match}} label="User A"><%= match.user_a_id %></:col>
+ <:col :let={{_id, match}} label="User A"><%= Accounts.get_user!(match.user_a_id).email %></:col>
- <:col :let={{_id, match}} label="User B"><%= match.user_b_id %></:col>
+ <:col :let={{_id, match}} label="User B"><%= Accounts.get_user!(match.user_b_id).email %></:col>
  <:col :let={{_id, match}} label="Points"><%= match.result %></:col>
</.table>
Enter fullscreen mode Exit fullscreen mode

The above works but have you ever heard of the N+1 problem? That's bad. We are basically querying once for the matches then for each match two more queries, one for each user. Here's how my terminal logs appear after rendering that.

 ChampionsWeb.UserLive.Show.handle_params/3, at: lib/champions_web/live/user_live/show.ex:9
[debug] QUERY OK source="matches" db=5.0ms idle=374.9ms
SELECT m0."id", m0."result", m0."user_a_id", m0."user_b_id", m0."inserted_at", m0."updated_at" FROM "matches" AS m0 WHERE (m0."user_a_id" = $1) OR (m0."user_b_id" = $2) ORDER BY m0."id" DESC [2, 2]
 ChampionsWeb.UserLive.Show.handle_params/3, at: lib/champions_web/live/user_live/show.ex:10
[debug] Replied in 11ms
[debug] QUERY OK source="users" db=2.4ms idle=372.6ms
SELECT u0."id", u0."email", u0."hashed_password", u0."confirmed_at", u0."points", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [1]
 anonymous fn/3 in ChampionsWeb.UserLive.Show.render/1, at: lib/champions_web/live/user_live/show.ex:66
[debug] QUERY OK source="users" db=3.3ms decode=0.7ms idle=369.5ms
SELECT u0."id", u0."email", u0."hashed_password", u0."confirmed_at", u0."points", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [2]
 anonymous fn/3 in ChampionsWeb.UserLive.Show.render/1, at: lib/champions_web/live/user_live/show.ex:67
[debug] QUERY OK source="users" db=4.1ms idle=365.0ms
SELECT u0."id", u0."email", u0."hashed_password", u0."confirmed_at", u0."points", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [1]
...
Enter fullscreen mode Exit fullscreen mode

To add fuel to the problem, every single time our render function is called we would run those queries again. So if something triggers an update in the template, say the points changed, we'd run a ton of load on our database. Don't do queries inside your HEEx!

How to do load nested information on Phoenix LiveView Ecto

Ecto, just like any other good ORM, already knows that models can have relations to each other. We can teach it that a Match model belongs to each User model by simply changing the following:

schema "matches" do
  field :result, Ecto.Enum, values: [:winner_a, :winner_b, :draw]
- field :user_a_id, :integer
+ belongs_to :user_a, Champions.Accounts.User
- field :user_b_id, :integer
+ belongs_to :user_b, Champions.Accounts.User

  timestamps()
end
Enter fullscreen mode Exit fullscreen mode

Implicetely when you use Ecto's belongs_to/3 you get two fields in your model. If you say belongs_to: :user_a, Champions.Accounts.User a user_a_id field will be created with :integer type and also a :user_a field will be created which will store User when we preload it. Preloading relations in Ecto is quite easy. Go to your Rankings context and add this line:

def list_matches_for_user(user_id) do
  Match
  |> or_where(user_a_id: ^user_id)
  |> or_where(user_b_id: ^user_id)
  |> order_by(desc: :id)
+ |> preload([:user_a, :user_b])
  |> Repo.all()
end
Enter fullscreen mode Exit fullscreen mode

Now all matches will come with both fields pre-populated. We can use them like this:

<.table
  id="matches"
  rows={@streams.matches}
>
- <:col :let={{_id, match}} label="User A"><%= match.user_a_id %></:col>
+ <:col :let={{_id, match}} label="User A"><%= match.user_a.email %></:col>
- <:col :let={{_id, match}} label="User B"><%= match.user_b_id %></:col>
+ <:col :let={{_id, match}} label="User B"><%= match.user_b.email %></:col>
  <:col :let={{_id, match}} label="Points"><%= match.result %></:col>
</.table>
Enter fullscreen mode Exit fullscreen mode

That's magic, ladies and gentleman. Our table is slightly less boring. One thing it doesn't do though is add matches as soon as someone clicks on the button. Let's do that.

Learning a little bit more about Phoenix LiveView Streams

Whenever we run the functions that concede loss or declare a draw match we receive back the updated users so we can update our UI. We should do the same for the match that was just created so we can put that into our UI. Let's get back to the Ranking context.

  @doc """
  Adds 3 points to the winning user

  ## Examples

      iex> concede_loss_to(%User{points: 0})
-     {:ok, %User{points: 3}}
+     {:ok, %User{points: 3}, %Match{}}

  """
  def concede_loss_to(loser, winner) do
-   {:ok, _match} = create_match(%{
+   {:ok, match} = create_match(%{
      user_a_id: loser.id,
      user_b_id: winner.id,
      result: :winner_b
    })
-   increment_user_points(winner, 3)
+   {:ok, updated_user} = increment_user_points(winner, 3)
+   {:ok, updated_user, Repo.preload(match, [:user_a, :user_b])}
  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}}
+     {:ok, %User{points: 1}, %User{points: 1}, %Match{}}

  """
  def declare_draw_match(user_a, user_b) do
-   {:ok, _match} = create_match(%{
+   {: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}
+   {:ok, updated_user_a, updated_user_b, Repo.preload(match, [:user_a, :user_b])}
  end
Enter fullscreen mode Exit fullscreen mode

As you can see both functions now also send a %Match{} as the last element on the tuple. Now let's update our usages in the UI. It's very important to notice that we also preloaded the associations after creating the matches so our UI will have that info otherwise it's going to error later on.

  def handle_event("concede_loss", _value, %{assigns: %{current_user: current_user, user: user}} = socket) do
-   {:ok, updated_user} = Ranking.concede_loss_to(current_user, user)
-   {:noreply, assign(socket, :user, updated_user)}
+   {:ok, updated_user, match} = Ranking.concede_loss_to(current_user, user)
+
+   {:noreply,
+     socket
+     |> assign(:user, updated_user)
+     |> stream_insert(:matches, match, at: 0)
+   }
  end

  def handle_event("concede_draw", _value, %{assigns: %{current_user: current_user, user: user}} = socket) do
-   {:ok, updated_my_user, updated_user} = Ranking.declare_draw_match(current_user, user)
+   {:ok, updated_my_user, updated_user, match} = Ranking.declare_draw_match(current_user, user)
    {:noreply,
      socket
      |> assign(:user, updated_user)
      |> assign(:current_user, updated_my_user)
+     |> stream_insert(:matches, match, at: 0)
    }
  end
Enter fullscreen mode Exit fullscreen mode

Now we have a new function here. Meet stream_insert/4. Remember that whenever we defined tables we used stream(socket, :stream_name, elements)? Streams are an efficient way of handling large collections of data in LiveView without storing them in memory.

When the user opens the LiveView we render the matches stream once and leave it at that. Now that we have a new match we use stream_insert using the same stream name and saying that the position should be 0 so this is effectively a prepend. If you're wondering why we prepend to the table, remember our match table is sorted in descending ID order so new ones should go at the top. Feel free to test it and when you're comfortable we will start fixing some tests.

Fixing old tests

Right now you should have at least a couple erroring tests. It's fine, we changed things on Ranking module so let's head to test/champions/ranking_test.exs.

  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 {:ok, %User{points: 3}, match} = Ranking.concede_loss_to(loser, user)
      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 {:ok, %User{points: 1}, %User{points: 1}, match} = Ranking.declare_draw_match(user_a, user_b)
      assert match.user_a_id == user_a.id
      assert match.user_b_id == user_b.id
      assert match.result == :draw
    end
  end

# some stuff

- def get_last_match!() do
-   Match
-   |> order_by(desc: :id)
-   |> limit(1)
-   |> Repo.one!()
- end
Enter fullscreen mode Exit fullscreen mode

All of a sudden our get_last_match! function is useless! But it was nice since it taught you some Ecto concepts last time.

Adding tests for the UI changes

You LiveView tests are probably not failing right now, but that doesn't mean they have good coverage. Let's add simple tests for the matches. Head out to test/champions_web/live/user_live_test.exs:

describe "Authenticated Show" do
  setup [:register_and_log_in_user]

  # stuff

- test "concede 3 points when I lose to another player", %{conn: conn, user: _user} do
+ test "concede 3 points when I lose to another player", %{conn: conn, user: user} do
    other_user = user_fixture()
    {:ok, show_live, _html} = live(conn, ~p"/users/#{other_user}")

    assert other_user.points == 0
    assert show_live |> element("button", "I lost to this person") |> render_click()
    assert element(show_live, "span[data-points]") |> render() =~ "3"
+   assert element(show_live, "#matches") |> render() =~ user.email
+   assert element(show_live, "#matches") |> render() =~ other_user.email
+   assert element(show_live, "#matches") |> render() =~ "winner_b"
    assert Accounts.get_user!(other_user.id).points == 3
  end

  test "concede 1 point to each user when there's a draw match", %{conn: conn, user: user} do
    other_user = user_fixture()
    {:ok, show_live, _html} = live(conn, ~p"/users/#{other_user}")

    assert user.points == 0
    assert other_user.points == 0
    assert show_live |> element("button", "Declare draw match") |> render_click()
    assert element(show_live, "span[data-my-points]") |> render() =~ "1"
    assert element(show_live, "span[data-points]") |> render() =~ "1"
+   assert element(show_live, "#matches") |> render() =~ user.email
+   assert element(show_live, "#matches") |> render() =~ other_user.email
+   assert element(show_live, "#matches") |> render() =~ "draw"
    assert Accounts.get_user!(user.id).points == 1
    assert Accounts.get_user!(other_user.id).points == 1
  end
end
Enter fullscreen mode Exit fullscreen mode

This is a simple tricky because <.table> will use an ID equal to the stream name so in this case #matches then we just check the user emails and the result to be winner_b and draw. In just a few lines we already added tests to check that our LiveView is reactive. Neat huh?

Summary

  • Do not do queries on your LiveView, use assigns
  • Preload that whenever need and possible
  • We learned a bit more about LiveView streams, namely stream_insert/4

See you next time!

💖 💪 🙅 🚩
lubien
Lubien

Posted on February 5, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Listing matches for users
elixir Listing matches for users

February 5, 2024