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

Query Builder - recherches avancées

7 min de lecture
Dans cette leçon7

Le consul Caesar.js se présente devant toi avec une nouvelle mission : "Le censeur demande quels centurions ont collecté ce mois-ci les tributs de plus grande valeur totale. Il veut la liste triée du plus riche au moins riche." Tu ouvres le repository que tu connais bien... et tu t'arrêtes. La méthode find() répond très bien aux questions simples : "donne-moi les tributs valant plus de 1000 deniers", "trouve un légionnaire par son nom". Mais additionner, grouper par centurion, assembler des conditions seulement pendant l'exécution du programme - tout cela, find() ne sait pas l'exprimer.

Pour ces questions, TypeORM possède un outil plus puissant : le Query Builder. Imagine-le comme le cartographe du trésor - tu construis la requête pas à pas, méthode par méthode, et à la fin il dessine de ces étapes une seule carte précise (la requête SQL) et l'envoie à la base de données.

Ta première requête, pas à pas

Commençons par une question que tu saurais aussi poser avec find() - c'est la façon la plus simple de voir ce que le Query Builder fait différemment. Nous cherchons les tributs valant plus de 1000 deniers, du plus précieux au moins précieux :

1const tributes = await this.tributeRepository
2  .createQueryBuilder('tribute')
3  .where('tribute.value > :minValue', { minValue: 1000 })
4  .orderBy('tribute.value', 'DESC')
5  .getMany();

Suivons la chaîne. createQueryBuilder('tribute') ouvre la construction et donne à l'entité un alias - un petit surnom que nous utilisons dans toutes les méthodes suivantes, en écrivant tribute.value ou tribute.name. where(...) ajoute une condition, orderBy(...) ajoute le tri. Seul getMany() assemble ces briques en SQL et l'envoie à la base - avant cela, rien ne part vers la base. Le résultat est un simple tableau d'entités Tribute[], exactement celui que find() t'aurait donné - la manière de construire la question a changé, la forme de la réponse, non.

Les paramètres - un bouclier contre les ordres hostiles

Dans la condition where, nous n'avons pas écrit le nombre 1000 directement. À sa place se trouve :minValue - un paramètre, dont la valeur est fournie à côté, dans l'objet { minValue: 1000 }. Pourquoi cette séparation ? Imagine que la valeur vienne d'un utilisateur. Collée directement dans le texte de la requête, elle pourrait y glisser un ordre hostile - comme un messager qui cache dans sa lettre l'ordre d'ouvrir les portes de la ville. Cette attaque s'appelle SQL injection :

1// MAL - les données de l'utilisateur collées au texte de la requête
2.where(`tribute.name = '${userInput}'`)
3
4// BIEN - les données voyagent à part, comme paramètre
5.where('tribute.name = :name', { name: userInput })

La différence est fondamentale : un paramètre n'est jamais collé au texte SQL. La base le reçoit par un canal séparé et le traite uniquement comme une donnée - même si quelqu'un tape un fragment de requête malveillante dans un formulaire, cela reste un simple texte étrange. Adopte donc une règle de fer : chaque valeur d'une requête passe par un paramètre. Sans exception.

Enchaîner les conditions

Les vraies recherches ont rarement une seule condition. Tu ajoutes la suivante avec andWhere - elle doit être remplie en même temps que les précédentes. Il existe aussi orWhere - il suffit que l'une d'elles soit remplie :

1const tributes = await this.tributeRepository
2  .createQueryBuilder('tribute')
3  .where('tribute.value >= :min', { min: 1000 })
4  .andWhere('tribute.type = :type', { type: 'gold' })
5  .getMany();

Cette requête trouve les tributs qui sont en or et valent au moins 1000 deniers. Et comme la requête est un objet ordinaire construit par des méthodes, nous pouvons aussi ajouter des conditions... sous condition - dans un if :

1let query = this.tributeRepository.createQueryBuilder('tribute');
2
3if (criteria.minValue) {
4  query = query.andWhere('tribute.value >= :min', { min: criteria.minValue });
5}
6if (criteria.type) {
7  query = query.andWhere('tribute.type = :type', { type: criteria.type });
8}
9
10const tributes = await query.orderBy('tribute.value', 'DESC').getMany();

Et c'est le moment où le Query Builder laisse find() loin derrière : la requête grandit pendant l'exécution du programme, condition après condition, selon ce que demande l'utilisateur - et elle part quand même vers la base comme un seul SQL, seulement au moment du getMany().

