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:
- ActiveModel defines
attribute_method_suffix
to dispatch methods likename_changed?
. ||=
is a convenient way to initialize variables on demand.HashWithIndifferentAccess
will treat string and symbol keys the same.- A
Hash
can be initialized withHash[ key_value_pairs ]
. - You can use modules to intercept and layer functionality onto a method.
Let me know if you have a suggestions other parts of Rails you would like to read.
More articles in this series
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions