gwift-book/chapters/migrations.tex

522 lines
20 KiB
TeX
Executable File

\chapter{Migrations}
Dans cette section, nous allons voir comment fonctionnent les migrations.
Lors d'une première approche, elles peuvent sembler un peu magiques, puisqu'elles 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 l'application à niveau. Une analyse en profondeur montrera qu'elles ne sont pas plus complexes à suivre et à comprendre qu'un ensemble de fonctions de gestion appliquées à notre application.
L'inté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 \href{https://south.readthedocs.io/en/latest}{South}.
Prenons l'exemple de notre liste de souhaits; nous nous rendons (bêtement) compte que nous avons oublié d'ajouter un champ de \texttt{description} à une liste.
Historiquement, cette action nécessitait l'intervention d'un administrateur système ou d'une personne
ayant accès au schéma de la base de données, à partir duquel ce-dit utilisateur pouvait jouer manuellement un script SQL. \index{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 l'ensemble 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:
\begin{minted}{sql}
ALTER TABLE WishList ADD COLUMN Description nvarchar(MAX);
\end{minted}
Et là, nous nous rappelons qu'un client tourne sur Oracle et pas sur MySQL, et qu'il a donc besoin de son propre script d'exécution, parce que le type du nouveau champ n'est pas exactement le même entre deux moteurs différents:
\begin{minted}{sql}
-- 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
\end{minted}
En bref, les problèmes suivants apparaissent très rapidement:
\begin{enumerate}
\item
\textbf{Manque d'autonomie}: il est nécessaire d'avoir les compétences d'une
personne tierce pour avancer ou de disposer des droits
administrateurs,
\item
\textbf{Manque d'automatisation possible}, à moins d'écrire un programme, qu'il
faudra également maintenir et intégrer au niveau des tests
\item
\textbf{Nécessité de maintenir des scripts} différents, en fonction des
moteurs de base de données supportés
\item
\textbf{Manque de vérification} si un script a déjà été exécuté ou non,
à moins, à nouveau, de maintenir un programme ou une table supplémentaire.
\end{enumerate}
\section{Fonctionnement 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 l'arbre de dépendances entre les modifications qui doivent être appliquées.
Pour reprendre un de nos exemples précédents, nous avions créé un modèle contenant deux classes, qui correspondent chacun à une table dans un modèle relationnel:
\begin{minted}{python}
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)
\end{minted}
Nous avions ensuite modifié la clé de liaison, pour permettre d'associer plusieurs catégories à un même livre, et inversément:
\begin{minted}{python}
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)
\end{minted}
Chronologiquement, cela nous a donné
\begin{enumerate}
\item Une première migration consistant à créer le modèle initial
\item Suivie d'une seconde migration après que nous ayons modifié le modèle pour autoriser des relations multiples
\end{enumerate}
\begin{minted}{python}
# 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(
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(
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",
),
),
],
),
]
\end{minted}
\begin{itemize}
\item
La migration crée un nouveau modèle intitulé "Category", possédant un
champ \texttt{id} (auto-défini, puisque nous n'avons rien fait), ainsi
qu'un champ \texttt{name} de type texte et d'une longue maximale de
255 caractères.
\item
Elle crée un deuxième modèle intitulé "Book", possédant trois champs:
son identifiant auto-généré \texttt{id}, son titre \texttt{title} et
sa relation vers une catégorie, au travers du champ \texttt{category}.
\end{itemize}
Un outil comme \href{https://sqlitebrowser.org/}{DB Browser For SQLite} nous donne la structure suivante:
\includegraphics{images/db/migrations-0001-to-0002.png}
La représentation au niveau de la base de données est la suivante:
\includegraphics{images/db/link-book-category-fk.drawio.png}
\begin{minted}{python}
class Category(models.Model):
name = models.CharField(max_length=255)
class Book(models.Model):
title = models.CharField(max_length=255)
category = models.ManyManyField(Category)
\end{minted}
Vous noterez que l'attribut \texttt{on\_delete} n'est plus nécessaire.
Après cette modification, la migration résultante à appliquer correspondra à ceci. En SQL, un champ de type \texttt{ManyToMany} ne peut qu'être représenté par une table intermédiaire.
Ce qu'applique la migration en supprimant le champ liant initialement un livre à une catégorie et en ajoutant une nouvelle table de liaison.
\begin{minted}{python}
# 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(
model_name='book',
name='category',
),
migrations.AddField(
model_name='book',
name='category',
field=models.ManyToManyField(to='library.Category'),
),
]
\end{minted}
\begin{itemize}
\item
La migration supprime l'ancienne clé étrangère \ldots
\item
\ldots et ajoute une nouvelle table, permettant de lier nos catégories à nos livres.
\end{itemize}
\includegraphics{images/db/migrations-0002-many-to-many.png}
Nous obtenons à présent la représentation suivante en base de données:
\includegraphics{images/db/link-book-category-m2m.drawio.png}
\section{Graph de dépendances}
Lorsqu'une migration applique une modification au schéma d'une base de données, il est évident qu'elle ne peut pas être appliquée dans n'importe quel ordre ou à n'importe quel moment.
Dès la création d'un nouveau projet, avec une configuration par défaut et même sans avoir ajouté d'applications, Django proposera immédiatement d'appliquer les migrations des applications \textbf{admin},
\textbf{auth}, \textbf{contenttypes} et \textbf{sessions}, qui font partie du coeur du système, et qui se trouvent respectivement aux emplacements suivants:
\begin{itemize}
\item
\textbf{admin}: \texttt{site-packages/django/contrib/admin/migrations}
\item
\textbf{auth}: \texttt{site-packages/django/contrib/auth/migrations}
\item
\textbf{contenttypes}:
\texttt{site-packages/django/contrib/contenttypes/migrations}
\item
\textbf{sessions}:
\texttt{site-packages/django/contrib/sessions/migrations}
\end{itemize}
Ceci est dû au fait que, toujours par défaut, ces applications sont reprises au niveau de la configuration d'un nouveau projet, dans le fichier \texttt{settings.py}:
\begin{minted}{python}
[snip]
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
[snip]
\end{minted}
Dès que nous les appliquerons, nous recevrons les messages suivants:
\begin{verbatim}
$ 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
\end{verbatim}
Cet ordre est défini au niveau de la propriété \texttt{dependencies}, que l'on 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:
\includegraphics{images/db/migrations_auth_admin_contenttypes_sessions.png}
\section{Sous le capot}
Une migration consiste à appliquer un ensemble de modifications (ou
\textbf{opérations}), qui exercent un ensemble de transformations, pour
que le schéma de base de données corresponde au modèle de l'application
sous-jacente.
Les migrations (comprendre les "\emph{migrations du schéma de base de
données}") sont intimement liées à la représentation d'un contexte
fonctionnel: l'ajout d'une nouvelle information, d'un nouveau champ ou
d'une nouvelle fonction peut s'accompagner 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 l'application s'attend, sans
quoi la probabilité que l'utilisateur tombe sur une erreur de type
\texttt{django.db.utils.OperationalError} est (très) grande.
Typiquement, après avoir ajouté un nouveau champ \texttt{summary} à
chacun de nos livres, et sans avoir appliqué de migrations, nous tombons
sur ceci:
\begin{verbatim}
>>> 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
\end{verbatim}
Pour éviter ce type d'erreurs, il est impératif que les nouvelles
migrations soient appliquées \textbf{avant} que le code ne soit déployé;
l'idéal étant que ces deux opérations soient réalisées de manière
atomique, avec un \emph{rollback} si une anomalie était détectée.
Pour éviter ce type d'erreurs, plusieurs stratégies peuvent être
appliquées:
TODO 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 s'occupe de créer les
migrations en fonction des actions à entreprendre; ces migrations
peuvent être retravaillées, \emph{squashées}, \ldots\hspace{0pt} et
feront partie intégrante du processus de mise à jour de l'application.
A noter que les migrations n'appliqueront de modifications que si le
schéma est impacté. Ajouter une propriété \texttt{related\_name} sur une
ForeignKey n'engendrera aucune nouvelle action de migration, puisque ce
type d'action ne s'applique que sur l'ORM, 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 \emph{a minima}
deux propriétés:
\begin{enumerate}
\item
\texttt{dependencies}, qui décrit les opérations précédentes devant obligatoirement avoir été appliquées
\item
\texttt{operations}, qui consiste à décrire précisément ce qui doit être exécuté.
\end{enumerate}
Pour reprendre notre exemple d'ajout d'un champ \texttt{description} sur le modèle \texttt{WishList}, la migration ressemblera à ceci:
\begin{minted}{python}
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,
),
]
\end{minted}
\section{Liste des migrations appliquées}
L'option \texttt{showmigrations} de \texttt{manage.py} permet de lister
toutes les migrations du projet, et d'identifier celles qui n'auraient
pas encore été appliquées:
\begin{verbatim}
$ python manage.py showmigrations
admin
[X] 0001_initial
[X] 0002_logentry_remove_auto_add
[X] 0003_logentry_add_action_flag_choices
auth
[X] 0001_initial
[X] 0002_alter_permission_name_max_length
[X] 0003_alter_user_email_max_length
[X] 0004_alter_user_username_opts
[X] 0005_alter_user_last_login_null
[X] 0006_require_contenttypes_0002
[X] 0007_alter_validators_add_error_messages
[X] 0008_alter_user_username_max_length
[X] 0009_alter_user_last_name_max_length
[X] 0010_alter_group_name_max_length
[X] 0011_update_proxy_permissions
[X] 0012_alter_user_first_name_max_length
contenttypes
[X] 0001_initial
[X] 0002_remove_content_type_name
library
[X] 0001_initial
[X] 0002_remove_book_category_book_category
[ ] 0003_book_summary
sessions
[X] 0001_initial
\end{verbatim}
Nous voyons que parmi toutes les migrations déjà enregistrées au niveau du projet, seule la migration \texttt{0003\_book\_summary} n'a pas encore été appliquée sur ce schéma-ci.
\section{Squash}
Finalement, lorsque vous développez sur votre propre branche (cf. \protect\hyperlink{git}{???}), vous serez peut-être tentés de créer plusieurs migrations en fonction de l'évolution de ce que vous mettez en place. Dans ce cas précis, il peut être intéressant d'utiliser la méthode \texttt{squashmigrations}, qui permet \emph{d'aplatir} plusieurs fichiers en un seul.
Nous partons dans deux migrations suivantes:
\begin{minted}{python}
# library/migrations/0002_remove_book_category.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='category',
),
migrations.AddField(
model_name='book',
name='category',
field=models.ManyToManyField(to='library.Category'),
),
]
\end{minted}
\begin{minted}{python}
# library/migrations/0003_book_summary.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0002_remove_book_category_book_category'),
]
operations = [
migrations.AddField(
model_name='book',
name='summary',
field=models.TextField(blank=True),
),
]
\end{minted}
La commande \texttt{python\ manage.py\ squashmigrations\ library\ 0002\ 0003} appliquera une fusion entre les migrations numérotées \texttt{0002} et \texttt{0003}:
\begin{verbatim}
$ python manage.py squashmigrations library 0002 0003
Will squash the following migrations:
- 0002_remove_book_category_book_category
- 0003_book_summary
Do you wish to proceed? [yN] y
Optimizing...
No optimizations possible.
Created new squashed migration
/home/fred/Sources/gwlib/library/migrations/0002_remove_book_category_book_cat
egory_squashed_0003_book_summary.py
You should commit this migration but leave the old ones in place;
the new migration will be used for new installs. Once you are sure
all instances of the codebase have applied the migrations you squashed,
you can delete them
\end{verbatim}
Dans le cas où vous développez proprement (bis), il est sauf de purement et simplement supprimer les anciens fichiers; dans le cas où il pourrait exister au moins une instance ayant appliqué ces migrations, les anciens
\textbf{ne peuvent surtout pas être modifiés}.
Nous avons à présent un nouveau fichier intitulé \texttt{0002\_remove\_book\_category\_book\_category\_squashed\_0003\_book\_summary}:
\begin{minted}{python}
$ cat
library/migrations/0002_remove_book_category_book_category_squashed_0003_book_
summary.py
# Generated by Django 4.0.3 on 2022-03-15 18:01
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [
('library', '0002_remove_book_category_book_category'),
('library', '0003_book_summary')]
dependencies = [
('library', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='category',
),
migrations.AddField(
model_name='book',
name='category',
field=models.ManyToManyField(to='library.category'),
),
migrations.AddField(
model_name='book',
name='summary',
field=models.TextField(blank=True),
),
]
\end{minted}
\section{Réinitialisation de migrations}
\href{https://simpleisbetterthancomplex.com/tutorial/2016/07/26/how-to-reset-migrations.html}{reset
migrations}.
En résumé:
\begin{enumerate}
\item Soit on supprime toutes les migrations (en conservant le fichier \texttt{\_\_init\_\_.py})
\item Soit on réinitialise proprement les migrations avec un \texttt{--fake-initial} (sous réserve que toutes les personnes qui utilisent déjà le projet s'y conforment\ldots Ce qui n'est pas gagné.
\end{enumerate}