Careful when writing to has_many :through associations

tl;dr: Using has_many associations with a :through option can lead to lost or duplicate records. You should avoid them, or only use them to read records.

Consider this:

class User < ActiveRecord::Base
end

class Party < ActiveRecord::Base
  has_many :invitations
  has_many :users, through: :invitations, include: :user, order: 'users.name'
end

class Invitation < ActiveRecord::Base
  belongs_to :party
  belongs_to :user
  
  after_create :send_invite
  
  def send_invite
    other_user_names = party.users.collect(&:name)
    message = "You've been invited. Also coming: #{other_user_names.join(', ')}."
    deliver_email(user.email, message)
  end
end

When creating a party and lots of invitations, you want to send an e-mail to each user and tell them who else is coming.

Unfortunately, accessing party.users may or may not give you the list of users that you expect:

  • When accessing users, they are loaded from the database (probably because of the include/order combination), so you may be missing User records that have not yet been saved.
  • When creating invitation records via a nested form, you may even end up creating duplicate Invitation records for some reason (we did not entirely find out why, and we eventually did not care).

This happened on a Rails 3.2 application and is likely to happen on Rails 4 applications as well. Your best bet is to stay away from using them. Most times, it's good enough to actually loop associated records like this:

class Party < ActiveRecord::Base
  has_many :invitations
  
  def users
    invitations.reject(&:marked_for_destruction?).map(&:user).flatten
  end
end

That way you no longer can say party.users << some_user_object to create invitation records for users, but you probably should not have done that anyway (just use party.invitation.create(user: some_user_object).

Arne Hartherz Over 10 years ago