Rails idiom to avoid duplicates in has_many :through

12,312

Solution 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.

Solution 2

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.

Solution 3

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

Solution 4

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

validates_uniqueness_of :user_id, :scope => [:role_id]
Share:
12,312
KingPong
Author by

KingPong

Polyglot programmer and automation zealot. Currently automating massively scalable architecture for The Weather Company.

Updated on June 06, 2022

Comments

  • KingPong
    KingPong almost 2 years

    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">]
  • KingPong
    KingPong over 14 years
    Thanks. That doesn't actually do what I want though (which is a set-like behavior), and I've clarified what that is in the original post. Sorry 'bout that.
  • KingPong
    KingPong over 14 years
    It doesn't work like that. I'll update the post to include the test.
  • austinfromboston
    austinfromboston over 14 years
    I think this is the best answer for your problem. If you are careful in creating your interface, a user would have to hack it to add the wrong role anyway, in which case a validation exception is a totally suitable response.
  • KingPong
    KingPong over 14 years
    Heh, are you crazy? Users don't add their own roles :-) The typical use case is that a user becomes a member of a role as a side effect of something else. For example, buying a particular product. Other products may also provide the same role, so there is a chance for duplication there. I'd rather do the duplication checking in one place than in whatever random places need to ensure a user has a role. In this sense, giving a user a role he already has is NOT an error condition.
  • KingPong
    KingPong over 14 years
    Thanks, I'll try the association extension.
  • KingPong
    KingPong over 14 years
    That worked perfectly. Thanks! The part I was missing when I tried something like this myself was the proxy_owner bit.
  • KingPong
    KingPong over 14 years
    For posterity, the above method can be shortened and genericized to: def <<(*items) super(items - proxy_target) end
  • austinfromboston
    austinfromboston over 14 years
    That was my first version :). I didn't have actual models set up and was concerned that super(nil) might cause errors. updating the answer with your version and leaving the sub-par code here ( for posterity ): def <<(*items); new_items = items - proxy_owner.roles; super( new_items ) unless new_items.empty?; end
  • Turadg
    Turadg over 11 years
    For Rails 3.1, s/proxy_owner/proxy_association.owner/ related Q
  • Turadg
    Turadg over 11 years
    Why make the argument *items when << takes only a single object? ruby-doc.org/core-1.9.3/Array.html#method-i-3C-3C
  • austinfromboston
    austinfromboston over 11 years
    It's an easy way to transparently convert the added item to an array. I'll update it with the new idioms.
  • Turadg
    Turadg over 11 years
    It's odd to me that << dedupes for has_many but not has_many :through. However a Rails issue to fix this (github.com/rails/rails/issues/8573) was rejected with "This is your domain logic so it your responsibility to check it."
  • Ashitaka
    Ashitaka over 10 years
    Couldn't you change that exception to ActiveRecord::RecordNotUnique? I like this answer. Be aware of race conditions though.
  • Ruby Racer
    Ruby Racer about 8 years
    Nice answer. I used it without validates_uniqueness_of, having declared unique index in database and works charmingly.
  • lulalala
    lulalala over 7 years
    @Turadg Now << can take more than one argument guides.rubyonrails.org/…
  • DonMB
    DonMB almost 3 years
    Correct and most clean answer in my opinion