Blog tech

Migration de données avec Rails

Rédigé par François Vantomme | 6 juillet 2017

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 !

Qu’est-ce qu’une migration ?

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.

Migration de structure

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.

Et mes migrations de données, alors ?

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.

Alice et Bob sont sur un projet, Bob part en vacances…

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.

Peut-on faire mieux ?

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.

Des migrations plus rapides et moins gourmandes

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.

Des migrations découplées

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.

Des migrations plus sûres

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

Des migrations plus verbeuses

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)
=======================================

Pour conclure

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.