Search code examples
elixirphoenix-frameworkecto

Unable to create many to many associations in Ecto


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?


Solution

  • 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.