Cours NestJS · Module 3: TypeORM et bases de données
Migrations et Entités - l'archive de la légion romaine
Dans cette leçon6
Tu as ajouté un champ rank à l'entité Legionary. Chez toi, cela fonctionne - la base de développement a créé la colonne toute seule. Tu pousses le code et, le lendemain matin, un message d'un collègue arrive : l'application ne démarre pas, car sa base n'a pas de colonne rank. En production, ce serait pire - là, personne ne laisse une base se reconstruire seule, car elle peut effacer des données au passage.
Le problème est le même que celui du code avant l'invention de git : les changements de structure de la base n'ont pas d'historique. On ne sait pas qui a changé quoi, dans quel ordre, ni comment revenir en arrière. Les Romains tenaient une archive précisément pour cela - chaque changement du registre de la légion consigné dans un document daté à part. Dans TypeORM, ce document s'appelle une migration.
Ce qu'est une migration
Une migration est un fichier contenant un seul changement de schéma - l'ajout d'une table, d'une colonne, d'un index. Son nom porte un horodatage, les migrations s'alignent donc dans un ordre, et la base retient celles qu'elle a déjà exécutées. C'est du versionnement de schéma : exactement ce que git fait pour le code, mais pour la structure de tes tables.
Grâce à cela, chaque collègue exécute les mêmes migrations dans le même ordre et obtient une base identique. Et en production, le changement entre délibérément, par une seule commande, et non par les suppositions d'un ORM. C'est pourquoi synchronize: true - l'option qui laisse TypeORM reconstruire la base d'après les entités - est pratique en local mais ne doit jamais atteindre la production.
Anatomie d'une migration : up et down
Une migration est une classe implémentant l'interface MigrationInterface avec deux méthodes :
1import { MigrationInterface, QueryRunner } from 'typeorm';
2
3export class CreateLegionaryTable1710000000000 implements MigrationInterface {
4 public async up(queryRunner: QueryRunner): Promise<void> {
5 await queryRunner.query(`
6 CREATE TABLE "legionary" (
7 "id" SERIAL PRIMARY KEY,
8 "name" character varying(100) NOT NULL,
9 "rank" character varying(50)
10 )
11 `);
12 }
13
14 public async down(queryRunner: QueryRunner): Promise<void> {
15 await queryRunner.query(`DROP TABLE "legionary"`);
16 }
17}Ces deux méthodes sont le chemin aller et le chemin retour. up() applique le changement - ici, elle crée la table. down() l'annule - elle supprime cette même table. La règle est simple : down() doit inverser exactement ce qu'a fait up(). Tu as ajouté une colonne dans up ? Supprime-la dans down. Sans cela, annuler une migration laisse la base dans un état que personne n'a prévu.
Note l'argument des deux méthodes : queryRunner. C'est le même objet que tu retrouveras dans la leçon sur les transactions - il représente une connexion à la base sur laquelle nous exécutons des commandes. Le nombre dans le nom de la classe (1710000000000) est l'horodatage de création ; c'est par lui que TypeORM établit l'ordre des migrations, et non par le nom du fichier.
Le cycle de travail avec les migrations
Au quotidien, tu écris rarement les migrations à la main. L'ordre est toujours le même et mérite d'être retenu comme quatre étapes :
1. Modifie l'entité - ajoute un champ, change un type, supprime une colonne. C'est le seul endroit où tu décris à quoi la base doit ressembler.
2. Génère la migration avec la commande qui compare les entités à l'état de la base et enregistre la différence :
1typeorm migration:generate -n AddRankToLegionary3. Exécute la migration - c'est seulement maintenant que le changement atteint la base :
1typeorm migration:run4. Vérifie le résultat dans la base - la colonne est-elle là, a-t-elle le bon type, les données ont-elles survécu.
Entre les étapes deux et trois se glisse une chose facile à oublier : lis le fichier généré. Le générateur compare les entités à la base et interprète parfois un changement autrement que tu ne l'avais prévu - au lieu de renommer une colonne, il peut supprimer l'ancienne et en ajouter une nouvelle, perdant au passage toutes les données. Cette relecture prend trente secondes, et je te la recommande comme habitude.
generate et create - deux commandes différentes
Il vaut la peine de distinguer deux commandes aux noms trompeusement proches. migration:generate compare les entités à la base et remplit up() et down() à ta place - c'est la commande que tu utiliseras dans 95% des cas. migration:create crée un squelette de migration vide, que tu remplis toi-même - utile quand le changement ne découle pas des entités, par exemple pour déplacer des données entre colonnes.
Quand une migration se révèle erronée, tu l'annules par une seule commande :
1typeorm migration:revertrevert appelle la méthode down() de la dernière migration exécutée - une seule, pas toutes. Pour reculer de trois migrations, tu la lances trois fois. Et l'on voit ici à quoi servait cette discipline dans l'écriture de down() : le retour en arrière fonctionnera exactement aussi bien que tu auras décrit le chemin du retour.
L'entité pilote la migration - les options de colonnes
Puisque les migrations naissent des entités, les options de colonnes sont en réalité des instructions pour le générateur. Quelques-unes méritent d'être connues, car elles décident directement de la forme de la table :
1@Entity()
2export class Tribute {
3 @PrimaryGeneratedColumn()
4 id: number;
5
6 @Column({ type: 'varchar', length: 100 })
7 name: string;
8
9 @Column({ type: 'decimal', precision: 10, scale: 2 })
10 value: number;
11
12 @Column({ default: false })
13 isCursed: boolean;
14}@PrimaryGeneratedColumn() est une clé primaire attribuée automatiquement par la base - tu ne la fixes jamais à la main. type: 'varchar' avec length: 100 donne un texte de longueur limitée ; une tentative d'enregistrer un nom plus long est rejetée par la base, et non tronquée en silence.
Le plus intéressant est decimal avec le couple precision et scale, car ce couple prête à confusion. precision est le nombre total de chiffres, et scale combien d'entre eux se placent après la virgule. Ainsi precision: 10, scale: 2 signifie huit chiffres avant la virgule et deux après - des valeurs jusqu'à 99 999 999,99. Pourquoi pas un simple float ? Parce que les nombres à virgule flottante arrondissent - avec l'argent et la valeur des tributs, les totaux cesseraient de tomber juste. decimal conserve la valeur exacte.
default: false inscrit une valeur par défaut dans la définition de la colonne en base - un nouveau tribut sera non maudit même si le code ne renseigne jamais ce champ.
Récapitulatif
L'archive de la légion se tient toute seule, et tu sais désormais la lire :
- une migration est le versionnement du schéma - git pour la structure de tes tables,
synchronize: truepeut être pratique en local, mais en production il peut effacer des données,- une classe de migration implémente
MigrationInterface:up()applique le changement,down()doit l'inverser exactement, - le cycle de travail : modifie l'entité,
migration:generate, lis le fichier généré,migration:run, vérifie la base, generateremplit la migration à partir de l'écart entre entités et base,createdonne un squelette vide à écrire à la main,migration:revertannule une seule migration, la dernière, en appelant sondown(),- les options de colonnes pilotent le générateur :
lengthlimite le texte,decimalavecprecisionetscaleconserve des montants exacts,defaultinscrit une valeur par défaut en base.
Dans la prochaine leçon, nous relierons les tables par des relations - car un légionnaire sans centurion ni tribut reste une entrée solitaire dans le registre. En attendant, retiens ceci : une migration est un document d'archive - elle décrit un changement et son chemin de retour, pour que chaque base de l'Empire puisse atteindre le même état.
Code de cette leçon : src/migrations.ts
1// Migrations et entités - Les archives de la légion romaine
2import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
3
4console.log("Migrations - une réorganisation sûre du trésor !");
5
6// ===========================================
7// 1. Créer une table - première migration
8// ===========================================
9
10export class CreateLegionaries1700000000000 implements MigrationInterface {
11 async up(queryRunner: QueryRunner): Promise<void> {
12 await queryRunner.createTable(
13 new Table({
14 name: 'legionaries',
15 columns: [
16 {
17 name: 'id',
18 type: 'integer',
19 isPrimary: true,
20 isGenerated: true,
21 generationStrategy: 'increment',
22 },
23 { name: 'name', type: 'varchar', isNullable: false },
24 { name: 'rank', type: 'varchar', isNullable: false },
25 { name: 'experience', type: 'integer', default: '0' },
26 { name: 'legion', type: 'varchar', isNullable: false },
27 { name: 'created_at', type: 'timestamp', default: 'now()' },
28 ],
29 }),
30 );
31 console.log('Table legionaries créée !');
32 }
33
34 async down(queryRunner: QueryRunner): Promise<void> {
35 await queryRunner.dropTable('legionaries');
36 console.log('Table legionaries supprimée !');
37 }
38}
39
40// ===========================================
41// 2. Ajouter une colonne - migration suivante
42// ===========================================
43
44export class AddProvinceToLegionaries1700000001000 implements MigrationInterface {
45 async up(queryRunner: QueryRunner): Promise<void> {
46 await queryRunner.addColumn(
47 'legionaries',
48 new TableColumn({
49 name: 'province',
50 type: 'varchar',
51 isNullable: true,
52 default: "'Roma'",
53 }),
54 );
55 console.log('Colonne province ajoutée !');
56 }
57
58 async down(queryRunner: QueryRunner): Promise<void> {
59 await queryRunner.dropColumn('legionaries', 'province');
60 }
61}
62
63// ===========================================
64// 3. Commandes de migration
65// ===========================================
66
67// npx typeorm migration:generate -n CreateLegionaries
68// npx typeorm migration:run // Exécuter la migration
69// npx typeorm migration:revert // Annuler la dernière
70// npx typeorm migration:show // Afficher le statut
71
72// ===========================================
73// 4. Configuration des migrations
74// ===========================================
75
76// ormconfig.ts / data-source.ts
77// export const dataSource = new DataSource({
78// type: 'postgres',
79// host: 'localhost',
80// port: 5432,
81// migrations: ['src/migrations/*.ts'],
82// migrationsTableName: 'typeorm_migrations',
83// });
84
85console.log("\n=== RÉSUMÉ DES MIGRATIONS ===");
86console.log("up() - applique le changement (créer une table, ajouter une colonne)");
87console.log("down() - annule le changement (supprimer une table, une colonne)");
88console.log("migration:run - exécute les migrations en attente");
89console.log("migration:revert - annule la dernière migration");
90console.log("synchronize: true UNIQUEMENT en développement !");
91Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. À quoi servent les migrations dans TypeORM ?
2. Quelle commande génère une migration à partir des changements dans les entités TypeORM ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Crée une migration CreateLegionaryTable avec une méthode up() qui crée la table et une méthode down() qui la supprime
- Tri vertical
Classe les étapes du processus de migration de base de données, de la première à la dernière :
- Clique dans l'ordre
Classe les éléments de la commande de génération de migration dans le bon ordre :
- Éditeur de code
Écris une entité Tribute avec @Column({ type: 'varchar', length: 100 }), @Column({ type: 'decimal', precision: 10, scale: 2 }) et @Column({ default: false })