I’m trying to figure out the “best” approach to inserting 1 or more associations in a single go. The Ecto associations API made me think there would be an easy way to create several associations and then put
or insert
the whole batch in a single shot.
After much experimenting, it seems the best answer is… maybe don’t use associations.
Here’s my test schema:
schema "users" do
field :email, :string
has_many :roles, Dma.Accounts.Role, on_replace: :delete_if_exists
end
@primary_key false
schema "roles" do
field :has_role, Ecto.Enum, values: [:is_admin, :is_team_lead, :is_company_admin], primary_key: true
belongs_to :user, Dma.Accounts.User, primary_key: true
end
Pretty simple… a User
has zero or more Roles
. The roles
table is simply a pair of [user_id, has_role]
(where the latter is basically just a string, mapped to atoms courtesy of Ecto). When I create a new User
they don’t necessarily have roles… those are added later on a case-by-case basis. A User
can have none or a multiple, such as is_admin
and is_team_lead
.
The intended API is like so, which allows for adding or removing one or more Roles
in a single call:
user
|> Accounts.assign_roles([:is_admin, :is_team_lead], true)
|> ...
My first attempt at an API looked like this:
def assign_roles(id, roles, true) when is_list(roles) do
for role <- roles do
%User{id: id}
|> Ecto.build_assoc(:roles, %{has_role: role})
|> Repo.insert()
end
get_user!(id)
end
The intended API is that a User
(with all Roles
preloaded) would be returned from the call – basically so that I can be sure my LiveView is reflecting a true picture of this user’s roles.
This solution is incomplete, as it throws an Ecto.ConstraintError
if a duplicate record is inserted. My desired behavior is to ideally not attempt to insert a dupe, or at least, ignore any errors – so I moved on to this:
def assign_roles(id, roles, true) when is_list(roles) do
for role <- roles do
%Role{user_id: id}
|> Ecto.Changeset.change(has_role: role)
|> Ecto.Changeset.unique_constraint(:user_id, name: :roles_pkey)
|> Repo.insert
end
get_user!(id)
end
This works, but is more verbose. It’s seems unnecessary.
I finally settled on this:
def assign_roles(id, roles, true) when is_list(roles) do
for role <- roles do
Repo.insert!(%Role{user_id: id, has_role: role}, on_conflict: :nothing)
end
get_user!(id)
end
And that brings me to my questions:
- Have I missed the point somehow? Originally I was trying to use
put_assoc
(or variations) and do this through the Ecto associations API. It just didn’t seem to work out that well. But I feel there should be a way that works well. - I don’t like having to reload the
User
at the end. On the other hand – there’s really no race condition to worry about… I just want to make sure that when the call is done, I’ve got a current snapshot of theUser
andRoles
. Is there a better way? - I haven’t tackled deleting yet… I think the typical approach is to
nil
the unwantedRole
’s but I’m wondering if it just makes sense todelete
them (e.g., use myRepo
to effectively “delete from roles where user_id=x and has_role=y”).
Any comments / suggestions / pointers to how to do it right much appreciated.