Rails idiom to avoid duplicates in has_many :through

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

Answers


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.


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.


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

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

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

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.


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

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.


Need Your Help

Compile 32 bit binary on 64 bit system

windows go

I've coded a Go program in a 64 bit system but I want to compile a 32 bit binary!

glCreateShader is crashing

c opengl graphics glut glew

I should have the newest version of Glew and Glut so that shouldn't be the problem. Everything should be linked, and I'm using MS visual studio 2010.