Lubien
Posted on February 5, 2024
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
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
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>
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]
...
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
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
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>
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
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
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
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
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!
Posted on February 5, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.