Écrire des migrations de base de données qui ne cassent pas si elles sont exécutées deux fois

Vous déployez une nouvelle fonctionnalité qui nécessite une colonne supplémentaire dans la table users. Vous écrivez le script de migration, vous l'exécutez en staging, tout semble bon. Puis quelqu'un dans l'équipe relance le même script par accident lors d'une relance de pipeline. Résultat : une erreur "column already exists". Le déploiement échoue, quelqu'un doit corriger la base manuellement, et la release est retardée.

Ce scénario arrive plus souvent que la plupart des équipes ne l'admettent. La solution n'est pas compliquée, mais elle nécessite de changer la façon dont vous concevez vos scripts de migration. Vous devez les écrire pour qu'ils puissent être exécutés plusieurs fois sans causer de problèmes.

Qu'est-ce qui rend une migration sûre ?

Le principe fondamental est l'idempotence. Une opération est idempotente si l'exécuter une fois ou cent fois produit le même état final. Il ne s'agit pas d'empêcher le script de s'exécuter deux fois, mais de garantir que le faire ne cause aucun dommage.

Pensez aux raisons pour lesquelles un script pourrait être exécuté plusieurs fois : un pipeline échoue à mi-parcours et est relancé, quelqu'un exécute la migration en staging, oublie, et la relance plus tard, deux développeurs appliquent le même changement dans des environnements différents à des moments différents. Sans idempotence, n'importe lequel de ces scénarios peut corrompre vos données ou casser votre déploiement.

Le modèle simple : vérifier avant d'agir

La façon la plus directe de rendre une migration idempotente est de vérifier si le changement existe déjà avant de l'appliquer. Les bases de données SQL facilitent cela avec des instructions conditionnelles.

Voici un exemple concret. Supposons que vous deviez ajouter une colonne last_login_at pour suivre l'activité des utilisateurs :

-- Non idempotent : échoue si la colonne existe déjà
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP;

-- Idempotent : réussit que la colonne existe ou non
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP;

La première version génère une erreur si la colonne existe déjà. La seconde version s'exécute sans risque à chaque fois.

Au lieu d'écrire :

ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);

Écrivez :

ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR(20);

La clause IF NOT EXISTS garantit que le script réussit, que la colonne soit déjà présente ou non. La même approche fonctionne pour les index, les contraintes et les nouvelles tables. PostgreSQL, MySQL et la plupart des bases de données modernes supportent ces constructions conditionnelles.

Pour la suppression, la même logique s'applique. Supprimer une colonne qui n'existe pas échouera. Utilisez IF EXISTS pour rendre l'opération sûre :

ALTER TABLE users DROP COLUMN IF EXISTS old_phone_number;

Gérer les migrations de données

L'ajout et la suppression de colonnes sont les cas simples. La vraie complexité apparaît lorsque vous devez déplacer ou transformer des données existantes. Supposons que vous scindiez une colonne full_name en first_name et last_name. La migration doit :

  1. Ajouter les deux nouvelles colonnes
  2. Les remplir à partir des données existantes
  3. Gérer le cas où le script est exécuté à nouveau

Une approche naïve copierait les données sans vérifier si elles ont déjà été copiées. L'exécuter deux fois créerait des doublons ou écraserait des valeurs valides. Un modèle plus sûr ressemble à ceci :

-- Ajouter les colonnes si elles n'existent pas
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);

-- Ne remplir que si les colonnes cibles sont vides
UPDATE users 
SET first_name = SPLIT_PART(full_name, ' ', 1),
    last_name = SPLIT_PART(full_name, ' ', 2)
WHERE first_name IS NULL AND last_name IS NULL;

La clause WHERE garantit que la mise à jour ne s'applique qu'aux lignes qui n'ont pas encore été traitées. Si le script est exécuté à nouveau, ces lignes sont déjà remplies et la mise à jour ne fait rien.

Pour des scénarios plus complexes, vous pourriez avoir besoin de comparer les données source et cible, ou d'utiliser une somme de contrôle pour vérifier que la transformation a produit le résultat correct. Le principe reste le même : ne supposez jamais que les données sont dans leur état d'origine.

Les suppressions progressives réduisent les risques

Supprimer des colonnes ou des tables est risqué car il est difficile de revenir en arrière. Une approche plus sûre consiste à le faire par étapes :

  1. Première migration : renommer la colonne en nom_colonne_deprecated
  2. Attendre quelques cycles de release pour s'assurer que rien ne casse
  3. Deuxième migration : supprimer la colonne dépréciée

Ce modèle donne à votre équipe le temps de détecter tout code qui référence encore l'ancienne colonne. Si quelque chose se passe mal, le renommage est réversible. La suppression ne l'est pas.

Conserver un enregistrement de ce qui a été exécuté

L'idempotence gère le cas où un script s'exécute plusieurs fois. Mais vous devez aussi savoir quels scripts ont été exécutés, quand, et s'ils ont réussi. C'est votre piste d'audit.

La plupart des frameworks de migration comme Flyway ou Liquibase gèrent cela automatiquement. Ils créent une table qui suit chaque script de migration par nom, somme de contrôle et horodatage d'exécution. Si vous écrivez des scripts SQL bruts sans framework, créez votre propre table de suivi :

CREATE TABLE IF NOT EXISTS migration_log (
    script_name VARCHAR(255) PRIMARY KEY,
    started_at TIMESTAMP,
    completed_at TIMESTAMP,
    status VARCHAR(20),
    script_hash VARCHAR(64)
);

Avant d'exécuter une migration, insérez une ligne avec le nom du script et un statut "running". Après la fin, mettez à jour le statut en "success" ou "failed". Si le script échoue, le pipeline peut être relancé et l'exécuteur de migration peut vérifier si le script s'est déjà terminé avec succès.

Ce journal n'est pas seulement utile pour le débogage. C'est votre preuve pour la conformité et la gouvernance. Quand quelqu'un demande "qui a modifié la table users et quand", la réponse doit venir du journal, pas de la mémoire de quelqu'un.

Liste de contrôle pratique pour écrire des scripts de migration

Avant d'exécuter une migration en production, vérifiez ces points :

  • Le script peut-il s'exécuter deux fois sans erreur ? Testez-le en l'exécutant deux fois de suite sur une copie de votre base.
  • Le script vérifie-t-il l'existence des colonnes, index ou contraintes avant de les créer ?
  • Pour les transformations de données, le script gère-t-il correctement les lignes déjà transformées ?
  • Les suppressions sont-elles faites par étapes, avec une période de dépréciation avant la suppression ?
  • Existe-t-il une entrée de journal pour chaque exécution de script, avec horodatages et statut ?
  • Pouvez-vous annuler la modification en cas de problème ? Sinon, avez-vous un plan ?

Ce qu'il faut retenir

Un script de migration qui échoue lorsqu'il est exécuté deux fois n'est pas un script de migration. C'est une bombe à retardement qui attend que quelqu'un relance un pipeline. Écrivez chaque migration comme si elle allait être exécutée plusieurs fois, car en pratique, c'est probablement le cas. Vérifiez avant de créer, vérifiez avant de transformer, et journalisez tout. Votre futur vous, en train de déboguer un déploiement à 2 heures du matin, vous remerciera.