Reputation: 1469
I have a standard many-to-many relationship between users and roles in my Rails app:
class User < ActiveRecord::Base
has_many :user_roles
has_many :roles, :through => :user_roles
end
I want to make sure that a user can only be assigned any role once. Any attempt to insert a duplicate should ignore the request, not throw an error or cause validation failure. What I really want to represent is a "set", where inserting an element that already exists in the set has no effect. {1,2,3} U {1} = {1,2,3}, not {1,1,2,3}.
I realize that I can do it like this:
user.roles << role unless user.roles.include?(role)
or by creating a wrapper method (e.g. add_to_roles(role)
), but I was hoping for some idiomatic way to make it automatic via the association, so that I can write:
user.roles << role # automatically checks roles.include?
and it just does the work for me. This way, I don't have to remember to check for dups or to use the custom method. Is there something in the framework I'm missing? I first thought the :uniq option to has_many would do it, but it's basically just "select distinct."
Is there a way to do this declaratively? If not, maybe by using an association extension?
Here's an example of how the default behavior fails:
>> u = User.create User Create (0.6ms) INSERT INTO "users" ("name") VALUES(NULL) => #<User id: 3, name: nil> >> u.roles << Role.first Role Load (0.5ms) SELECT * FROM "roles" LIMIT 1 UserRole Create (0.5ms) INSERT INTO "user_roles" ("role_id", "user_id") VALUES(1, 3) Role Load (0.4ms) SELECT "roles".* FROM "roles" INNER JOIN "user_roles" ON "roles".id = "user_roles".role_id WHERE (("user_roles".user_id = 3)) => [#<Role id: 1, name: "1">] >> u.roles << Role.first Role Load (0.4ms) SELECT * FROM "roles" LIMIT 1 UserRole Create (0.5ms) INSERT INTO "user_roles" ("role_id", "user_id") VALUES(1, 3) => [#<Role id: 1, name: "1">, #<Role id: 1, name: "1">]
Upvotes: 43
Views: 13416
Reputation: 1060
This will create only one association in the database even if called multiple times Refer rails guide.
user.roles=[Role.first]
Upvotes: 0
Reputation: 47551
|=
Join Method.You can use Array's |=
join method to add an element to the Array, unless it is already present. Just make sure you wrap the element in an Array.
role #=> #<Role id: 1, name: "1">
user.roles #=> []
user.roles |= [role] #=> [#<Role id: 1, name: "1">]
user.roles |= [role] #=> [#<Role id: 1, name: "1">]
Can also be used for adding multiple elements that may or may not already be present:
role1 #=> #<Role id: 1, name: "1">
role2 #=> #<Role id: 2, name: "2">
user.roles #=> [#<Role id: 1, name: "1">]
user.roles |= [role1, role2] #=> [#<Role id: 1, name: "1">, #<Role id: 2, name: "2">]
user.roles |= [role1, role2] #=> [#<Role id: 1, name: "1">, #<Role id: 2, name: "2">]
Found this technique on this StackOverflow answer.
Upvotes: 29
Reputation: 21
I ran into this today and ended up using #replace, which "will perform a diff and delete/add only records that have changed".
Therefore, you need to pass the union of the existing roles (so they don't get deleted) and your new role(s):
new_roles = [role]
user.roles.replace(user.roles | new_roles)
It's important to note that both this answer and the accepted one are loading the associated roles
objects into memory in order to perform the Array diff (-
) and union (|
). This could lead to performance issues if you're dealing with a large number of associated records.
If that's a concern, you may want to look into options that check for existence via queries first, or use an INSERT ON DUPLICATE KEY UPDATE
(mysql) type query for inserting.
Upvotes: 2
Reputation: 4810
I think you want to do something like:
user.roles.find_or_create_by(role_id: role.id) # saves association to database
user.roles.find_or_initialize_by(role_id: role.id) # builds association to be saved later
Upvotes: 0
Reputation: 3780
As long as the appended role is an ActiveRecord object, what you are doing:
user.roles << role
Should de-duplicate automatically for :has_many
associations.
For has_many :through
, try:
class User
has_many :roles, :through => :user_roles do
def <<(new_item)
super( Array(new_item) - proxy_association.owner.roles )
end
end
end
if super doesn't work, you may need to set up an alias_method_chain.
Upvotes: 29
Reputation: 301
You can use a combination of validates_uniqueness_of and overriding << in the main model, though this will also catch any other validation errors in the join model.
validates_uniqueness_of :user_id, :scope => [:role_id]
class User
has_many :roles, :through => :user_roles do
def <<(*items)
super(items) rescue ActiveRecord::RecordInvalid
end
end
end
Upvotes: 4
Reputation: 2088
i think the proper validation rule is in your users_roles join model:
validates_uniqueness_of :user_id, :scope => [:role_id]
Upvotes: 2
Reputation: 838
Perhaps it is possible to create the validation rule
validates_uniqueness_of :user_roles
then catch the validation exception and carry on gracefully. However, this feels really hacky and is very inelegant, if even possible.
Upvotes: 0