Cours NestJS · Module 3: TypeORM et bases de données
Database Validation - le contrôle de la qualité des données
Dans cette leçon8
Un légionnaire sans nom est entré hier dans le trésor. Aujourd'hui - deux entrées identiques pour le même tribut. Le consul Caesar.js est furieux : nous validons pourtant les données dans les DTO ! C'est vrai - mais la validation dans la couche API ne garde qu'une seule porte. Les données entrent dans la base par d'autres routes aussi : par un seeder, par un autre service, par la requête manuelle d'un administrateur. C'est pourquoi un système mûr poste ses gardes en couches : le DTO vérifie la forme de la requête, le service applique les règles métier, et la base de données est la dernière ligne de défense - un mur que personne ne contourne. Dans cette leçon, nous construisons précisément ce mur.
Des règles inscrites dans les colonnes
La première ligne du mur, ce sont les définitions de colonnes. Le décorateur @Column accepte des options que la base transforme en contraintes strictes :
1@Entity()
2export class Legionary {
3 @Column({ length: 100, nullable: false })
4 name: string;
5
6 @Column({ unique: true })
7 codeName: string;
8
9 @Column({ type: 'int', default: 0 })
10 tributeCount: number;
11
12 @Column({ type: 'boolean', default: true })
13 isActive: boolean;
14}Lisons ces options comme des ordres donnés à la base. nullable: false est le NOT NULL du SQL - un enregistrement sans nom est rejeté avant même de toucher la table. unique: true pose une contrainte d'unicité : un deuxième légionnaire avec le même codeName déclenche une erreur de la base au lieu de devenir un doublon silencieux. default remplit une valeur quand on n'en fournit pas - un nouveau légionnaire démarre à zéro tribut sans qu'on l'écrive dans le code. Et type avec length ajustent le type de colonne aux données : un entier pour un compteur, un texte de longueur limitée pour un nom. Remarque que ces règles s'appliquent toujours - quelle que soit la route empruntée par les données.
Les index - la table des matières du trésor
Puisque codeName doit être unique, la base a déjà construit un index pour lui. Mais un index sert aussi là où nous cherchons souvent sans exiger l'unicité - nous posons alors nous-mêmes le décorateur @Index :
1@Entity()
2@Index(['rank', 'isActive'])
3export class Legionary {
4 @Index()
5 @Column()
6 lastName: string;
7}Un index fonctionne comme la table des matières d'un registre d'inventaire : au lieu de feuilleter toute la table, la base saute directement à la bonne page. Un @Index() simple au-dessus d'une colonne accélère la recherche par lastName ; un index composite au-dessus de la classe - les requêtes qui filtrent à la fois par rang et par activité. C'est l'optimisation de requêtes la plus simple de cette leçon : aucun changement dans le code du service, et les requêtes avec un where sur ces colonnes cessent de balayer la table entière. Le prix est unique - chaque écriture doit aussi mettre l'index à jour - d'où la règle : on indexe les colonnes par lesquelles on cherche vraiment, pas toutes.
Les horodatages - une chronique automatique
Quand cet enregistrement a-t-il été créé ? Quand a-t-il été modifié pour la dernière fois ? Ces deux colonnes, nous ne les remplissons jamais à la main :
1@CreateDateColumn()
2createdAt: Date;
3
4@UpdateDateColumn()
5updatedAt: Date;@CreateDateColumn reçoit sa date une fois, à la création de l'enregistrement, et ne change plus jamais. @UpdateDateColumn se rafraîchit à chaque sauvegarde. La chronique s'écrit toute seule - et tu obtiens la réponse à "que s'est-il passé ici récemment" avant que quiconque pose la question.
Les hooks d'entité - un rituel avant la sauvegarde
Parfois il faut veiller à quelque chose avant l'écriture : couper les espaces, normaliser la casse, calculer un champ dérivé. C'est le rôle des hooks - des méthodes d'entité marquées d'un décorateur, que TypeORM appelle lui-même au bon moment :
1@Entity()
2export class Legionary {
3 @Column()
4 codeName: string;
5
6 @BeforeInsert()
7 @BeforeUpdate()
8 normalizeCodeName() {
9 this.codeName = this.codeName.trim().toLowerCase();
10 }
11}@BeforeInsert déclenche la méthode juste avant la première sauvegarde d'un enregistrement, @BeforeUpdate - avant chaque mise à jour. Ici les deux décorateurs surmontent une seule méthode, donc la normalisation agit dans les deux situations : quelle que soit la négligence de saisie du codeName, la base reçoit une version rognée et en minuscules. Une frontière importante : un hook vit dans l'entité et ne voit qu'elle-même. Quand tu dois réagir aux sauvegardes de plusieurs entités en un seul endroit (journaliser chaque changement du trésor, par exemple), TypeORM propose les subscribers - des classes séparées à l'écoute des événements de toute la base. Retiens ce partage : le hook est le rituel d'une entité, le subscriber est l'observateur de tout le système.
Soft delete - l'exil plutôt que l'exécution
L'ordre "supprime ce légionnaire" est souvent hâtif. Un delete ordinaire efface l'enregistrement pour de bon - et avec lui l'historique des tributs et les relations. C'est pourquoi les systèmes mûrs recourent au soft delete : l'enregistrement reçoit un marqueur de suppression mais reste physiquement dans la table. Dans TypeORM, une colonne suffit :
1@Entity()
2export class Legionary {
3 @DeleteDateColumn()
4 deletedAt: Date;
5}
6
7// suppression douce - remplit deletedAt, l'enregistrement reste
8await legionaryRepository.softDelete(legionaryId);
9
10// restauration - efface deletedAt
11await legionaryRepository.restore(legionaryId);
12
13// retrouver aussi les supprimés
14const all = await legionaryRepository.find({ withDeleted: true });La présence de @DeleteDateColumn change le comportement de tout le repository : softDelete inscrit une date dans deletedAt, et chaque find ordinaire commence automatiquement à ignorer les enregistrements portant cette date - le légionnaire supprimé disparaît des résultats tout en restant dans la table. restore efface le marqueur et le légionnaire reprend du service. Et quand un auditeur veut voir tout le monde, exilés compris - il ajoute l'option withDeleted: true. L'exécution est devenue un exil : réversible, et qui laisse une trace.
Cascade - le sort des enregistrements liés
Reste une décision du garde : que deviennent les tributs quand leur propriétaire disparaît ? Nous écrivons la réponse dans la définition de la relation :
1@OneToMany(() => Tribute, (tribute) => tribute.legionariusze, {
2 cascade: true,
3})
4tributes: Tribute[];
5
6@ManyToOne(() => Legionary, { onDelete: 'CASCADE' })
7legionariusze: Legionary;Ce sont deux cascades différentes et il vaut la peine de les distinguer. cascade: true agit à la sauvegarde : en enregistrant un légionnaire avec de nouveaux tributs dans le tableau, TypeORM les enregistre lui et eux - d'un seul geste. onDelete: 'CASCADE' agit à la suppression, côté base : quand un légionnaire disparaît vraiment, la base supprime elle-même ses tributs, sans laisser d'enregistrements orphelins. Parmi les alternatives à 'CASCADE', il y a 'SET NULL' - le tribut reste mais perd son propriétaire. Le choix t'appartient - mais fais-le en conscience, car la cascade de suppression est l'outil le plus tranchant de cette leçon.
findAndCount - la pagination côté repository
Pour finir, relions la qualité des données à quelque chose que tu connais de la leçon sur le Query Builder : servir les résultats par portions. Le repository dispose d'une paire de méthodes réunies en une :
1const [legionaries, total] = await legionaryRepository.findAndCount({
2 where: { isActive: true },
3 order: { name: 'ASC' },
4 skip: (page - 1) * limit,
5 take: limit,
6});findAndCount renvoie un tableau de deux éléments : la liste des résultats et le nombre total d'enregistrements correspondants. L'écriture const [legionaries, total] = ... est une déstructuration - on déballe la paire en deux variables nommées, en une ligne. Le reste ressemble à un find ordinaire : skip saute les pages précédentes, take prend une portion. C'est le jumeau côté repository de getManyAndCount() du Query Builder - une forme plus simple pour des cas plus simples ; quand les conditions se compliquent, tu reviens au builder.
Récapitulatif
Le mur autour du trésor est debout. Tu l'as monté en couches :
- les options de
@Column-nullable,unique,default,typeetlength- des règles strictes appliquées par la base elle-même, @Index- une table des matières pour les colonnes de recherche ; lectures plus rapides au prix des écritures,@CreateDateColumnet@UpdateDateColumn- une chronique qui s'écrit seule,@BeforeInsertet@BeforeUpdate- le rituel d'une entité avant sauvegarde ; les subscribers - l'observateur des événements de toute la base,@DeleteDateColumnavecsoftDelete,restoreetwithDeleted- l'exil plutôt que l'exécution,cascadeà la sauvegarde etonDelete: 'CASCADE'à la suppression - un sort choisi en conscience pour les enregistrements liés,findAndCountavec la déstructuration[résultats, compteur]- la pagination sans Query Builder.
Devant toi se trouve le projet qui clôt ce module : un système d'inventaire des tributs où ces mécanismes de garde se retrouveront dans un même code. En attendant, retiens ceci : la validation dans les DTO garde la porte, mais ce sont les règles de la base qui forment le mur - elles tiennent toujours, quelle que soit la route que les données tentent d'emprunter.
Code de cette leçon : src/database-validation.ts
1// Validation en base de données - Le contrôle qualité des données
2import {
3 Entity, Column, PrimaryGeneratedColumn, Index,
4 CreateDateColumn, UpdateDateColumn, DeleteDateColumn,
5 Check, BeforeInsert, BeforeUpdate, AfterInsert,
6} from 'typeorm';
7
8console.log("Validation de la base de données - la dernière ligne de défense !");
9
10// ===========================================
11// 1. Contraintes sur les colonnes
12// ===========================================
13
14@Entity('tribute_records')
15@Index(['provincia', 'year']) // Index composite
16export class TributeRecord {
17 @PrimaryGeneratedColumn()
18 id: number;
19
20 @Column({ length: 100 })
21 provincia: string;
22
23 @Column({ type: 'decimal', precision: 12, scale: 2 })
24 @Check('"amount" > 0') // La valeur doit être positive
25 amount: number;
26
27 @Column({ type: 'int' })
28 year: number;
29
30 @Index() // Index sur la colonne
31 @Column({ length: 100 })
32 collector: string;
33
34 @Column({ type: 'enum', enum: ['gold', 'silver', 'goods'], default: 'gold' })
35 type: string;
36
37 @CreateDateColumn()
38 createdAt: Date;
39}
40
41// ===========================================
42// 2. Hooks d'entité (événements de cycle de vie)
43// ===========================================
44
45@Entity('centurions')
46export class Centurion {
47 @PrimaryGeneratedColumn()
48 id: number;
49
50 @Column()
51 name: string;
52
53 @Column()
54 rank: string;
55
56 @Column({ default: 0 })
57 experience: number;
58
59 @Column({ nullable: true })
60 slug: string;
61
62 @CreateDateColumn()
63 createdAt: Date;
64
65 @UpdateDateColumn()
66 updatedAt: Date;
67
68 @BeforeInsert()
69 generateSlug() {
70 if (this.name) {
71 this.slug = this.name.toLowerCase().replace(/ /g, '-');
72 }
73 }
74
75 @BeforeUpdate()
76 validateExperience() {
77 if (this.experience < 0) {
78 this.experience = 0;
79 }
80 }
81
82 @AfterInsert()
83 logCreation() {
84 console.log('Nouveau centurion enrôlé : ' + this.name);
85 }
86}
87
88// ===========================================
89// 3. Soft delete
90// ===========================================
91
92@Entity('decrees')
93export class Decree {
94 @PrimaryGeneratedColumn()
95 id: number;
96
97 @Column({ length: 200 })
98 title: string;
99
100 @Column({ type: 'text' })
101 content: string;
102
103 @Column({ type: 'boolean', default: true })
104 isActive: boolean;
105
106 @CreateDateColumn()
107 createdAt: Date;
108
109 @UpdateDateColumn()
110 updatedAt: Date;
111
112 @DeleteDateColumn() // Soft delete - ne supprime pas réellement
113 deletedAt: Date;
114}
115
116// Utilisation du soft delete :
117// await decreeRepo.softDelete(id); // Définit deletedAt
118// await decreeRepo.restore(id); // Restaure
119// await decreeRepo.find(); // Ignore les soft-deleted
120// await decreeRepo.find({ withDeleted: true }); // Tous
121
122console.log("\n=== RÉSUMÉ DE LA VALIDATION DB ===");
123console.log("@Check() - contraintes au niveau de la base de données");
124console.log("@Index() - index accélérant la recherche");
125console.log("@BeforeInsert/@BeforeUpdate - hooks de cycle de vie");
126console.log("@DeleteDateColumn - soft delete (ne supprime pas réellement)");
127console.log("unique: true - unicité de la colonne");
128Tu 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. Comment ajoute-t-on une validation au niveau de l'entité dans TypeORM avec class-validator ?
2. Comment marque-t-on une colonne comme unique dans une entité TypeORM ?
Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Écris une entité Province avec @Index() sur le name, @Column({ unique: true }) pour le code et les décorateurs @IsString(), @Length(2, 100)
- Tri vertical
Classe les couches de validation des données, de la plus externe à la plus interne :
- Clique dans l'ordre
Classe les éléments d'une définition de colonne avec valeur par défaut et NOT NULL :
- Éditeur de code
Écris une entité avec @BeforeInsert() qui affecte new Date() à createdAt et @BeforeUpdate() qui affecte updatedAt
- Tri horizontal
Classe les éléments du décorateur @Index pour une entité à plusieurs colonnes :
- Tri vertical
Classe les types de colonnes TypeORM du plus simple au plus complexe :
- Éditeur de code
Écris une entité avec @CreateDateColumn(), @UpdateDateColumn() et @DeleteDateColumn() pour le soft delete
- Clique dans l'ordre
Classe les éléments d'un soft delete via un repository dans le bon ordre :
- Tri horizontal
Classe les éléments d'une requête incluant les enregistrements soft-deleted :
- Tri vertical
Classe les techniques d'optimisation des requêtes du plus simple au plus avancé :
- Éditeur de code
Écris une méthode findPaginated(page, limit) qui utilise findAndCount avec skip et take pour paginer les légionnaires
- Tri horizontal
Classe les éléments de la déstructuration du résultat de findAndCount :