I'm working on a multi-user, multi-room chat app, for which my models are as follows (the App model has been omitted for simplicity):
defmodule Elemental.TxChat.User do
use Elemental.TxChat.Web, :model
schema "users" do
# The rooms the user is currenly logged into
many_to_many :rooms, Elemental.TxChat.Room, join_through: "rooms_users"
timestamps()
end
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [])
|> validate_required([])
end
end
and
defmodule Elemental.TxChat.Room do
use Elemental.TxChat.Web, :model
schema "rooms" do
field :name, :string
# The user id that created this room
field :created_by, :integer
field :created_from_app, :integer
many_to_many :members, Elemental.TxChat.User, join_through: "rooms_users"
timestamps()
end
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:name, :created_from_app, :created_by])
|> validate_required([:name, :created_by, :created_from_app])
end
end
Next, I created some rooms and users (three each) from iex
. Now I got wondering: Suppose I want user1 to belong to room1 and room2, and user2 to belong to room2 and room3 ... how to do it?
It seems to me that while defining the schemas is fine, there has to be an intermediate step that does something like user1.rooms = [room1, room2]
. So I ended up on this post and saw an example of build_assoc
:
Ecto.build_assoc(current_user, :post)
So this app has users and posts, and is trying to link them. But I don't see how it gets achieved. How does the database/Ecto know which user id to link with which post id?
Anyway, I tried doing this in my app's iex
:
iex(46)> Ecto.build_assoc(user1, :rooms, room1)
%Elemental.TxChat.Room{__meta__: #Ecto.Schema.Metadata<:built, "rooms">,
created_by: 1, created_from_app: 1, id: 2,
inserted_at: #Ecto.DateTime<2016-09-16 05:00:00>,
members: #Ecto.Association.NotLoaded<association :members is not loaded>,
name: "room1", updated_at: #Ecto.DateTime<2016-09-16 05:00:00>}
I figured the function call would join user1
to the model Room
, using the data in room1
to find the destination room. My heart sank when I saw <association :members is not loaded>
in the output, but I thought I should check the database. Guess what, there's no entry in my joining table (rooms_users). :(
I think it's fairly clear that these links between models need to be somehow created, but I seem to have hit a wall. How to do this?
EDIT
Side Note: it would be much easier to make UserRoom model for users_rooms table and create associations with UserRoom.changeset/2
ORIGINAL ANSWER
I made small example project
defmodule Playground.User do
use Playground.Web, :model
alias __MODULE__
schema "users" do
field :title, :string
many_to_many :rooms, Playground.Room, join_through: "users_rooms"
timestamps()
end
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:title])
|> validate_required([:title])
end
def assoc_changeset(struct, params \\ %{}) do
struct
|> cast(params, [:title])
|> validate_required([:title])
|> add_rooms(params, struct)
end
defp add_rooms(changeset, params, %User{rooms: rooms}) do
case params do
%{add_rooms: to_be_added} when is_list(to_be_added) ->
changeset |> put_assoc(:rooms, rooms ++ to_be_added)
_ ->
changeset
end
end
end
How it works:
iex(1)> u = User.changeset(%User{}, %{title: "u"}) |> Repo.insert!
iex(2)> r1 = Room.changeset(%Room{}, %{title: "r1"}) |> Repo.insert!
iex(3)> r2 = Room.changeset(%Room{}, %{title: "r2”}) |> Repo.insert!
iex(4)> u = User.changeset(u, %{title: "u1"}) |> Repo.update!
%Playground.User{
rooms: #Ecto.Association.NotLoaded<association :rooms is not loaded>,
title: "u1",
...
}
iex(5)> u = User.assoc_changeset(u, %{add_rooms: [r1]}) |> Repo.update!
error about #Ecto.Association.NotLoaded<association :rooms is not loaded> here
iex(6)> u = Repo.preload(u, :rooms)
%Playground.User{
rooms: [],
title: "u1",
...
}
iex(7)> u = User.assoc_changeset(u, %{add_rooms: [r1]}) |> Repo.update!
%Playground.User{
rooms: [
%Playground.Room{title: "r1", ...}
],
title: "u1",
...
}
iex(7)> u = User.assoc_changeset(u, %{add_rooms: [r2]}) |> Repo.update!
%Playground.User{
rooms: [
%Playground.Room{title: "r1", ...},
%Playground.Room{title: "r2", ...}
],
title: "u1",
...
}
There is still room for improvements. Helper function add_rooms/3 probably should take user rooms from changeset instead of third argument and be changed to add_rooms/2.
It is up to you to decide what else needs to be improved.