Cours NestJS · Module 3: TypeORM et bases de données

Les relations en base de données - les liens de la légion

7 min de lecture
Dans cette leçon5

Tu as déjà une table de légionnaires et une table de légions. Chacune tient bien toute seule - mais comment savoir qui sert sous les ordres de qui ? On pourrait inscrire le nom de la légion dans une colonne texte à côté de chaque légionnaire. Cela marche jusqu'à la première faute de frappe : "Legio X Equestris" et "Legio X Equestriss" sont deux légions différentes pour la base, et tu viens de scinder une cohorte en deux.

Les Romains connaissaient une meilleure méthode. Un légionnaire ne porte pas le nom de sa légion - il en porte le numéro. Une légion, un numéro, tout le monde pointant vers la même entrée du registre. Voilà ce qu'est une relation : un lien fondé sur un identifiant plutôt que sur du texte recopié.

Dans cette leçon, tu découvriras trois sortes de ces liens et tu apprendras à les lire du côté du code.

Un à plusieurs - une légion et ses légionnaires

Commençons par la configuration la plus courante. Une légion a plusieurs légionnaires ; un légionnaire appartient à une légion. Cette relation a deux côtés et il faut décrire les deux - chacun dans son entité :

1@Entity()
2export class Legion {
3  @OneToMany(() => Legionary, (legionary) => legionary.legion)
4  legionaries: Legionary[];
5}
6
7@Entity()
8export class Legionary {
9  @ManyToOne(() => Legion, (legion) => legion.legionaries)
10  legion: Legion;
11}

L'essentiel est la règle qui t'indique où placer quoi : un décorateur décrit le côté sur lequel il se trouve. Une légion est le "un", et elle a "plusieurs" légionnaires - donc @OneToMany se place dans l'entité Legion et son champ est un tableau. Un légionnaire est l'un des "plusieurs" et appartient à "une" légion - donc @ManyToOne se place dans l'entité Legionary et son champ est au singulier. Chaque fois que tu hésites, lis la phrase depuis le côté de l'entité que tu écris : "un légionnaire appartient à une légion" - et voilà ton @ManyToOne.

Regardons les deux arguments du décorateur, car tous deux sont obligatoires et tous deux prêtent à confusion. Le premier, () => Legionary, désigne l'entité de l'autre côté. Pourquoi une fonction plutôt que le nom de la classe ? Parce que les deux entités se référencent mutuellement, et dans une telle boucle l'une des classes ne serait pas encore définie au moment de la lecture. La fonction repousse cette recherche jusqu'à ce que les deux existent.

Le second argument, (legionary) => legionary.legion, est le côté inverse : il désigne le champ de l'autre entité qui décrit la même relation. Grâce à lui, TypeORM sait que Legion.legionaries et Legionary.legion sont les deux extrémités d'un seul pont, et non deux relations indépendantes.

Et que se passe-t-il en base ? La colonne d'identifiant est créée uniquement du côté @ManyToOne - un legionId apparaît dans la table des légionnaires. La table des légions ne change pas du tout. C'est bien le "numéro que l'on porte sur soi" : chaque légionnaire pointe vers sa légion, et non l'inverse.

Plusieurs à plusieurs - légionnaires et compétences

Deuxième configuration : un légionnaire maîtrise plusieurs compétences, et chaque compétence est maîtrisée par plusieurs légionnaires. Ici, aucun des deux côtés ne peut stocker l'identifiant - une seule colonne ne contiendra pas une liste.

1@Entity()
2export class Legionary {
3  @ManyToMany(() => Skill)
4  @JoinTable()
5  skills: Skill[];
6}
7
8@Entity()
9export class Skill {
10  @ManyToMany(() => Legionary, (legionary) => legionary.skills)
11  legionaries: Legionary[];
12}

