Jul 01, 2015

Tip: Dirty Associations with Active Record

Depending on your project you may need to know exactly when something changes in your model, to do whatever you have to do, specially important if you need to know what changed inside the associations defined in your model.

So, how do we know, when modifying a model, what changed inside that model?

The simplest answer would be: ActiveModel::Dirty, and that works, except it does not consider associations (which is the whole point of this post); so how do we get what changed inside the associations?!

First a little context, in Ruby on Rails I use Services and Wisper to keep my models slim and the external logic outside the model, which although related to the business, is not relevant to the actual models themselves.

That being said, let’s assume I have a model and according my to business rules, I have to create/update a file after this model is updated. How can we do this? Hint: not using callbacks, like after_save, that for sure!

Again, let’s assume I have my controller that uses a service to update the model, inside this service I use Wispper to broadcast the changes after saving a valid? record. Everything fine and dandy so far.

But… how do I determine what changed inside the associations? How do I know in the service, what to broadcast if one of items in a particular association changed? Because for whatever reason, this model does some crazy stuff regarding a particular association, and honestly I only want to make those changes when that association or any item from that association changes.

Well, here’s my solution, a little bit of reflection and concerns.

module Dirtyable
  extend ActiveSupport::Concern

  WhatChanged = Struct.new(:object, :changes) do
    def changed?
      self != NoChanges
    end
    delegate :has_key?, to: :changes
  end
  NoChanges = WhatChanged.new(nil, nil)

  def what_changed?
    what_changed = nil

    self.previous_changes.delete('updated_at')
    unless self.previous_changes.empty?
      what_changed = WhatChanged.new(self, self.previous_changes)
    end

    associations = self.class.reflections.select { |k, v| v.collection? }.keys

    associations.each do |association_name|
      catch :next_association_name do
        association_collection = self.send(association_name)
        throw :next_association_name unless association_collection
        throw :next_association_name if association_collection.empty?

        association_collection.each do |item|
          throw :next_association_name unless item.respond_to?(:what_changed?)

          association_changes = item.what_changed?
          if association_changes != NoChanges
            what_changed ||= WhatChanged.new(self, {})

            what_changed.changes[association_name] ||= []
            what_changed.changes[association_name] << association_changes
          end
        end
      end
    end

    what_changed || NoChanges
  end
end

The module above is pretty straightforward, it uses both ActiveModel::Dirty and reflection to get the associations that have changes, however the important thing is that in order to make this work all models that you want to keep track of must include this module.

So for example if you have a couple of models like this:

class FancyModel < ActiveRecord::Base
  include Dirtyable
  
  has_many :things
end

class Thing < ActiveRecord::Base
  include Dirtyable
  
  belongs_to :fancy_model
end

You can easily get the changes when updating FancyModel (and having changes in any of the things, in your service for example), of what changed by just executing a call like this:

what_changed = fancy_model.what_changed?
if what_changed.changed?
  if what_changed.has_key?(:things)
    # I do whatever I have to do... maybe call "broadcast(:on_things_changed)"?
  end
end

Pretty cool. With that you can easily broadcast specific events when specific things change, no need to broadcast a global on_model_changed if you don’t have to.