Some time ago we have already mentioned Ecto while we were describing Ecto models. Ecto quite a big topic, which we cannot cover in a single post.
Today I would like to talk again about Ecto and describe Ecto Queries.
Introduce Message model
Before we dig into queries, we need to extend our chat functionality with messages. Well, our users are already able to send messages to each other, but those messages are not persisted in the database.
Let’s start with the messages model.
A message is going to be related to a room and to a user. It will also contain a content of a message.
And last but not least, our message is going to be related to Conversation
concern.
So to generate a model with all required attributes we need to run the following command.
→ mix phx.gen.schema Conversation.Message messages \
room_id:references:rooms user_id:references:users content
Before we migrate our database let’s jump into migration file and update a couple of things.
add :content, :string, null: false
add :room_id, references(:rooms, on_delete: :delete_all)
add :user_id, references(:users, on_delete: :delete_all)
First, we want our content
field to have a value all the time. So null: false
forces that.
I’ve also updated :on_delete
key to :delete_all
in the references
call.
That means if a related room or a user would be deleted from the database, the messages related to them would be deleted as well.
Why do we do that? Because we don’t want to keep invalidated messages in our database.
Now we are ready to migrate the database.
→ mix ecto.migrate
Next thing we need to do is to update relations between our tables.
First, would be a Conversation.Message
. In lib/prater/conversation/message.ex
replace
field :room_id, :id
field :user_id, :id
with
belongs_to :room, Prater.Conversation.Room
belongs_to :user, Prater.Auth.User
Then we need to add
has_many :messages, Prater.Conversation.Message
to the schema
block of the Conversation.Room
and the Auth.User
models.
That’s it. The model is ready to work.
Persisting messages
Let’s create a function which would handle the creation of a message.
In the lib/prater/conversation/conversation.ex
we need to create a new function
alias Prater.Conversation.Message
def create_message(user, room, attrs \\ %{}) do
user
|> Ecto.build_assoc(:messages, room_id: room.id)
|> Message.changeset(attrs)
|> Repo.insert()
end
We need to create a message and associate it with both a user and a room.
We use a user’s record and a build_assoc/3
to build an association with a message and extend it with the room_id
,
that makes our future message record associated with both of them.
Then we apply a changeset of the Message
model and insert it into a database.
That part is ready, let’s move to the RoomChannel
to do the rest.
Next, in the lib/prater_web/channels/room_channel.ex
we need to update the handle_in
function for new messages.
We are going to store the message in the database and only then broadcast it to the subscribers.
def handle_in("message:add", %{"message" => content}, socket) do
room = Conversation.get_room!(socket.assigns[:room_id])
user = find_user(socket)
case Conversation.create_message(user, room, %{content: content}) do
{:ok, message} ->
message = Repo.preload(message, :user)
message_template = %{
content: message.content,
user: %{username: message.user.username}
}
broadcast!(
socket,
"room:#{message.room_id}:new_message",
message_template
)
{:reply, :ok, socket}
{:error, _reason} ->
{:reply, :error, socket}
end
end
Let’s try to understand what do we have here.
First, we need a room record instead of just its ID in order to pass it through to the Conversation.create_message
function.
Then we call the function and in case of failure, we just respond with an error.
The main behavior happens when we are succeeded. We preload the user association of the message, then we form a message template and broadcast the data.
Why do we need that Repo.preload
call? We need to get a user’s username to pass back to the front-end to render a message. So we want a message to provide it to us.
To understand that let’s jump to an Interactive Elixir and experiment a bit.
Preload associations
Let’s grab the first message from our database
iex> message = Prater.Repo.get!(Prater.Conversation.Message, 1)
%Prater.Conversation.Message{
# ...
content: "Hello",
id: 1,
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 2
}
Among other attributes, we can see that the value of user
attribute is #Ecto.Association.NotLoaded<association :user is not loaded>
And if we try to access the user’s data we will face the following error:
iex> message.user.username
** (KeyError) key :username not found in:
#Ecto.Association.NotLoaded<association :user is not loaded>
The thing is, that Ecto does not load all available associations for a record by default. If we want to, we need to do it explicitly.
We can use Repo.preload
function to do that.
iex> message_with_user = Prater.Repo.preload(message, :user)
%Prater.Conversation.Message{
# ...
content: "Hello",
id: 1,
user: %Prater.Auth.User{
# ...
id: 2,
username: "user"
},
user_id: 2
}
Now we can see that user
field contains a value of Prater.Auth.User
model and we can access it.
iex> message_with_user.user.username
"user"
Now when we are sending messages to any room, those messages are going to be created in the database as well.
Load messages after joining a room
We have our messages in the database, now it’s time to fetch them and show on the page.
Let’s start from the function for fetching messages and proceed from there.
In the lib/prater/conversation/conversation.ex
let’s create the list_messages/2
function.
import Ecto.Query
def list_messages(room_id, limit \\ 15) do
Repo.all(
from msg in Message,
join: user in assoc(msg, :user),
where: msg.room_id == ^room_id,
order_by: [desc: msg.inserted_at],
limit: ^limit,
select: %{content: msg.content, user: %{username: user.username}}
)
end
The function has two arguments: a room_id
because we want to fetch only messages related to that room and limit
the number of messages we are fetching.
We call a Repo.all
with the query. If you are familiar with SQL syntax it would be easy to understand what is going on here.
First, we are stating we are going to fetch data from the Message
model and label it as msg
.
Then we are joining the user table. We are saying Ecto to use user association between message and user.
In the next step, we are filtering messages by a room_id
. Notice we are using ^room_id
to keep the value unchanged in case of Pattern Matching. That is how Ecto requires us to do.
Then we are ordering messages in descending order by date of creation to grab last 15 messages.
As the last thing, we select only required information such as content and username and form it as the Map with the format we need.
All that is similar to the following SQL query.
SELECT msg."content", user."username"
FROM "messages" AS msg
INNER JOIN "users" AS user ON user."id" = msg."user_id"
WHERE msg."room_id" = 1
ORDER BY msg."inserted_at" DESC
LIMIT 15
Ecto also allows us to reuse queries and then extend them. For example, we could split the “select” part from the base query in the following way.
def list_messages(room_id, limit \\ 15) do
query =
from msg in Message,
join: user in assoc(msg, :user),
where: msg.room_id == ^room_id,
order_by: [desc: msg.inserted_at],
limit: ^limit
Repo.all(
from [msg, user] in query,
select: %{content: msg.content, user: %{username: user.username}}
)
end
For example, we can extract the query part into a separate function and use it as a predecessor for the different selects.
Now it’s time to use that function. Open lib/prater_web/channels/room_channel.ex
and update join/3
function as follows:
def join("room:" <> room_id, _params, socket) do
send(self(), :after_join)
{
:ok,
%{messages: Conversation.list_messages(room_id)},
assign(socket, :room_id, room_id)
}
end
Now when some user joins a room, we are providing the list of recent messages back. All we need to do now is the get those messages on the client-side and render them.
In the assets/js/socket.js
let’s change the following line
.receive("ok", resp => { console.log("Joined successfully", resp) })
to
.receive("ok", resp => {
console.log("Joined successfully", resp)
resp.messages.reverse().map(message => renderMessage(message))
})
As soon as our renderMessage
adds messages to the end, we need to flip array and then map through it in order to render messages in the correct order.
That is pretty much it. Now if we join any room, we would see messages which were created earlier.
Let’s take a look at a couple of more features Ecto gives us.
Pipe Syntax
Ecto also supports the Pipe Syntax for queries.
The shorter version of our previous query would look like:
iex> import Ecto.Query
iex> room_id = 1
iex> limit = 3
iex> Prater.Conversation.Message |>
...> join(:inner, [msg], usr in assoc(msg, :user)) |>
...> select([msg, usr], %{id: msg.id, content: msg.content, user: %{username: usr.username}}) |>
...> where([msg], msg.room_id == ^room_id) |>
...> order_by([msg], desc: msg.inserted_at) |>
...> limit(^limit) |>
...> Prater.Repo.all
[
%{content: "Hello again", id: 4, user: %{username: "ck3g"}},
%{content: "Hey ", id: 3, user: %{username: "user"}},
%{content: "Hello there", id: 2, user: %{username: "ck3g"}}
]
Although I didn’t manage to find how to properly use “joins” for that syntax.
If you know how to do that, let me know in the comments below.
Thanks to Robert Beene who pointed out how to use “joins” in the comments below.
Which type of syntax to use, it is completely up to you.
Fragments
Ecto provides a lot of functionality to work with databases. Although it does not cover 100% functionality some databases have.
When you are facing a situation in which you need to use some features of a database which is not supported by Ecto (yet). You can use a feature called “query fragments”. You can construct a small piece of SQL and Ecto will safely pass it down to the database level.
We can see how to use fragments in the following example and the SQL it produces.
iex> import Ecto.Query
iex> Prater.Repo.all(
from r in Prater.Conversation.Room,
where: fragment("lower(name) = ?", ^String.downcase("Lobby")))
SELECT r0."id", r0."description", r0."name"
FROM "rooms" AS r0
WHERE (lower(name) = $1) ["lobby"]
[
%Prater.Conversation.Room{
description: "The general chat room. Everybody welcome here.",
id: 1,
name: "Lobby"
}
]
Wrapping up
Today we have we have covered an important piece of Ecto functionality. Ecto queries. We haven’t covered every single opportunity Ecto gives us, but that wasn’t the goal of that article. I think that can give you a starting point to learn about Ecto abilities. Some of them I think we will cover someday in the following articles. Stay tuned.
The related code you can find on a GitHub page.