Ole Spaarmann
Ole Spaarmann

Reputation: 16749

Elixir Ecto 2: Self-referencing many_to_many and Ecto.Changeset.put_assoc. How?

I'm trying to create a self-referencing many_to_many relationship in Ecto 2. I followed this blogpost and got it working so far. However trying to update the associations with Ecto.Changeset.put_assoc always leads to an error. And I don't understand why.

This is the setup:

First the migrations to create users and the association table for contacts (every user can have multiple contacts which are also users):

# priv/repo/migrations/create_users_table.ex
defmodule MyApp.Repo.Migrations.CreateUsersTable do  
  use Ecto.Migration

  def change do
    create table(:users) do
      add :username, :string
    end

    create unique_index(:users, [:username])
  end
end  

# priv/repo/migrations/create_contacts_table.ex
defmodule MyApp.Repo.Migrations.CreateContactsTable do  
  use Ecto.Migration

  def change do
    create table(:contacts) do
      add :user_id, references(:users, on_delete: :nothing), primary_key: true
      add :contact_id, references(:users, on_delete: :nothing), primary_key: true

      timestamps()
    end
  end
end 

Now the models:

defmodule MyApp.User do  
  use MyApp.Web, :model
  alias MyApp.Contact

  schema "users" do
    field :username, :string
    # Add the many-to-many association
    has_many :_contacts, MyApp.Contact
    has_many :contacts, through: [:_contacts, :contact]
    timestamps
  end

  # Omitting changesets
end   

defmodule MyApp.Contact do  
  use MyApp.Web, :model

  alias MyApp.User

  schema "contacts" do
    belongs_to :user, User
    belongs_to :contact, User
  end
end 

This now works:

user = Repo.get!(User, 1) |> Repo.preload :contacts
user.contacts  

Now I'm trying to parse a string of comma-separated IDs, fetch the users, turn them into changesets and attach them as contacts to another user

# Parse string and get list of ids
contact_ids = String.split("2, 3, 4", ",") |> Enum.map(&String.trim/1)

# Get contacts
contacts = Enum.map(contact_ids, fn(id) ->
  Repo.get! User, id
end)

# Turn them into changesets
contact_changesets = Enum.map(contacts, &Ecto.Changeset.change/1)

# Update the associations
result = user |> Ecto.Changeset.change
|> Ecto.Changeset.put_assoc(:contacts, contact_changesets)
|> Repo.update

The error I'm getting is

** (ArgumentError) cannot put assoc `contacts`, assoc `contacts` not found. Make sure it is spelled correctly and properly pluralized (or singularized)
    (ecto) lib/ecto/changeset.ex:568: Ecto.Changeset.relation!/4
    (ecto) lib/ecto/changeset.ex:888: Ecto.Changeset.put_relation/5

But I can preload the association and I can also manually create associations. So I could loop over the contact_ids and do this:

result = user 
|> Ecto.Changeset.change 
|> Ecto.Changeset.put_assoc(:contacts, [Contact.changeset(%Contact{}, %{user_id: user_id, contact_id: contact_id})])
|> Repo.insert

What am I doing wrong here?

Upvotes: 2

Views: 963

Answers (1)

Oliver
Oliver

Reputation: 4081

I couldn't reproduce the problem with my own associations. I have a feeling you might at some point be working with %MyApp.Contact{} instead of %MyApp.User{}? Can you check that and report back?

Something that I did notice (but would not produce this error): you are trying to put MyApp.User changesets into the :contacts association, which requires MyApp.Contact changesets.

You could try to use many_to_many. You can be sure you get back MyApp.User with it, so there will be less edge cases like this. It was specifically made for these types of associations anyways.

MyApp.User schema:

schema "users" do
  field :username, :string

  many_to_many :contacts, MyApp.User, join_through: MyApp.Contact, join_keys: [user_id: :id, contact_id: id]
  timestamps
end

I added the join_keys option, because I think without it Ecto might get confused in this situation. I suggest you try with and without it.

With many_to_many you can insert MyApp.User changesets directly into the :contacts association as well, which seems to be what you want to do.

Upvotes: 2

Related Questions