L’évolution. Sujet passionnant s’il en est ! Un jour ou l’autre, nous devons tous faire face à cette situation : le changement. Nos besoins — ou ceux de nos clients — évoluent, changent avec le temps ; et nous devons donc accompagner ce changement en adaptant le code de nos projets. Nous devons effectuer des migrations.
Nous avons tous une idée de ce qu’est ou ce que doit être une migration ; il s’agit d’adapter la structure de nos données ou nos données elles-mêmes à nos nouveaux besoins. Ainsi nous distinguons déjà deux formes de migration : d’une part les migrations de structure (on parlera de schema en anglais) et d’autre part les migrations de données. Voyons ensemble ce que Rails met à notre disposition pour réaliser nos migrations et comment utiliser ces outils au mieux, en évitant les pièges que nous pourrions rencontrer !
Voici ce que nous dit le guide de migration d’ActiveRecord :
Migrations are a convenient way to alter your database schema over time in a consistent and easy way. They use a Ruby DSL so that you don’t have to write SQL by hand, allowing your schema and changes to be database independent.
Il est intéressant de noter qu’il n’est question ici que de migration de notre structure de données et en aucun cas de migration de données. Et pour cause, Rails ne nous fournit qu’un ensemble d’outils dédiés à cette première forme de migration.
Cela ne nous empêche pas de procéder à des migrations de données à l’aide de ces mêmes outils ; mais nous verrons plus loin pourquoi cela tient souvent de la fausse bonne idée.
Voici un exemple de migration tel que nous en présente le guide :
class CreateProducts < ActiveRecord::Migration[5.0]
def change
create_table :products do |t|
t.string :name
t.text :description
t.timestamps
end
end
end
Sans trop rentrer dans les détails — car le guide, bien qu’en anglais, le fait
déjà très bien — nous remarquons que nous avons une migration définie par une
classe CreateProducts
qui étend d’ActiveRecord::Migration[5.0]
et contenant
une méthode change
qui sera appelée lors de l’exécution de la migration et
dont l’objet est la création d’une table products
constituée d’un nom, d’une
description et de deux timestamps que sont la date de création et la date de
mise à jour de l’entrée en base qui correspondra à une instance de notre classe
Product
.
Cette méthode change
est bidirectionnelle ; au sein de celle-ci nous pouvons
faire appel à la méthode reversible
qui prend en paramètre un bloc permettant
de définir des actions à réaliser en fonction du sens de la migration, up
ou
down
, correspondant respectivement aux tâches rails db:migrate
et rails
db:rollback
. Cette dernière permettant de restaurer notre base de données dans
son état précédent, avant la migration.
Note : Pensez à toujours écrire vos migrations de manière à ce qu’elles
soient réversibles. Si vous utilisez les outils proposés par ActiveRecord, tel
que create_table
, Rails sera capable d’en déduire l’action à réaliser en cas
de rollback.
Bien souvent, les problèmes de réversibilité se posent lors de migrations de
structure à coup de SQL via ActiveRecord::Base.connection.execute
, ou lors de
migrations de données.
Si vous devez à tout prix écrire une migration non réversible, ActiveRecord fournit une exception dédiée :
raise ActiveRecord::IrreversibleMigration
Ce faisant, toute tentative de rollback sera infructueuse. Peut-être devriez-vous envisager de créer une table temporaire ou un fichier JSON pour y stocker les données qui vont être perdues afin de les restaurer lors du rollback.
Pour en connaître davantage sur les méthodes disponibles, la documentation
d’ActiveRecord vous sera d’une grande aide. Notamment
SchemaStatements
qui détaille les méthodes disponibles à l’intérieur d’un bloc change
, up
et
down
, ainsi que
TableDefinition
et
Table
qui listent les méthodes accessibles lors d’un appel à create_table
et
change_table
.
OK. Nous avons vu comment migrer notre structure de données. Nous avons aussi compris qu’une migration irréversible était une mauvaise chose et que cela était bien souvent dû à des migrations de données…
Hélas ! La migration de données est chose fourbe ! Et même si nous passons souvent au travers des mailles du filet, les cas d’échec de migration sont une réalité. À quoi sont-ils dus ? Bien souvent à un manque de rigueur lorsque nous rédigeons nos migrations.
L’exemple suivant est turpidement inspiré d’une section du guide de migration d’ActiveRecord aujourd’hui supprimée pour cause de… manque d’exemple concret justifiant sa présence.
Cela dit, l’utilisation de modèles ActiveRecord n’est pas en soi une mauvaise pratique ; il nous appartient seulement de bien comprendre la portée des méthodes que nous utilisons et le cycle de vie d’une migration.
Lorsque nous utilisons un modèle ActiveRecord au sein d’une migration, c’est le modèle tel qu’il est défini au moment de l’exécution de cette dernière qui sera utilisé. Voyons avec un exemple l’incidence que cela peut avoir.
Alice crée une migration pour la table products
qui ajoute un flag et
l’initialise à false
.
# db/migrate/20100513121110_add_flag_to_product.rb
class AddFlagToProduct < ActiveRecord::Migration
def change
add_column :products, :flag, :boolean
reversible do |dir|
dir.up { Product.update(:all, flag: false }
end
end
end
Puis s’assure dans le modèle, via une validation, que ce champ n’accepte que des valeurs binaires.
# app/models/product.rb
class Product < ActiveRecord::Base
validates :flag, inclusion: { in: [true, false] }
end
Elle rédige ensuite une seconde migration qui ajoute un champ fuzz
à cette
même table, met à jour tous les produits en leur assignant la valeur "fuzzy"
, et
s’assure, dans le modèle, que ce champ contient toujours une valeur.
# db/migrate/20100515121110_add_fuzz_to_product.rb
class AddFuzzToProduct < ActiveRecord::Migration
def change
add_column :products, :fuzz, :string
reversible do |dir|
dir.up { Product.update(:all, fuzz: 'fuzzy') }
end
end
end
# app/models/product.rb
class Product < ActiveRecord::Base
validates :flag, inclusion: { in: [true, false] }
validates :fuzz, presence: true
end
Bon, l’exemple se veut simpliste, mais des cas similaires se présentent parfois dans la réalité, et plus souvent qu’il n’y parait.
Alice, puisqu’elle a joué ses migrations au fur et à mesure de ses développements, n’a rencontré aucun souci.
Puis arriva ce qui devait arriver : Bob rentra de vacances. D’attaque et plein d’entrain, il récupère la dernière version du projet
❯ git pull
Updating 5f47a2cc..6447db06
Fast-forward
app/models/product.rb
db/migrate/20100513121110_add_flag_to_product.rb
db/migrate/20100515121110_add_fuzz_to_product.rb
3 files changed
Et joue les migrations d’Alice
❯ rails db:migrate
…
rake aborted!
An error has occurred, this and all later migrations canceled:
undefined method `fuzz' for #<Product:0x000001049b14a0>
Bam ! Que s’est-il passé ? Bob a tout simplement joué la première migration avec
un modèle Product
contenant la validation sur le champ fuzz
, champ dont la
migration n’a pas encore été faite.
La validation a en effet été vérifiée, puisque l’on a fait appel à la méthode
update
qui n’inscrit les données en base qu’à la condition que toutes les
validations sur l’objet instancié passent.
Plusieurs erreurs ont été faites ici. Tout d’abord, l’utilisation d’une méthode
instanciant des objets ActiveRecord et passant à travers tout le cycle de
callbacks et les validations, alors que nous aurions très bien pu faire appel
à update_all
qui se serait contenté de construire une unique requête SQL et
nous aurait évité ces désagréments.
Parfois, cependant, cela ne suffit pas. Si Alice en avait profité pour modifier
le default_scope
de notre classe Product
— chose à éviter tant que faire se
peut, mais qui se produit néanmoins lorsqu’on active une gem comme
paranoia — la requête SQL générée par la
méthode update_all
en aurait été affectée, et nous serions tombés dans les
mêmes travers.
Pour pallier cette situation — et il s’agit bien d’un palliatif car l’approche
est loin d’être élégante — nous pouvons redéfinir la classe Product
localement, comme ceci :
# db/migrate/20100513121110_add_flag_to_product.rb
class AddFlagToProduct < ActiveRecord::Migration
class Product < ActiveRecord::Base
end
def change
add_column :products, :flag, :boolean
Product.reset_column_information
reversible do |dir|
dir.up { Product.update_all flag: false }
end
end
end
Bien sûr, il faudra faire de même avec la seconde migration. Nous n’avons donc
que le strict minimum : une classe Product
sans validation, sans modification
du scope par défaut. C’est cette classe qui prendra le dessus sur celle
définie dans notre fichier app/models/product.rb
. Et avant d’y faire appel,
nous en profitons pour réinitialiser le cache de Rails grâce à la ligne
suivante :
Product.reset_column_information
Si Alice avait écrit ses migrations de la sorte, Bob n’aurait eu aucun problème en les jouant.
Nous avons vu comment nous éviter quelques mésaventures, mais cela est loin d’être suffisant ! Il y a en effet d’autres points de vigilance à surveiller.
Ne pas instancier chaque objet quand on peut s’en dispenser, c’est déjà très
bien ! Mais on peut aller encore plus loin en limitant l’impact de nos
migrations aux seuls enregistrements effectivement concernés par celles-ci.
Pour cela, rien ne vaut l’utilisation d’un scope, ou à défaut, d’une petite
clause where
bien pensée.
Quand l’instantiation d’objets ActiveRecord s’avère nécessaire, pensez à le
faire par lot grâce aux méthodes fournies par
ActiveRecord::Batches
telles que find_each
ou find_in_batches
. Vous vous éviterez ainsi de charger
l’ensemble des données en une seule fois.
Instancier le strict minimum d’objets, réduira l’empreinte mémoire. Limiter la portée de vos requêtes soulagera votre base de donnée et accélèrera leur exécution.
Il est important de distinguer les migrations de structure des migrations de données. On l’a vu, les migrations de données peuvent parfois être assez complexes et vos migrations n’en seront que plus compréhensibles si vous séparez ces deux notions. J’ai pour habitude d’insister sur l’intention du développeur ; quand vous nommez un fichier de migration, votre intention doit transparaître dans ce nom : ici j’altère ma structure, là je mets à jour mes données. Certains frameworks comme Django incitent d’ailleurs à séparer ces deux concepts.
Pour aller plus loin, il est même envisageable, comme le suggère Elle Meredith de ThoughtBot, d’extraire toutes les migrations de données dans des tâches Rake temporaires. Comme vous le constaterez à la lecture de l’article, cette approche a ses avantages, mais apporte aussi son lot d’inconvénients.
On peut noter le fait qu’une telle migration de données, si elle est bien écrite, sera idempotente. Cela signifie qu’elle peut être jouée à plusieurs reprises sans risque. Tout comme la réversibilité, l’idempotence est une caractéristique importante qu’il est bon de garder à l’esprit lorsque l’on rédige nos migrations afin de s’éviter de mauvaises surprises.
Une migration, soit ça passe, soit ça ne passe pas. Mais en aucun cas nous ne souhaitons qu’elle passe partiellement ! Ainsi, Rails encapsule systématiquement toute migration de structure — pour peu que votre base de données le supporte — au sein d’une transaction. S’assurant ainsi de son intégrité.
Pour vos migrations de données, si vous avez opté pour une tâche Rake, il est important de vous assurer de la cohérence des données manipulées. Pour ce faire, il vous faudra encapsuler vous-même vos manipulations de données dans une transaction, comme ceci :
ActiveRecord::Base.transaction do
# Do stuff on Product
end
Dernier axe d’amélioration que je vous propose : soyez verbeux ! Il n’y a rien de plus frustrant que de rester bêtement devant une migration dont on ne sait si elle est réalisée à 2 ou 92%. ActiveRecord nous met pourtant à disposition quelques méthodes pour agrémenter nos migrations.
suppress_messages
prend un bloc en paramètre et le rend silencieux en oubliant de transmettre tout message en émanant.
say
affiche le message qui lui est passé en paramètre.
say_with_time
affiche le temps d’exécution du bloc reçu en paramètre et
considère que si le bloc retourne un entier, alors il s’agit du nombre
d’enregistrements affectés.
Ainsi, cet exemple de migration extrait du guide :
class CreateProducts < ActiveRecord::Migration[5.0]
def change
suppress_messages do
create_table :products do |t|
t.string :name
t.text :description
t.timestamps
end
end
say "Created a table"
suppress_messages { add_index :products, :name }
say "and an index!", true
say_with_time 'Waiting for a while' do
sleep 10
250
end
end
end
Retournera ceci :
== CreateProducts: migrating =================================================
-- Created a table
-> and an index!
-- Waiting for a while
-> 10.0013s
-> 250 rows
== CreateProducts: migrated (10.0054s)
=======================================
Soyez conscients de la portée et du potentiel des outils que vous utilisez et avez à disposition. Anticipez les cas d’erreur et prévenez-les. Tâchez d’écrire des migrations réversibles et testez le rollback en environnement de développement pour éviter les mauvaises surprises en production si une telle opération s’avérait nécessaire !
L’équipe Synbioz.
Libres d’être ensemble.