La solution est une table intermédiaire séparée, dans laquelle une ligne est une paire : ce légionnaire connaît cette compétence. Le décorateur @JoinTable() la crée pour toi - et c'est le détail sur lequel trébuchent le plus de personnes : @JoinTable() ne se place que d'un seul côté, celui que l'on considère comme propriétaire de la relation. Le placer des deux côtés aboutit à deux tables intermédiaires décrivant la même chose. Ne le placer nulle part aboutit à une erreur au démarrage de l'application.

Remarque que @ManyToMany du côté Legionary se passe du second argument. Le côté inverse est facultatif : tu l'indiques quand tu veux aussi atteindre les liens depuis l'autre extrémité - ici, lire depuis une compétence qui l'a maîtrisée.

Un à un - un légionnaire et sa carte

La troisième configuration est la plus simple : un légionnaire possède une carte personnelle des tributs, et la carte appartient à un légionnaire.

1@Entity()
2export class Legionary {
3  @OneToOne(() => TributeMap)
4  @JoinColumn()
5  map: TributeMap;
6}

Ici, c'est toi qui décides dans quelle table atterrira la colonne d'identifiant - @JoinColumn() la désigne. Placé dans l'entité Legionary, il fait que la table des légionnaires recevra une colonne mapId. Ne le confonds pas avec @JoinTable() de la section précédente : @JoinColumn désigne une colonne dans une table existante, @JoinTable crée une nouvelle table intermédiaire.

Charger les relations

Une relation décrite dans une entité ne signifie pas que les données arrivent d'elles-mêmes. Par défaut, find() rapporte l'enregistrement seul et le champ de relation reste vide - délibérément, car autrement récupérer un légionnaire entraînerait la moitié de la base. Tu tires les liens en conscience :

1const legionaries = await this.legionaryRepo.find({
2  relations: ['legion', 'skills'],
3});

La clé relations énumère ce qui doit voyager avec l'enregistrement. C'est le chargement explicite et la façon de travailler par défaut : dans chaque requête, tu décides séparément de ce dont tu as besoin.

Il existe aussi une variante automatique. Ajouter eager: true à une relation la fait arriver toujours, sans qu'on la demande :

1@ManyToOne(() => Legion, (legion) => legion.legionaries, { eager: true })
2legion: Legion;

Pratique - et dangereux pour cette raison même. eager s'applique à chaque requête sur les légionnaires, y compris là où la légion est parfaitement inutile, et alourdit en silence les requêtes de toute l'application. Note aussi ce que eager ne change pas : cela reste une seule requête avec un JOIN, simplement exécutée toujours au lieu d'être à la demande. C'est pourquoi je te recommande de t'en tenir à relations et de recourir à eager exceptionnellement - quand la relation est vraiment nécessaire à chaque fois.

Récapitulatif

La légion tient ensemble, et tu sais décrire ses liens :

  • une relation relie les tables par un identifiant, non par du texte recopié,
  • un décorateur décrit le côté sur lequel il se trouve : @OneToMany du côté "un" (champ tableau), @ManyToOne du côté "plusieurs" (champ singulier),
  • le premier argument est une fonction () => Entité - elle repousse la lecture de la classe, car les entités se pointent mutuellement,
  • le second argument désigne le côté inverse, l'autre extrémité du même pont,
  • la colonne d'identifiant est créée du côté @ManyToOne,
  • @ManyToMany exige une table intermédiaire : @JoinTable() d'un seul côté,
  • @OneToOne avec @JoinColumn() décide quelle table reçoit la colonne - @JoinColumn désigne une colonne, @JoinTable crée une table,
  • tu charges les relations explicitement avec relations: ['...'] ; eager: true le fait toujours, alors utilise-le avec parcimonie.

Dans la prochaine leçon, tu découvriras les repositories - les archivistes qui vont chercher ces données au nom des services. En attendant, retiens ceci : une relation est un numéro que l'on porte sur soi - une seule entrée du registre vers laquelle tous pointent, au lieu d'un nom recopié à côté de chaque légionnaire.

