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:
- It adds a callback, invoked
before_addandbefore_removeon the association, which:
- marks the
Answeras dirty, and - saves the original contents of the collection.
- marks the
- Because the answer is dirty, it will be validated and saved. It adds a
before_validationcallback to the model which:
- marks the
Answeras clean (so that it won’t be unnecessarily saved), and - invokes the
remove_empty_answerscallback, passing it the sets of records that were removed and added.
- marks the
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