Passer à Rails 5.1 du côté d'ActiveRecord

La montée en version de Rails est toujours un moment délicat dans le cycle de vie d'une application. En particulier les versions majeures ou de nombreuses modifications sont souvent nécessaires. Mais parfois, les versions mineures apportent leur lot de modifications profondes qui nous obligent à revoir notre code.

La technique pour mettre à jour Rails est similaire à celle pour mettre à jour n'importe quelle gem : on modifie la version dans le Gemfile, on appelle bundler, on lance la suite de test et on regarde ce qui est cassé. Pour une gem classique c'est souvent sans douleur, pour Rails qui est le cœur de nombre de nos applications lourdes, c'est souvent plus compliqué.

De Rails 5.0 à 5.1, la suite de test à remonté un certain nombre de messages de dépréciation :

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.

Ce type de message de dépréciation est assez courant lors d'une montée en version. Le framework annonce les changements à venir pour la version suivante afin de nous préparer aux changements. Ces messages n'étant que des avertissements (deprecation warning), nous n'y avions pas vraiment prêté attention car ils sont courants en phase de beta/rc de Rails. Cependant, la suite de test, ainsi que le comportement général de certaines parties de l'application ne fonctionnaient vraiment pas comme dans la version précédente.

En creusant un peu, nous avons été surpris de ne trouver ni annonce dans le ChangeLog officiel ni dans les nombreux billets que l'on trouve partout sur le net annonçant les changements de la nouvelle version de rails. C'est dans le dépôt rails que nous avons trouvé une explication, sur ce commit en particulier :

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.

Bien qu'il soit indiqué DEPRECATION WARNING pour la prochaine version dans les logs, il y a bel et bien un changement qui s'applique sur la version courante. Le commit en question datant du 9 juin 2016, peut-être devait il être inclus dans la release 5.0 de Rails sorti le 30 du même mois, et a été repoussé à la dernière minute ? Toujours est-il que le changement est là et qu'il faut s'en accommoder.

Comportement des callbacks

Lors du passage de Rails en 4.x, des changements sur les callbacks avait été fait pour bien séparer les changements sur le modèle avant sont écriture sur la DB et après. Concrètement, il fallait faire bien attention à se réserver l'usage des helpers <attribute>_changed? pour les callbacks after_save et de previous_changes[:<attributes>] pour les callbacks after_commit. On en arrivait à du code du genre :

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

Si vous n'êtes pas familiers avec after_commit, nous vous invitons à découvrir ce callback très pratique qui n'est exécuté qu'après la transaction SQL terminée lors de l'enregistrement d'un modèle. Ce n'est pas le cas avec after_save qui n'attends pas le retour de la base de données pour s'exécuter, et est souvent source de race conditions désagréables. On pense par exemple à une tâche asynchrone (par exemple un worker Sidekiq) qui se lance avant même qu'un nouvel enregistrement soit sauvegardé dans la base de données.

En Rails 5.0, les méthodes attribute_changed? ne sont pas disponibles à l'exécution du callback after_commit et on doit utiliser previous_changes[:attribute] pour accéder aux changements. Pas vraiment pratique.

Le code Rails 5.0 devient plus cohérent et même plus simple à comprendre avec Rails 5.1, car on peut utiliser saved_changed_to_<attribute> :

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

L'usage de <attribute>_changed? par rapport à saved_change_to_<attribute> est également plus compréhensible :

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

Mais pour bien comprendre ce qu'il se passe au sein des callbacks, plaçons des points d'arrêt sur notre modèle User :

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

On constate que saved_change_to_name? a le même comportement dans les 2 types de callbacks et c'est bien plus simple ainsi.

previous_changes existe toujours mais uniquement dans un callback after_commit, et le message de dépréciation est assez nébuleux. Nous utiliserons cette méthode uniquement quand nous voudrons récupérer la liste des attributs modifiés, avec leurs valeurs précédentes & actuelles.

Pour résumer

À partir de rails 5.1, de nouvelles méthodes helpers sont disponibles pour clarifier et simplifier l'accès aux dirty attributes. Elles remplacent les précédents souvent ambigus dans leur comportement :

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)

Veillez à bien les utiliser, et dans le doute surveillez vos logs pour détecter les messages de dépréciation lors de votre prochaine montée en version.