Underrated and enhanced dirty attributes changes in Rails 5.1

Upgrading to Rails 5.1 on the ActiveRecord side

Our method to upgrade Rails is somehow similar to upgrading any other ruby gem: changing the version in the Gemfile, call bundler upgrade <gem> and run out test suite to check what’s broken. While this is rather painless for a small gem, when it comes to Rails, that can be much more complicated.

Moving from 5.0 to 5.1, our test suite showed up a few deprecation messages:

DEPRECATION WARNING: The behavior of changed? inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after save returned (e.g. the opposite of what it returns now). To maintain the current behavior, use saved_changes? instead.

DEPRECATION WARNING: The behavior of changed_attributes inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after save returned (e.g. the opposite of what it returns now). To maintain the current behavior, use saved_changes.transform_values(&:first) instead.

DEPRECATION WARNING: The behavior of attribute_changed? inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after save returned (e.g. the opposite of what it returns now). To maintain the current behavior, use saved_change_to_attribute? instead.

DEPRECATION WARNING: The behavior of attribute_was inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after save returned (e.g. the opposite of what it returns now). To maintain the current behavior, use attribute_before_last_save instead.

These messages are relatively common while upgrading Rails, the framework announce the changes to come in the next version so that developers can take the time to adapt their code. Being just deprecation warnings, we did not quite notice them during Rails beta/rc phase. But as we more heavily test the behavior of out application, some part where not iso-functional to the previous version.

Digging a little, we were surprised not to find a note in the official Changelog, nor in the large amount of blog post which rise around the new releases of Rails. And the explanation were right in the Rails repository, precisely in this commit:

Deprecate the behavior of AR::Dirty inside of after_(create|update|save) callbacks

We pretty frequently get bug reports that “dirty is broken inside of after callbacks”. Intuitively they are correct. You’d expect Model.after_save { puts changed? }; model.save to do the same thing as model.save; puts model.changed?, but it does not.

However, changing this goes much farther than just making the behavior more intuitive. There are a ton of places inside of AR that can be drastically simplified with this change. Specifically, autosave associations, timestamps, touch, counter cache, and just about anything else in AR that works with callbacks have code to try to avoid “double save” bugs which we will be able to flat out remove with this change.

We introduce two new sets of methods, both with names that are meant to be more explicit than dirty. The first set maintains the old behavior, and their names are meant to center that they are about changes that occurred during the save that just happened. They are equivalent to previous_changes when called outside of after callbacks, or once the deprecation cycle moves.

The second set is the new behavior. Their names imply that they are talking about changes from the database representation. The fact that this is what we really care about became clear when looking at BelongsTo.touch_record when tests were failing. I’m unsure that this set of methods should be in the public API. Outside of after callbacks, they are equivalent to the existing methods on dirty.

Dirty itself is not deprecated, nor are the methods inside of it. They will only emit the warning when called inside of after callbacks. The scope of this breakage is pretty large, but the migration path is simple. Given how much this can improve our codebase, and considering that it makes our API more intuitive, I think it’s worth doing.

Although marked as DEPRECATION WARNING, so for the next release of Rails, these change applies to the very current version we try to upgrade to. The commit is from June, 9 2017, may be the change would have been included in the 5.0 release which came on June, 30, and has been postponed on the last minute, so the deprecation message only appears now we are on the 5.1 branch? Nonetheless, change is around the corner and we have to deal with it.

Deal with it...

Model callbacks behavior

After Rails 4.x release, some changes were made on callbacks to split update on the model before and after writing data to the database. We had to be careful to use the correct helpers: <attribute>_changed? for after_save callbacks, and previous_changes[:<attributes>] for after_commit callbacks:

  # Rails 5.0
  after_save   :update_customer_details!,  if: :status_changed?
  after_commit :create_new_email_address!, if: -> { previous_changes[:email] }

With Rails 5.1, the code above becomes more coherent and even simpler to understand, as we can use the new helper saved_changed_to_<attribute> in both callbacks:

  # Rails 5.1
  after_save   :update_customer_details!,  if: :saved_changes_to_status?
  after_commit :create_new_email_address!, if: :saved_changes_to_email?

Using the <attribute>_changed? helper along saved_change_to_<attribute> is now more comprehensible:

user = User.last
user.name                   # => "Bob"
user.name_changed?          # => false
user.saved_change_to_name?  # => false

user.name = "Clément"       # => "Clément"
user.name_changed?          # => true
user.saved_change_to_name?  # => false

user.save                   # => true

user.name                   # => "Clément"
user.name_changed?          # => false
user.saved_change_to_name?  # => true

But let’s see what’s going on inside callbacks when they are triggered by placing breakpoints in our User model:

class User < ApplicationRecord
  after_save   { binding.pry }
  after_commit { binding.pry }
end
user = User.last
user.name             # => "Clément"
user.changed?         # => false
user.saved_changes?   # => false

user.name = "Bob"     # => "Bob"
user.changed?         # => true
user.saved_changes?   # => false

user.save!
From: /Users/bob/Work/test/app/models/user.rb @ line 2 :

    1: class User < ApplicationRecord
 => 2:   after_save   { binding.pry }
    3:   after_commit { binding.pry }
    4: end

[1] > changed?
DEPRECATION WARNING: The behavior of `changed?` inside of after callbacks will
be changing in the next version of Rails. The new return value will reflect the
behavior of calling the method after `save` returned (e.g. the opposite of what
it returns now). To maintain the current behavior, use `saved_changes?`
instead.
=> true

[2] > name_changed?
DEPRECATION WARNING: The behavior of `attribute_changed?` inside of after
callbacks will be changing in the next version of Rails. The new return value
will reflect the behavior of calling the method after `save` returned (e.g. the
opposite of what it returns now). To maintain the current behavior, use
`saved_change_to_attribute?` instead.
=> true

[3] > previous_changes
DEPRECATION WARNING: The behavior of `previous_changes` inside of after
callbacks is deprecated without replacement. In the next release of Rails, this
method inside of `after_save` will return the changes that were just saved.
=> {}

[4] > saved_change_to_name?
=> true
[5] > name_before_last_save
=> "Clément"
[6] > name
=> "Bob"
[6] > exit


From: /Users/bob/Work/test/app/models/user.rb @ line 3 :

    1: class User < ApplicationRecord
    2:   after_save   { binding.pry }
 => 3:   after_commit { binding.pry }
    4: end


[1] > saved_change_to_name?
=> true
[2] > name_before_last_save
=> "Clément"
[3] > name
=> "Bob"
[4] > previous_changes[:name]
=> ["Clément", "Bob"]
[5] > exit

Here we can see saved_change_to_name? behaves the same in the 2 callbacks after_save and after_commit. It’s very pleasing not to have to ask ourself which helper to use.

Also note previous_changes still exists, and only works in a after_commit callback (with a weird deprecation messages). We would only use this method to fetch a hash of updated attributes with both their previous and current values.

To summarize

From Rails 5.1, new helper methods are introduced to clarify and simplify usage of dirty attributes inside models callbacks, and replace the old ambiguous ones:

Rails 5.0 Rails 5.1+
<attribute>_changed? –> saved_change_to_<attribute>?
<attribute>_was –> <attribute>_before_last_save
changed? –> saved_changes?
changed_attributes –> saved_changes.transform_values(&:first)

Be sure to use them right when upgrading to Rails 5.1. Check your logfiles, as even if it seem to only be a deprecation, new behavior applies.