Code de cette leçon : src/relations.ts
1// Relations en base de données - Les liens entre légionnaires
2import {
3  Entity, Column, PrimaryGeneratedColumn,
4  OneToMany, ManyToOne, ManyToMany,
5  JoinColumn, JoinTable, OneToOne,
6} from 'typeorm';
7
8console.log("Relations TypeORM - les liens entre les données de l'imperium !");
9
10// ===========================================
11// 1. OneToMany / ManyToOne - Légion et soldats
12// ===========================================
13
14@Entity('legions')
15export class Legion {
16  @PrimaryGeneratedColumn()
17  id: number;
18
19  @Column()
20  name: string;
21
22  @Column()
23  commander: string;
24
25  @OneToMany(() => Soldier, soldier => soldier.legion)
26  soldiers: Soldier[];
27}
28
29@Entity('soldiers')
30export class Soldier {
31  @PrimaryGeneratedColumn()
32  id: number;
33
34  @Column()
35  name: string;
36
37  @Column()
38  rank: string;
39
40  @ManyToOne(() => Legion, legion => legion.soldiers)
41  @JoinColumn({ name: 'legion_id' })
42  legion: Legion;
43}
44
45// ===========================================
46// 2. OneToOne - Légionnaire et carte
47// ===========================================
48
49@Entity('treasure_maps')
50export class TreasureMap {
51  @PrimaryGeneratedColumn()
52  id: number;
53
54  @Column()
55  location: string;
56
57  @Column()
58  description: string;
59
60  @OneToOne(() => Soldier)
61  @JoinColumn()
62  owner: Soldier;
63}
64
65// ===========================================
66// 3. ManyToMany - Batailles et légions
67// ===========================================
68
69@Entity('battles')
70export class Battle {
71  @PrimaryGeneratedColumn()
72  id: number;
73
74  @Column()
75  name: string;
76
77  @Column()
78  location: string;
79
80  @ManyToMany(() => Legion)
81  @JoinTable({
82    name: 'battle_legions',
83    joinColumn: { name: 'battle_id' },
84    inverseJoinColumn: { name: 'legion_id' },
85  })
86  legions: Legion[];
87}
88
89// ===========================================
90// 4. Charger les relations (eager vs lazy)
91// ===========================================
92
93// Eager loading (automatique) :
94// @OneToMany(() => Soldier, s => s.legion, { eager: true })
95// soldiers: Soldier[];
96
97// Lazy loading (à la demande) :
98// const legion = await legionRepo.findOne({
99//   where: { id: 1 },
100//   relations: ['soldiers'],
101// });
102
103// Opérations en cascade :
104// @OneToMany(() => Soldier, s => s.legion, { cascade: true })
105// soldiers: Soldier[];
106
107console.log("\n=== RÉSUMÉ DES RELATIONS ===");
108console.log("@OneToMany + @ManyToOne - un à plusieurs (légion -> soldats)");
109console.log("@OneToOne - un à un (légionnaire -> carte)");
110console.log("@ManyToMany + @JoinTable - plusieurs à plusieurs (batailles -> légions)");
111console.log("@JoinColumn - le côté qui détient la clé étrangère");
112console.log("relations: ['x'] - chargement des relations dans la requête");
113

Tu 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. 1. Que signifie la relation @OneToMany dans TypeORM ?

  2. 2. De quel côté de la relation place-t-on @ManyToOne ?

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

    Écris une entité Legion avec @OneToMany(() => Legionary, ...) et une entité Legionary avec @ManyToOne(() => Legion, ...)

  • Tri horizontal

    Classe les éléments de la déclaration @OneToMany dans le bon ordre :

  • Clique dans l'ordre

    Classe les éléments de la configuration d'une relation en eager loading dans le bon ordre :

  • Éditeur de code

    Écris une entité Legionary avec @ManyToMany(() => Skill) et @JoinTable() et une entité Skill avec @ManyToMany(() => Legionary)