Using put_assoc for many_to_many association without the actual record

Hi! I’m trying to emulate an “enum” data type by forming a many-to-many relationship between 2 tables. Essentially I have Users and they have Roles. The roles are seeded into the database, but rarely change. I’m using the following pattern to emulate enums for roles:

roles.ex

  @superhero %Role{
    id: 1,
    name: "superhero"
  }

  @none %Role{
    id: 2,
    name: "none"
  }

  def get_by_name("superhero"), do: @superhero
  def get_by_name("none"), do: @none
...

I have a many-to-many set up between user and role schemas:
user.ex

  schema "users" do
    field :name, :string, default: ""
    field :email, :string, default: ""
    
    many_to_many :roles, MyApp.Role,
      join_through: "user_roles", on_replace: :delete
  end

role.ex

  schema "roles" do
    field :name, :string, unique: true

    many_to_many :users, MyApp.User,
      join_through: "user_roles", on_replace: :delete
  end

I would like to be able to simply change the roles a user has using something like put_assoc without doing a database lookup for the actual role record. For example, I could change my get_by_name(name) function to do a database lookup for the actual record (In fact, changing this function to do exactly that works fine), but this seems inefficient and might cause performance troubles since this function will be used extensively throughout the codebase.

I also would prefer a solution that avoids directly modifying the join table user_roles, as that seems a bit cumbersome and counter to the intuitive/succinct “ecto way” of managing associations. I’m hoping there is an efficient way to specify an existing record (where I already know the id) to add to or modify the association collection, for example passing a (list of) struct(s) with the id of the role I want to associate.

I’ve tried the following code to do this, but it raises a unique_constraint error on the roles, which leads me to think it is trying to create a new record with the same id instead of associating the existing one.

    {:ok, user} =
      User.changeset(%User{
        email: "ironman@avengers.com",
        name: "Tony Stark",
      }, attrs)
      |> put_assoc(:roles, [MyApp.Roles.get_by_name("superhero")])
      |> Repo.insert
** (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * roles_pkey (unique_constraint)

If you would like to stop this constraint violation from raising an
exception and instead add it as an error to your changeset, please
call `unique_constraint/3` on your changeset with the constraint
`:name` as an option.

The changeset has not defined any constraint.

How can I get this to associate the existing record for superhero with id: 1, instead of trying to create a new one?

Any help or thoughts about how to best approach this would be greatly appreciated. Thanks!

Hello.
I assume Ecto tries to create a new record, because of metadata in role structs.

"superhero" |> MyApp.Roles.get_by_name() |> Ecto.get_meta(:state)

will show you :built instead of :loaded.
Try setting state using Ecto.put_meta/2:

role |> Ecto.put_meta(state: :loaded)

Note that Ecto 3.5 comes with enum support.

1 Like