Detecting changes to associations

4 min read Original article ↗

The problem

On several different occasions, we’ve come across situations where we need to detect that an association has changed, but we only want one event for the transaction rather than being notified of each additional/removal.

For example, we have an Answer model, which represents the answer to a question. There are many types of questions; one of them is a multi-select question, which is simply a multiple-choice question that allows the user to select more than one option. (We present this to the user as a list of checkboxes, but it could also be rendered with <select multiple>).

To support this case, the Answer model has and belongs to many AnswerOptions:

class Answer < ActiveRecord::Base
  has_and_belongs_to_many :answer_options
end

We don’t want our database polluted with empty answers, so when a multi-select answer is updated, we’d like to check whether or not answer_options is empty, and if so, delete the corresponding answer. We could handle this in after_update:

class Answer < ActiveRecord::Base
  has_and_belongs_to_many :answer_options
  after_update :remove_empty_answers

  def remove_empty_answers(*args)
    self.destroy if answer_options.empty?
  end
end

This works great if we’re calling update_attributes on Answer directly, but in our case, we’re calling update_attributes on another model (Plan) which accepts_nested_attributes_for :answers.

What if we add an after_remove callback?

class Answer < ActiveRecord::Base
  has_and_belongs_to_many :answer_options,
    after_remove: :remove_empty_answers
end

This code has two problems. First, it’s suboptimal; if a single operation removes multiple answer options, the callback will be invoked multiple times. Second, and far worse, this destroys answers that only transiently become empty:

class Answer < ActiveRecord::Base
  has_and_belongs_to_many :answer_options,
    after_add: ->(owner,record) { puts "added #{record.id}" }
    after_remove: ->(owner,record) { puts "removed #{record.id}" }
end

answer = Answer.create(answer_option_ids: [123])
# => added 123
answer.update_attributes(answer_option_ids: [456])
# => removed 123
# => added 456

Our solution

Our first inclination was to add two new callbacks, before_change and after_change, to ActiveRecord::Associations::CollectionAssociation. That seemed like it might be too heavy-handed and invasive, though, so we decided instead to add a class macro to ActiveRecord::Base. Here’s how you use it:

class Answer < ActiveRecord::Base
  has_and_belongs_to_many :answer_options
  after_association_change :answer_options, :remove_empty_answers
end

What it does:

  1. It adds a callback, invoked before_add and before_remove on the association, which:
    1. marks the Answer as dirty, and
    2. saves the original contents of the collection.
  2. Because the answer is dirty, it will be validated and saved. It adds a before_validation callback to the model which:
    1. marks the Answer as clean (so that it won’t be unnecessarily saved), and
    2. invokes the remove_empty_answers callback, passing it the sets of records that were removed and added.

Here’s the code. I’d love to get the community’s feedback, and if this is generally useful, I’m happy to either package it as a gem or submit a pull request to rails.

The code

module ModelExtensions
  extend ActiveSupport::Concern

  module ClassMethods
    # Given a has_many or has_and_belong_to_many association (e.g. +articles+),
    # you can listen for changes by calling +after_association_change+
    #
    # e.g.
    #   after_association_change :articles, ->(name, removed_articles, added_articles) { ... }
    # or
    #   after_association_change :article_ids, ->(name, removed_article_ids, added_article_ids) { ... }
    #
    def after_association_change(name, *callbacks)
      # if name is like 'foo_ids', then association_name is 'foos'
      association_name = if name =~ /(.*)_ids\z/ then $1.pluralize else name end

      # before any change to the association, save the old state
      before_change_callback = ->(method,owner,change) { owner.changed_attributes[name] ||= owner.send(name).to_a }
      self.send("before_add_for_#{association_name}").send(:<<, before_change_callback)
      self.send("before_remove_for_#{association_name}").send(:<<, before_change_callback)

      # before validation on the owner model, check if the state has changed, and if so,
      # fire the event listener
      self.before_validation ->(owner) {
        old_records = owner.changed_attributes.delete name
        if old_records
          new_records = owner.send(name)
          removed = old_records - new_records
          added = new_records - old_records

          # Perhaps this condition is not required. What's the expected behavior if the caller
          # removes and re-adds the same entity?
          if removed.present? || added.present?
            callbacks.each do |callback|
              case callback
              when Symbol
                owner.send(callback, name, removed, added)
              else
                callback.send(name, removed, added)
              end
            end
          end
        end
      }
    end
  end
end

class ActiveRecord::Base
  include ModelExtensions
end