Read more

Careful when writing to has_many :through associations

Arne Hartherz
December 20, 2013Software engineer at makandra GmbH

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.

Illustration money motivation

Opscomplete powered by makandra brand

Save money by migrating from AWS to our fully managed hosting in Germany.

  • Trusted by over 100 customers
  • Ready to use with Ruby, Node.js, PHP
  • Proactive management by operations experts
Read more Show archive.org snapshot

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

Posted by Arne Hartherz to makandra dev (2013-12-20 16:37)