gwift-book/source/part-3-data-model/migrations.adoc

15 KiB
Raw Blame History

Migrations

Dans cette section, nous allons voir comment fonctionnent les migrations. Lors dune première approche, elles peuvent sembler un peu magiques, puisquelles centralisent un ensemble de modifications pouvant être répétées sur un schéma de données, en tenant compte de ce qui a déjà été appliqué et en vérifiant quelles migrations devaient encore lêtre pour mettre lapplication à niveau. Une analyse en profondeur montrera quelles ne sont pas plus complexes à suivre et à comprendre quun ensemble de fonctions de gestion appliquées à notre application.

Note
La commande sqldump, qui nous présentera le schéma tel quil sera compris.

Lintégration des migrations a été réalisée dans la version 1.7 de Django. Avant cela, il convenait de passer par une librairie tierce intitulée South.

Prenons lexemple de notre liste de souhaits; nous nous rendons (bêtement) compte que nous avons oublié dajouter un champ de description à une liste. Historiquement, cette action nécessitait lintervention dun administrateur système ou dune personne ayant accès au schéma de la base de données, à partir duquel ce-dit utilisateur pouvait jouer manuellement un script SQL. Cet enchaînement détapes nécessitait une bonne coordination déquipe, mais également une bonne confiance dans les scripts à exécuter. Et souvenez-vous (cf. ref-à-insérer), que lensemble des actions doit être répétable et automatisable.

Bref, dans les années '80, il convenait de jouer ceci après sêtre connecté au serveur de base de données:

ALTER TABLE WishList ADD COLUMN Description nvarchar(MAX);

Et là, nous nous rappelons quun utilisateur tourne sur Oracle et pas sur MySQL, et quil a donc besoin de son propre script dexécution, parce que le type du nouveau champ nest pas exactement le même entre les deux moteurs différents:

-- Firebird
ALTER TABLE Category ALTER COLUMN Name type varchar(2000)

-- MSSQL
ALTER TABLE Category ALTER Column Name varchar(2000)

-- Oracle
ALTER TABLE Category MODIFY Name varchar2(2000)

En bref, les problèmes suivants apparaissent très rapidement:

  1. Aucune autonomie: il est nécessaire davoir les compétences dune personne tierce pour avancer ou de disposer des droits administrateurs,

  2. Aucune automatisation possible, à moins décrire un programme, quil faudra également maintenir et intégrer au niveau des tests

  3. Nécessité de maintenir autant de scripts différents quil y a de moteurs de base de données supportés

  4. Aucune possibilité de vérifier si le script a déjà été exécuté ou non, à moins, à nouveau, de maintenir un programme supplémentaire.

Fonctionement général

Le moteur de migrations résout la plupart de ces soucis: le framework embarque ses propres applications, dont les migrations, qui gèrent elles-mêmes larbre de dépendances entre les modifications devant être appliquées.

Pour reprendre un des premiers exemples, nous avions créé un modèle contenant deux classes, qui correspondent chacun à une table dans un modèle relationnel:

class Category(models.Model):
    name = models.CharField(max_length=255)

class Book(models.Model):
    title = models.CharField(max_length=255)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)

Nous avions ensuite modifié la clé de liaison, pour permettre dassocier plusieurs catégories à un même livre, et inversément:

class Category(models.Model):
    name = models.CharField(max_length=255)

class Book(models.Model):
    title = models.CharField(max_length=255)
    category = models.ManyManyField(Category, on_delete=models.CASCADE)

Chronologiquement, cela nous a donné une première migration consistant à créer le modèle initial, suivie dune seconde migration après que nous ayons modifié le modèle pour autoriser des relations multiples.

migrations successives, à appliquer pour que la structure relationnelle corresponde aux attentes du modèle Django:

