Rails idiom to avoid duplicates in has_many :through

匿名 (未验证) 提交于 2019-12-03 02:14:01

问题:

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">]

回答1:

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.



回答2:

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 


回答3:

Use Array's |= 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.



回答4:

i think the proper validation rule is in your users_roles join model:

validates_uniqueness_of :user_id, :scope => [:role_id] 


回答5:

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.



回答6:

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 


回答7:

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.



标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!