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

290 lines
12 KiB
Plaintext
Raw Normal View History

== Migrations
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 https://south.readthedocs.io/en/latest[South].
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.
Prenons l'exemple de notre liste de souhaits; nous nous rendons (bêtement) compte que nous avons oublié d'ajouter un champ de `description` à une liste.
2022-03-10 18:36:01 +01:00
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. (((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.
2022-03-10 18:36:01 +01:00
Bref, dans les années '80, il convenait de jouer ceci après s'être connecté au serveur de base de données:
[source,sql]
----
ALTER TABLE WishList ADD COLUMN Description nvarchar(MAX);
----
2022-03-10 18:36:01 +01:00
Et là, nous nous rappelons qu'un utilisateur 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 les deux moteurs différents:
[source,sql]
----
2022-03-10 18:36:01 +01:00
-- 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 d'avoir les compétences d'une personne tierce pour avancer ou de disposer des droits administrateurs,
2. Aucune automatisation possible, à moins d'écrire un programme, qu'il faudra également maintenir et intégrer au niveau des tests
3. Nécessité de maintenir autant de scripts différents qu'il 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 l'arbre 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:
[source,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)
----
Nous avions ensuite modifié la clé de liaison, pour permettre d'associer plusieurs catégories à un même livre, et inversément:
[source,python,highlight=6]
----
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 d'une 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:
[source,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( <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 n'avons rien fait), ainsi qu'un champ `name` de type texte et d'une 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 https://sqlitebrowser.org/[DB Browser For SQLite] nous donne la structure suivante:
image::images/db/migrations-0001-to-0002.png[]
La représentation au niveau de la base de données est la suivante:
image::images/db/link-book-category-fk.drawio.png[]
[source,python,highlight=6]
----
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>
----
2022-03-10 18:36:01 +01:00
<1> Vous noterez que l'attribut `on_delete` n'est 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.
C'est qu'applique la migration en supprimant le champ liant initialement un livre à une catégorie et en ajoutant une nouvelle table de liaison.
[source,python]
----
# library/migrations/0002_remove_book_category_book_category.py
from django.db import migrations, models
class Migration(migrations.Migration):
2022-03-10 18:36:01 +01:00
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 l'ancienne clé étrangère...
<2> ... et ajoute une nouvelle table, permettant de lier nos catégories à nos livres.
image::images/db/migrations-0002-many-to-many.png[]
Nous obtenons à présent la représentation suivante en base de données:
image::images/db/link-book-category-m2m.drawio.png[]
=== 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 l'application sous-jacente.
Les migrations (comprendre les "_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 `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:
[source,shell,highlight=10]
----
>>> 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
----
2022-03-10 18:36:01 +01:00
Pour éviter ce type d'erreurs, plusieurs stratégies peuvent être appliquées:
2022-03-10 18:36:01 +01:00
intégrer ici un point sur les updates db - voir designing data-intensive applications.
2022-03-10 18:36:01 +01:00
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, _squashées_, ... 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é `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 _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 d'ajout d'un champ `description` sur le modèle `WishList`, la migration ressemblera à ceci:
[source,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,
),
]
----
Nous avions un modèle reprenant quelques classes, elles-mêmes saupoudrées de quelques propriétés.
=== Réinitialisation d'une ou plusieurs migrations
https://simpleisbetterthancomplex.com/tutorial/2016/07/26/how-to-reset-migrations.html[reset 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
=== Description d'une migration
1. Décrite, grâce à la commande `makemigrations`
=== Application d'une ou plusieurs migrations
1. Appliquée, avec la commande `migrate`.
=== Analyse
Nous allons ci-dessous analyser exactement les modifications appliquées au schéma de la base de données, en fonction des différents cas, et comment ils sont gérés par les pilotes de Django.
Nous utiliserons https://sqlitebrowser.org/[Sqlite Browser] et la commande `sqldump`, qui nous présentera le schéma tel qu'il sera compris.
==== Création de nouveaux champs
==== Modification d'un champ existant
==== Suppression d'un champ existant