# library/migrations/0001_initial.py

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

    initial = True

    dependencies = []

    operations = [
        migrations.CreateModel(  (1)
            name="Category",
            fields=[
                (
                    "id",
                    models.BigAutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                ("name", models.CharField(max_length=255)),
            ],
        ),
        migrations.CreateModel(  (2)
            name="Book",
            fields=[
                (
                    "id",
                    models.BigAutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                (
                    "title",
                    models.CharField(max_length=255)),
                (
                    "category",
                    models.ForeignKey(
                        on_delete=django.db.models.deletion.CASCADE,
                        to="library.category",
                    ),
                ),
            ],
        ),
    ]
  1. La migration crée un nouveau modèle intitulé "Category", possédant un champ id (auto-défini, puisque nous navons rien fait), ainsi quun champ name de type texte et dune longue maximale de 255 caractères.

  2. Elle crée un deuxième modèle intitulé "Book", possédant trois champs: son identifiant auto-généré id, son titre title et sa relation vers une catégorie, au travers du champ category.

Un outil comme DB Browser For SQLite nous donne la structure suivante:

migrations 0001 to 0002

La représentation au niveau de la base de données est la suivante:

link book category fk.drawio
class Category(models.Model):
    name = models.CharField(max_length=255)

class Book(models.Model):
    title = models.CharField(max_length=255)
    category = models.ManyManyField(Category) (1)
  1. Vous noterez que lattribut on_delete nest plus nécessaire.

Après cette modification, la migration résultante à appliquer correspondra à ceci. En SQL, un champ de type ManyToMany ne peut quêtre représenté par une table intermédiaire. Cest quapplique la migration en supprimant le champ liant initialement un livre à une catégorie et en ajoutant une nouvelle table de liaison.

# library/migrations/0002_remove_book_category_book_category.py

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('library', '0001_initial'),
    ]

    operations = [
        migrations.RemoveField(  (1)
            model_name='book',
            name='category',
        ),
        migrations.AddField(   (2)
            model_name='book',
            name='category',
            field=models.ManyToManyField(to='library.Category'),
        ),
    ]
  1. La migration supprime lancienne clé étrangère…

  2. …​ et ajoute une nouvelle table, permettant de lier nos catégories à nos livres.

migrations 0002 many to many

Nous obtenons à présent la représentation suivante en base de données:

link book category m2m.drawio

Graph de dépendances

Lorsquune migration applique une modification au schéma dune base de données, il est évident quelle ne peut pas être appliquée dans nimporte quel ordre ou à nimporte quel moment.

Dès la création dun nouveau projet, avec une configuration par défaut et même sans avoir ajouté dapplications, Django proposera immédiatement dappliquer les migrations des applications admin, auth, contenttypes et sessions, qui font partie du coeur du système, et qui se trouvent respectivement aux emplacements suivants:

  • admin: site-packages/django/contrib/admin/migrations

  • auth: site-packages/django/contrib/auth/migrations

  • contenttypes: site-packages/django/contrib/contenttypes/migrations

  • sessions: site-packages/django/contrib/sessions/migrations

Ceci est dû au fait que, toujours par défaut, ces applications sont reprises au niveau de la configuration dun nouveau projet, dans le fichier settings.py:

[snip]

INSTALLED_APPS = [
    'django.contrib.admin', (1)
    'django.contrib.auth', (2)
    'django.contrib.contenttypes', (3)
    'django.contrib.sessions', (4)
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

[snip]
  1. Admin

  2. Auth

  3. Contenttypes

  4. et Sessions.

Dès que nous les appliquerons, nous recevrons les messages suivants:

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, library, sessions, world
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

Cet ordre est défini au niveau de la propriété dependencies, que lon retrouve au niveau de chaque description de migration, En explorant les paquets qui se trouvent au niveau des répertoires et en analysant les dépendances décrites au niveau de chaque action de migration, on arrive au schéma suivant, qui est un graph dirigé acyclique:

migrations auth admin contenttypes sessions

Sous le capot

Une migration consiste à appliquer un ensemble de modifications (ou opérations), qui exercent un ensemble de transformations, pour que le schéma de base de données corresponde au modèle de lapplication sous-jacente.

Les migrations (comprendre les "migrations du schéma de base de données") sont intimement liées à la représentation dun contexte fonctionnel: lajout dune nouvelle information, dun nouveau champ ou dune nouvelle fonction peut saccompagner de tables de données à mettre à jour ou de champs à étendre. Il est primordial que la structure de la base de données corresponde à ce à quoi lapplication sattend, sans quoi la probabilité que lutilisateur tombe sur une erreur de type django.db.utils.OperationalError est (très) grande. Typiquement, après avoir ajouté un nouveau champ summary à chacun de nos livres, et sans avoir appliqué de migrations, nous tombons sur ceci:

>>> from library.models import Book
>>> Book.objects.all()
Traceback (most recent call last):
  File "~/Sources/.venvs/gwlib/lib/python3.9/site-packages/django/db/backends/utils.py", line 85, in _execute
    return self.cursor.execute(sql, params)
  File "~/Sources/.venvs/gwlib/lib/python3.9/site-packages/django/db/backends/sqlite3/base.py", line 416, in execute
    return Database.Cursor.execute(self, query, params)
sqlite3.OperationalError: no such column: library_book.summary

Pour éviter ce type derreurs, il est impératif que les nouvelles migrations soient appliquées avant que le code ne soit déployé; lidéal étant que ces deux opérations soient réalisées de manière atomique, avec un rollback si une anomalie était détectée.

En allant

Pour éviter ce type derreurs, plusieurs stratégies peuvent être appliquées:

intégrer ici un point sur les updates db - voir designing data-intensive applications.

Toujours dans une optique de centralisation, les migrations sont directement embarquées au niveau du code, et doivent faire partie du dépôt central de sources. Le développeur soccupe de créer les migrations en fonction des actions à entreprendre; ces migrations peuvent être retravaillées, squashées, …​ et feront partie intégrante du processus de mise à jour de lapplication.

A noter que les migrations nappliqueront de modifications que si le schéma est impacté. Ajouter une propriété related_name sur une ForeignKey nengendrera aucune nouvelle action de migration, puisque ce type daction ne sapplique que sur lORM, et pas directement sur la base de données: au niveau des tables, rien ne change. Seul le code et le modèle sont impactés.

Une migration est donc une classe Python, présentant a minima deux propriétés:

  1. dependencies, qui décrit les opérations précédentes devant obligatoirement avoir été appliquées

  2. operations, qui consiste à décrire précisément ce qui doit être exécuté.

Pour reprendre notre exemple dajout dun champ description sur le modèle WishList, la migration ressemblera à ceci:

from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

    dependencies = [
        ('gwift', '0004_name_value'),
    ]

    operations = [
        migrations.AddField(
            model_name='wishlist',
            name='description',
            field=models.TextField(default="", null=True)
            preserve_default=False,
        ),
    ]

Réinitialisation dune ou plusieurs migrations

 En gros, soit on supprime toutes les migrations (en conservant le fichier __init__.py), soit on réinitialise proprement les migrations avec un --fake-initial (sous réserve que toutes les personnes qui utilisent déjà le projet s'y conforment... Ce qui n'est pas gagné.
Pour repartir de notre exemple ci-dessus, nous avions un modèle reprenant quelques classes, saupoudrées de propriétés décrivant nos différents champs. Pour être prise en compte par le moteur de base de données, chaque modification doit être