Reading Rails - Change Tracking

Today we will look at how Rails tracks changes to a model's attributes.

person = Person.find(8)
person.name = "Mortimer" 
person.name_changed?    #=> true
person.name_was         #=> "Horton"
person.changes          #=> {"name"=>["Horton","Mortimer"]}
person.save!            
person.changes          #=> {}

Where does the name_changed? method come from, how does changes get created? Let's go behind the scenes, and see how it works.

To follow along, open each library in your editor with qwandry, or just look it up on Github.

ActiveModel

When investigating features in ActiveRecord, you should first look in ActiveModel. ActiveModel defines logic that isn't tied to the database. We'll start out in dirty.rb. At the very beginning of the module, there are several calls to attribute_method_suffix:

module Dirty
  extend ActiveSupport::Concern
  include ActiveModel::AttributeMethods

  included do
    attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
    #...

attribute_method_suffix defines custom attribute accessors. This is tells rails to dispatch calls with suffixes such as _changed? to special handler methods. To see how they're implemented, scroll down and look at def attribute_changed?:

def attribute_changed?(attr)
  changed_attributes.include?(attr)
end

We'll save the details of how these methods get wired up for another post, but when you call a method like name_changed?, Rails will pass in "name" as attr. Scroll up a tiny bit, and you'll see that changed_attributes is just a Hash containing the attribute mapped to its old value:

# Returns a hash of the attributes with unsaved changes indicating their original
# values like <tt>attr => original value</tt>.
#
#   person.name # => "bob"
#   person.name = 'robert'
#   person.changed_attributes # => {"name" => "bob"}
def changed_attributes
  @changed_attributes ||= {}
end

If you haven't seen ||= in Ruby before, it's a common idiom for initializing values. The first time it's called, the variable is nil, so it returns the the empty Hash and sets @changed_attributes. The second time it is called, @changed_attributes will be set already. So now we've answered our first question, name_changed? gets dispatched to attribute_changed?, which looks up the value in changed_attributes.

In our example, we saw that changes returns a Hash with both the new and old values like: {"name"=>["Horton","Mortimer"]}. Let's figure out how that gets built:

def changes
  ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
end

This may look a bit dense, but we can unpack it step by step. First we start with ActiveSupport::HashWithIndifferentAccess, this is a subclass of Hash defined in ActiveSupport that couldn't care less if you use strings or symbols to access it:

hash = ActiveSupport::HashWithIndifferentAccess.new
hash[:name] = "Mortimer"
hash["name"] #=> "Mortimer"

The next bit is a little odd, Rails is calling Hash[]. This is an obscure way of initializing a hash from an array of key / value pairs.

Hash[
  [:name, "Mortimer"],
  [:species, "Crow"]
] #=> {[:name, "Mortimer"]=>[:species, "Crow"]}

For more bits like this, check out Hash Tricks. The remainder of the method is more clear. The attribute names are mapped to an array: [attr, attribute_change(attr)]. The first element, attr, becomes a key, while the value is the result of attribute_change(attr).

def attribute_change(attr)
  [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
end

This is another dispatched attribute method, but in this case it returns an Array with two elements, the first is the old value of attr from the changed_attributes hash. The second is the new value. Rails gets the new value by using __send__ to call the method named by attr. This pair is then sent back, and used as the value in the changes hash.

ActiveRecord

Now let us find out how Rails records the changes. ActiveRecord implements the code to read and write the attributes that ActiveModel tracks. Just like ActiveModel, ActiveRecord has a dirty.rb that we'll dig into. Poking around in the file for changed_attributes shows use that this file wraps ActiveRecord's write_attribute with logic to track changes.

# Wrap write_attribute to remember original attribute value.
def write_attribute(attr, value)
  attr = attr.to_s

  # The attribute already has an unsaved change.
  if attribute_changed?(attr)
    old = @changed_attributes[attr]
    @changed_attributes.delete(attr) unless _field_changed?(attr, old, value)
  else
    old = clone_attribute_value(:read_attribute, attr)
    @changed_attributes[attr] = old if _field_changed?(attr, old, value)
  end

  # Carry on.
  super(attr, value)
end

Let us detour for a moment, and address method wrapping. This is a very common pattern in the Rails source. When you call super, Ruby looks at all the object's ancestors, which includes modules. Since a class can include many modules, you can wrap methods in many layers. Here's a simple example:

module Shouting
  def say(message)
    message.upcase
  end
end

class Speaker
  include Shouting

  def say(message)
    puts super(message)
  end
end

Speaker.new.say("Hi!") #=> "HI!"

Notice that Shouting is a module Speaker is including, not a class it is extending. Rails uses this idiom of wrapping methods to keep separate concerns in separate files. This does mean you may need to poke around in more files to see the whole picture. If you see a call to super, it's a good indication that there is more to see elsewhere. If you want to learn more, James Coglan has a very detailed article covering Ruby's method dispatch.

Back to write_attribute. There are two possible cases depending on whether the attribute has already been changed. The first branch checks to see if you are resetting an attribute to its original value, if so it deletes that attribute from the hash of changed attributes. The second branch only records a change if the new value is different from the old one. Once the change is recorded, the actual logic for updating an attribute gets invoked with super.

Recap

Rails provides change tracking for your models. Much of this functionality is implemented in ActiveModel, but the actual logic for intercepting changes resides in ActiveRecord.

Investigating this feature unearthed some other interesting tidbits:

Let me know if you have a suggestions other parts of Rails you would like to read.

blog comments powered by Disqus
Monkey Small Crow Small