Peter P.
Peter P.

Reputation: 3507

Make sure has_many :through association is unique on creation

If you are saving a has_many :through association at record creation time, how can you make sure the association has unique objects. Unique is defined by a custom set of attributes.

Considering:

 class User < ActiveRecord::Base
   has_many :user_roles
   has_many :roles, through: :user_roles

   before_validation :ensure_unique_roles

   private
   def ensure_unique_roles
      # I thought the following would work:
      self.roles = self.roles.to_a.uniq{|r| "#{r.project_id}-#{r.role_id}" }
      # but the above results in duplicate, and is also kind of wonky because it goes through ActiveRecord assignment operator for an association (which is likely the cause of it not working correctly)

     # I tried also:
     self.user_roles = []
    self.roles = self.roles.to_a.uniq{|r| "#{r.project_id}-#{r.role_id}" }

     # but this is also wonky because it clears out the user roles which may have auxiliary data associated with them
   end
 end

What is the best way to validate the user_roles and roles are unique based on arbitrary conditions on an association?

Upvotes: 2

Views: 2476

Answers (2)

Kache
Kache

Reputation: 16667

The best way to do this, especially if you're using a relational db, is to create a unique multi-column index on user_roles.

add_index :user_roles, [:user_id, :role_id], unique: true

And then gracefully handle when the role addition fails:

class User < ActiveRecord::Base
  def try_add_unique_role(role)
    self.roles << role
  rescue WhateverYourDbUniqueIndexExceptionIs
    # handle gracefully somehow
    # (return false, raise your own application exception, etc, etc)
  end
end

Relational DBs are designed to guarantee referential integrity, so use it for exactly that. Any ruby/rails-only solution will have race conditions and/or be really inefficient.

If you want to provide user-friendly messaging and check "just in case", just go ahead and check:

already_has_role = UserRole.exists?(user: user, role: prospective_role_additions)

You'll still have to handle the potential exception when you try to persist role addition, though.

Upvotes: 4

jvillian
jvillian

Reputation: 20263

Just do a multi-field validation. Something like:

class UserRole < ActiveRecord::Base
  validates :user_id,
            :role_id, 
            :project_id,
            presence: true

  validates :user_id, uniqueness: { scope: [:project_id, :role_id] }            

  belongs_to :user, :project, :role
end

Something like that will ensure that a user can have only one role for a given project - if that's what you're looking for.

As mentioned by Kache, you probably also want to do a db-level index. The whole migration might look something like:

class AddIndexToUserRole < ActiveRecord::Migration
  def change
    add_index :user_roles, [:user_id, :role_id, :project_id], unique: true, name: :index_unique_field_combination
  end
end

The name: argument is optional but can be handy in case the concatenation of the field names gets too long (and throws an error).

Upvotes: 2

Related Questions