Joindre les relations

Les tributs n'existent pas dans le vide - chacun appartient à un légionnaire, et chaque légionnaire sert sous un centurion. Pour récupérer les tributs avec les données de leurs propriétaires, nous joignons la relation :

1const tributes = await this.tributeRepository
2  .createQueryBuilder('tribute')
3  .leftJoinAndSelect('tribute.legionariusze', 'legionnaire')
4  .where('legionnaire.isActive = :active', { active: true })
5  .getMany();

Le nom leftJoinAndSelect contient deux décisions en un seul mot. LeftJoin : attache le légionnaire, mais ne rejette pas un tribut sans propriétaire - son champ restera simplement vide (le jumeau innerJoinAndSelect écarterait un tel tribut du résultat). AndSelect : charge aussi les données du légionnaire dans les entités du résultat. Le deuxième argument, 'legionnaire', est l'alias de l'entité jointe - c'est lui qui nous a permis de filtrer par legionnaire.isActive dans le where. Il existe aussi leftJoin tout court, sans AndSelect : la relation sert alors uniquement au filtrage et ses données ne sont pas chargées - le résultat est plus léger. C'est ce que je te recommande quand tu n'affiches pas les données du propriétaire.

Les statistiques - quand le résultat n'est pas une entité

Revenons à la question du censeur : la valeur totale des tributs par type. La réponse n'est pas une liste de tributs mais un tableau de synthèses - quelques lignes avec des compteurs et des sommes. Pour ces requêtes, nous choisissons les colonnes nous-mêmes et fermons la construction avec getRawMany() au lieu de getMany() :

1const stats = await this.tributeRepository
2  .createQueryBuilder('tribute')
3  .select('tribute.type', 'type')
4  .addSelect('COUNT(*)', 'count')
5  .addSelect('SUM(tribute.value)', 'totalValue')
6  .groupBy('tribute.type')
7  .orderBy('totalValue', 'DESC')
8  .getRawMany();

select choisit la première colonne, chaque addSelect en ajoute une autre, et le deuxième argument lui donne son nom dans le résultat. groupBy replie les lignes en groupes - COUNT et SUM se calculent alors séparément pour chaque type de tribut. La fin est le point crucial : getMany() renvoie des entités Tribute, mais une somme ou un compteur n'est pas une entité - nous appelons donc getRawMany(), qui renvoie des lignes brutes avec les champs que tu as nommés toi-même : { type, count, totalValue }.

La pagination - le trésor servi par portions

Le trésor de l'Empire compte des milliers de tributs et personne ne les regarde tous à la fois. Nous servons les résultats par pages :

1const [tributes, total] = await this.tributeRepository
2  .createQueryBuilder('tribute')
3  .orderBy('tribute.value', 'DESC')
4  .skip((page - 1) * limit)
5  .take(limit)
6  .getManyAndCount();

skip saute les résultats des pages précédentes, take prend une portion. getManyAndCount() renvoie une paire : la portion de résultats et le nombre total de lignes correspondantes - c'est avec lui que tu calcules le nombre de pages : Math.ceil(total / limit). Note que orderBy n'est pas ici une décoration mais une condition de justesse : sans ordre fixe, la base pourrait servir les lignes différemment à chaque fois, et les pages commenceraient à se mélanger.

Récapitulatif

Regarde combien de réponses tu sais déjà tirer du trésor :

  • createQueryBuilder('alias') ouvre la construction, et l'alias est le surnom de l'entité utilisé à l'intérieur,
  • tu enchaînes les conditions avec where, andWhere et orWhere - y compris dynamiquement, dans un if,
  • chaque valeur voyage comme paramètre :nom - ton bouclier contre le SQL injection,
  • tu joins les relations avec leftJoinAndSelect, ou avec le plus léger leftJoin quand elles ne servent qu'à filtrer,
  • tu construis les statistiques avec select, addSelect et groupBy, et tu les reçois par getRawMany(),
  • tu découpes les pages de résultats avec la paire skip et take, et getManyAndCount() ajoute le compteur total.

Dans la prochaine leçon, tu découvriras les transactions - le moyen d'exécuter plusieurs opérations sur le trésor comme une seule : toutes, ou aucune. En attendant, retiens ceci : le Query Builder est un cartographe - tu poses les briques, et il n'en dessine une seule carte SQL qu'au moment où tu appelles getMany().

Code de cette leçon : src/query-builder.ts
1// Query Builder - Recherches avancées dans le trésor
2import { Injectable } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5
6console.log("Query Builder - un puissant outil de recherche !");
7
8// ===========================================
9// 1. QueryBuilder de base
10// ===========================================
11
12@Injectable()
13export class LegionQueryService {
14  constructor(
15    @InjectRepository('Legionary')
16    private legionaryRepo: Repository<any>,
17  ) {}
18
19  // Trouver les légionnaires expérimentés
20  async findExperienced(minExp: number) {
21    return this.legionaryRepo
22      .createQueryBuilder('legionary')
23      .where('legionary.experience >= :minExp', { minExp })
24      .orderBy('legionary.experience', 'DESC')
25      .getMany();
26  }
27
28  // Rechercher par nom
29  async searchByName(name: string) {
30    return this.legionaryRepo
31      .createQueryBuilder('legionary')
32      .where('legionary.name LIKE :name', { name: '%' + name + '%' })
33      .getMany();
34  }
35
36  // Filtrage avec plusieurs conditions
37  async findByCriteria(rank?: string, minExp?: number, legion?: string) {
38    const qb = this.legionaryRepo.createQueryBuilder('l');
39
40    if (rank) {
41      qb.andWhere('l.rank = :rank', { rank });
42    }
43    if (minExp) {
44      qb.andWhere('l.experience >= :minExp', { minExp });
45    }
46    if (legion) {
47      qb.andWhere('l.legion = :legion', { legion });
48    }
49
50    return qb.orderBy('l.experience', 'DESC').getMany();
51  }
52}
53
54// ===========================================
55// 2. QueryBuilder avec relations (JOIN)
56// ===========================================
57
58@Injectable()
59export class AdvancedQueryService {
60  constructor(
61    @InjectRepository('Legion')
62    private legionRepo: Repository<any>,
63  ) {}
64
65  // LEFT JOIN avec une relation
66  async findLegionsWithCenturions() {
67    return this.legionRepo
68      .createQueryBuilder('legion')
69      .leftJoinAndSelect('legion.soldiers', 'soldier')
70      .where('soldier.rank = :rank', { rank: 'Centurio' })
71      .orderBy('legion.name', 'ASC')
72      .getMany();
73  }
74
75  // Agrégation - statistiques
76  async getLegionStats() {
77    return this.legionRepo
78      .createQueryBuilder('legion')
79      .select('legion.name', 'legionName')
80      .addSelect('COUNT(soldier.id)', 'soldierCount')
81      .addSelect('AVG(soldier.experience)', 'avgExperience')
82      .leftJoin('legion.soldiers', 'soldier')
83      .groupBy('legion.name')
84      .getRawMany();
85  }
86
87  // Sous-requête (subquery)
88  async findEliteLegions() {
89    const subQuery = this.legionRepo
90      .createQueryBuilder('sub')
91      .select('AVG(sub.soldiers)', 'avg')
92      .getQuery();
93
94    return this.legionRepo
95      .createQueryBuilder('legion')
96      .where('legion.soldiers > (' + subQuery + ')')
97      .getMany();
98  }
99}
100
101console.log("\n=== RÉSUMÉ DU QUERY BUILDER ===");
102console.log("createQueryBuilder('alias') - démarre la requête");
103console.log(".where() / .andWhere() - conditions de filtrage");
104console.log(".orderBy() - tri des résultats");
105console.log(".leftJoinAndSelect() - jointure avec les relations");
106console.log(".select() / .addSelect() - choix des colonnes");
107console.log(".groupBy() - regroupement (agrégation)");
108console.log(".getMany() / .getRawMany() - récupération des résultats");
109

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. Quand vaut-il mieux utiliser QueryBuilder plutôt que les méthodes simples du repository dans TypeORM ?

  2. 2. Que fait la méthode .leftJoinAndSelect() dans QueryBuilder ?

Tâches pratiques dans le jeu

  • Tri vertical

    Classe les éléments d'une requête QueryBuilder, du premier au dernier :

  • Éditeur de code

    Utilise createQueryBuilder pour trouver les légionnaires de rang 'Centurion', triés par expérience DESC, avec une limite de 10

  • Tri horizontal

    Classe les éléments d'une requête QueryBuilder avec LEFT JOIN :

  • Clique dans l'ordre

    Classe les méthodes de QueryBuilder dans le bon ordre pour construire une requête :

  • Éditeur de code

    Utilise QueryBuilder pour trouver les légions avec leurs légionnaires, où la légion compte plus de 1000 soldats, avec pagination (skip et take)