Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 826 minIntermédiaire

Agrégation MongoDB : pipeline, $match, $group, $lookup

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • construire un pipeline d'agrégation étape par étape ;
  • regrouper et calculer avec $group et ses accumulateurs ;
  • dérouler un tableau avec $unwind et joindre avec $lookup ;
  • traduire une requête SQL avec GROUP BY et JOIN en pipeline.

3 exercices corrigés1 schéma

L'agrégation MongoDB calcule des résultats à partir des documents d'une collection : des totaux, des moyennes, des regroupements, des jointures, des tableaux de bord. Elle s'écrit comme un pipeline : un tableau d'étapes (stages), chacune recevant les documents produits par la précédente et les transformant (filtrer, regrouper, trier, joindre). C'est l'équivalent de GROUP BY, JOIN et des fonctions d'agrégat de SQL, avec la méthode aggregate(). Ce chapitre construit pas à pas les statistiques dont le comptable d'AppliFrais a besoin.

Le principe du pipeline

js
db.fiches.aggregate([
  { $match: { etat: { $in: ['VA', 'MP', 'RB'] } } },                    // 1. filtrer
  { $group: { _id: '$visiteur', total: { $sum: '$montantValide' } } },  // 2. regrouper
  { $sort: { total: -1 } }                                              // 3. trier
])

Chaque étape est un objet à une seule clé, le nom de l'étape. Les documents traversent les étapes dans l'ordre, comme l'eau dans un tuyau : les 9 fiches entrent, $match en garde 4 (les fiches validées, mises en paiement ou remboursées), $group en fait 2 groupes (un par visiteur), $sort les ordonne.

js
[
  { _id: ObjectId('6a0000000000000000000001'), total: 1411.1 },
  { _id: ObjectId('6a0000000000000000000003'), total: 746.8 }
]

Lucas Martin : 512,20 + 898,90 = 1 411,10 € ; Thomas Girard : 184,40 + 562,40 = 746,80 €.

collectionfiches9 docs$matchétat VA, MPou RB4 docs$grouppar visiteur,somme2 groupes$lookup+ le documentdu visiteur2 docs$sorttotaldécroissant2 docsrésultat{ nom: 'Martin', total: 1411.1 }{ nom: 'Girard', total: 746.8 }Chaque étape reçoit les documentsde la précédente et les transforme.Filtrer tôt ($match), joindre tard($lookup) : moins de documents àtraiter à chaque étape.
Le pipeline complet, avec la jointure $lookup vue plus loin : chaque étape transforme le flux de documents de la précédente

Dans une étape, deux écritures reviennent sans cesse :

  • '$champ', une chaîne qui commence par $, désigne la valeur du champ dans le document courant : '$montantValide', '$lignesForfait.code' ;
  • { $operateur: [ … ] } est une expression qui calcule une valeur : { $multiply: ['$quantite', '$montantUnitaire'] }.

SQL et agrégation : la correspondance

SQLÉtape d'agrégationRôle
WHERE$matchFiltrer les documents (même syntaxe qu'un find)
SELECT col, expr AS alias$project, $set (alias $addFields)Choisir, renommer, calculer des champs
GROUP BY + SUM, AVG, COUNT…$groupRegrouper et calculer par groupe
HAVING$match placé après $groupFiltrer les groupes
ORDER BY$sortTrier
LIMIT, OFFSET$limit, $skipPaginer
LEFT JOIN$lookupJoindre une autre collection
(pas d'équivalent direct)$unwindFaire un document par élément d'un tableau
COUNT(*)$countCompter les documents qui sortent du pipeline
CREATE TABLE … AS SELECT$out, $mergeÉcrire le résultat dans une collection

$match : filtrer, et le plus tôt possible

$match accepte exactement les filtres de find, avec tous les opérateurs du chapitre 5. Placé en tête du pipeline, il peut utiliser un index et réduit le nombre de documents que les étapes suivantes traitent. Placé après un $group, il filtre des groupes, comme HAVING.

js
// HAVING : les visiteurs qui ont plus de 2 fiches
db.fiches.aggregate([
  { $group: { _id: '$visiteur', nb: { $sum: 1 } } },
  { $match: { nb: { $gt: 2 } } }
])
// Martin et Girard, 3 fiches chacun

$group : regrouper et calculer

$group crée un document par valeur distincte de son _id, puis calcule les accumulateurs demandés sur les documents du groupe.

js
// Le nombre de fiches par état
db.fiches.aggregate([
  { $group: { _id: '$etat', nb: { $sum: 1 } } },
  { $sort: { nb: -1, _id: 1 } }
])
js
[
  { _id: 'CR', nb: 3 },
  { _id: 'CL', nb: 2 },
  { _id: 'VA', nb: 2 },
  { _id: 'MP', nb: 1 },
  { _id: 'RB', nb: 1 }
]

{ $sum: 1 } ajoute 1 par document : c'est un COUNT(*). { $sum: '$montantValide' } additionne un champ : c'est un SUM.

AccumulateurCalcule, pour chaque groupe
$sumLa somme d'un champ, ou le nombre de documents avec $sum: 1
$avgLa moyenne
$min, $maxLa plus petite, la plus grande valeur
$first, $lastLa valeur du premier, du dernier document du groupe (après un $sort)
$pushLe tableau de toutes les valeurs
$addToSetLe tableau des valeurs distinctes (dans un ordre quelconque)
$countLe nombre de documents (forme { $count: {} })

Le _id du groupe peut être composé, pour regrouper sur plusieurs champs, ou null pour un seul groupe qui contient tout :

js
// Total et moyenne des montants validés par mois
db.fiches.aggregate([
  { $match: { etat: { $in: ['VA', 'MP', 'RB'] } } },
  { $group: { _id: '$mois', total: { $sum: '$montantValide' }, moyenne: { $avg: '$montantValide' } } },
  { $sort: { _id: 1 } }
])
// [ { _id: '2026-08', total: 696.6, moyenne: 348.3 },
//   { _id: '2026-09', total: 1461.3, moyenne: 730.65 } ]

// Un seul groupe : des compteurs conditionnels avec $cond
db.fiches.aggregate([
  { $group: {
      _id: null,
      aValider: { $sum: { $cond: [ { $eq: ['$etat', 'CL'] }, 1, 0 ] } },
      enCours: { $sum: { $cond: [ { $eq: ['$etat', 'CR'] }, 1, 0 ] } }
  } },
  { $project: { _id: 0 } }
])
// [ { aValider: 2, enCours: 3 } ]

// Repérer des doublons, avant de créer un index unique (chapitre 7)
db.fiches.aggregate([
  { $group: { _id: { visiteur: '$visiteur', mois: '$mois' }, nb: { $sum: 1 } } },
  { $match: { nb: { $gt: 1 } } }
])
// [] : aucun doublon

$cond: [ condition, siVrai, siFaux ] est le « si… alors… sinon » des expressions : il permet de compter ou d'additionner seulement certains documents, comme SUM(CASE WHEN … THEN 1 ELSE 0 END) en SQL.

$project et $set : choisir et calculer des champs

$project décrit la forme de sortie : 1 pour garder un champ, 0 pour l'enlever, une expression pour en calculer un. $set (ou son alias $addFields) ajoute des champs calculés sans toucher aux autres.

js
// Le total des frais forfaitisés de chaque fiche d'octobre
db.fiches.aggregate([
  { $match: { mois: '2026-10' } },
  { $project: {
      _id: 0, visiteur: 1, etat: 1,
      totalForfait: { $round: [
        { $sum: { $map: {
            input: '$lignesForfait', as: 'l',
            in: { $multiply: ['$$l.quantite', '$$l.montantUnitaire'] }
        } } },
        2 ] }
  } },
  { $sort: { totalForfait: -1 } }
])
js
[
  { visiteur: ObjectId('6a0000000000000000000003'), etat: 'CL', totalForfait: 665.6 },
  { visiteur: ObjectId('6a0000000000000000000001'), etat: 'CR', totalForfait: 203 },
  { visiteur: ObjectId('6a0000000000000000000002'), etat: 'CR', totalForfait: 108.9 },
  { visiteur: ObjectId('6a0000000000000000000004'), etat: 'CR', totalForfait: 0 }
]

Lis l'expression de l'intérieur vers l'extérieur : $map parcourt le tableau lignesForfait et, pour chaque ligne l (désignée par $$l, avec deux $), calcule quantité × tarif ; $sum additionne le tableau obtenu ; $round arrondit au centime, ce qui corrige les imprécisions des double (0,62 × 150 = 93, plus une poussière). Pour Thomas Girard : 3 × 110 + 380 × 0,62 + 4 × 25 = 330 + 235,60 + 100 = 665,60 €.

Les expressions les plus utiles :

FamilleOpérateurs
Arithmétique$add, $subtract, $multiply, $divide, $round, $abs
Comparaison et conditions$eq, $ne, $gt, $lt, $cond, $ifNull, $switch
Chaînes$concat, $toUpper, $substrCP, $split
Tableaux$size, $map, $filter, $sum (sur un tableau), $in
Dates$year, $month, $dateToString, $dateDiff, $dateAdd
Types$toString, $toInt, $toDouble, $toObjectId
js
// L'ancienneté des visiteurs et leur date d'embauche au format français
db.visiteurs.aggregate([
  { $match: { role: 'visiteur' } },
  { $project: {
      _id: 0, nom: 1,
      embauche: { $dateToString: { date: '$dateEmbauche', format: '%d/%m/%Y' } },
      anciennete: { $dateDiff: { startDate: '$dateEmbauche', endDate: ISODate('2026-11-01'), unit: 'year' } }
  } },
  { $sort: { anciennete: -1 } }
])
// Girard 07/01/2019 7 ; Martin 01/09/2021 5 ; Benali 13/03/2023 3 ; Roux 01/09/2026 0

$unwind : un document par élément de tableau

$unwind « déroule » un tableau : un document qui a 4 lignes devient 4 documents, chacun avec une ligne à la place du tableau. C'est l'étape indispensable pour regrouper sur les éléments d'un tableau.

js
db.fiches.aggregate([
  { $match: { visiteur: ObjectId('6a0000000000000000000001'), mois: '2026-10' } },
  { $unwind: '$lignesForfait' },
  { $project: { _id: 0, mois: 1, ligne: '$lignesForfait' } }
])
// [ { mois: '2026-10', ligne: { code: 'ETP', quantite: 1, montantUnitaire: 110 } },
//   { mois: '2026-10', ligne: { code: 'KM', quantite: 150, montantUnitaire: 0.62 } } ]
js
// Quantité et montant par type de frais forfaitisé, toutes fiches confondues
db.fiches.aggregate([
  { $unwind: '$lignesForfait' },
  { $group: {
      _id: '$lignesForfait.code',
      quantite: { $sum: '$lignesForfait.quantite' },
      montant: { $sum: { $multiply: ['$lignesForfait.quantite', '$lignesForfait.montantUnitaire'] } }
  } },
  { $set: { montant: { $round: ['$montant', 2] } } },
  { $sort: { montant: -1 } }
])
js
[
  { _id: 'ETP', quantite: 16, montant: 1760 },
  { _id: 'KM', quantite: 2365, montant: 1466.3 },
  { _id: 'REP', quantite: 24, montant: 600 },
  { _id: 'NUI', quantite: 6, montant: 480 }
]

Joindre deux collections avec $lookup

$lookup ajoute à chaque document un tableau des documents d'une autre collection qui lui correspondent. Les quatre paramètres de base :

ParamètreRôlePour joindre fiches et visiteurs
fromLa collection à joindre'visiteurs'
localFieldLe champ du document courant'visiteur' (dans la fiche)
foreignFieldLe champ de l'autre collection'_id' (du visiteur)
asLe nom du tableau ajouté'visiteur' (on remplace la référence)
js
// Les totaux validés par visiteur, avec leur nom
db.fiches.aggregate([
  { $match: { etat: { $in: ['VA', 'MP', 'RB'] } } },
  { $group: { _id: '$visiteur', total: { $sum: '$montantValide' }, nbFiches: { $sum: 1 } } },
  { $lookup: { from: 'visiteurs', localField: '_id', foreignField: '_id', as: 'visiteur' } },
  { $unwind: '$visiteur' },
  { $project: { _id: 0, nom: '$visiteur.nom', prenom: '$visiteur.prenom', total: 1, nbFiches: 1 } },
  { $sort: { total: -1 } }
])
js
[
  { total: 1411.1, nbFiches: 2, nom: 'Martin', prenom: 'Lucas' },
  { total: 746.8, nbFiches: 2, nom: 'Girard', prenom: 'Thomas' }
]

$lookup produit toujours un tableau (vide si rien ne correspond, ce qui en fait un LEFT JOIN) ; le $unwind qui suit le transforme en sous-document, puisqu'un groupe n'a qu'un visiteur. Le $lookup est placé après le $group : il ne cherche que 2 visiteurs au lieu de 4 fiches.

Dans l'autre sens, $lookup répond à « combien de fiches a chaque visiteur, y compris ceux qui n'en ont aucune » :

js
db.visiteurs.aggregate([
  { $match: { role: 'visiteur' } },
  { $lookup: { from: 'fiches', localField: '_id', foreignField: 'visiteur', as: 'fiches' } },
  { $project: { _id: 0, nom: 1, nbFiches: { $size: '$fiches' } } },
  { $sort: { nbFiches: -1, nom: 1 } }
])
// [ { nom: 'Girard', nbFiches: 3 }, { nom: 'Martin', nbFiches: 3 },
//   { nom: 'Benali', nbFiches: 2 }, { nom: 'Roux', nbFiches: 1 } ]

Un $lookup peut aussi recevoir un pipeline qui filtre et met en forme les documents joints :

js
// Pour chaque visiteur, les mois de ses fiches clôturées, à valider par le comptable
db.visiteurs.aggregate([
  { $match: { role: 'visiteur' } },
  { $lookup: {
      from: 'fiches', localField: '_id', foreignField: 'visiteur', as: 'aTraiter',
      pipeline: [ { $match: { etat: 'CL' } }, { $project: { _id: 0, mois: 1 } } ]
  } },
  { $project: { _id: 0, nom: 1, aTraiter: 1 } },
  { $sort: { nom: 1 } }
])
// Benali [ { mois: '2026-09' } ], Girard [ { mois: '2026-10' } ], Martin [], Roux []

$facet, $bucket et $count : les tableaux de bord

$facet lance plusieurs sous-pipelines sur les mêmes documents et rassemble leurs résultats dans un seul document : un tableau de bord en un seul appel.

js
db.fiches.aggregate([
  { $facet: {
      parEtat: [ { $group: { _id: '$etat', nb: { $sum: 1 } } }, { $sort: { _id: 1 } } ],
      horsForfait: [
        { $unwind: '$fraisHorsForfait' },
        { $group: { _id: null, total: { $sum: '$fraisHorsForfait.montant' }, nb: { $sum: 1 } } },
        { $project: { _id: 0 } }
      ]
  } }
])
js
[
  {
    parEtat: [
      { _id: 'CL', nb: 2 }, { _id: 'CR', nb: 3 }, { _id: 'MP', nb: 1 },
      { _id: 'RB', nb: 1 }, { _id: 'VA', nb: 2 }
    ],
    horsForfait: [ { total: 223.9, nb: 4 } ]
  }
]

$bucket répartit les documents en tranches de valeurs, pour un histogramme ; $count compte les documents qui sortent du pipeline :

js
db.fiches.aggregate([
  { $match: { montantValide: { $ne: null } } },
  { $bucket: { groupBy: '$montantValide', boundaries: [0, 300, 600, 1000],
               output: { nb: { $sum: 1 }, mois: { $push: '$mois' } } } }
])
// [ { _id: 0, nb: 1, mois: [ '2026-08' ] },          de 0 à moins de 300 €
//   { _id: 300, nb: 2, mois: [ '2026-08', '2026-09' ] }, de 300 à moins de 600 €
//   { _id: 600, nb: 1, mois: [ '2026-09' ] } ]       de 600 à moins de 1 000 €

db.fiches.aggregate([ { $match: { etat: 'CR' } }, { $count: 'enCours' } ])
// [ { enCours: 3 } ]

Écrire le résultat : $out et $merge

Un tableau de bord lourd peut être calculé la nuit et rangé dans une collection, que l'application lit ensuite instantanément : c'est une vue matérialisée. $out remplace entièrement une collection par le résultat ; $merge fusionne le résultat dans une collection existante (en insérant ou en mettant à jour), ce qui permet de recalculer seulement le mois écoulé.

js
db.fiches.aggregate([
  { $match: { etat: { $in: ['VA', 'MP', 'RB'] } } },
  { $group: { _id: '$mois', total: { $sum: '$montantValide' }, nb: { $sum: 1 } } },
  { $merge: { into: 'statsMensuelles', whenMatched: 'replace', whenNotMatched: 'insert' } }
])

$out et $merge sont toujours la dernière étape du pipeline.

Mettre au point un pipeline

Un pipeline se construit étape par étape : écris la première, regarde le résultat, ajoute la suivante. L'onglet Aggregations de Compass montre le résultat de chaque étape à côté de son code, et exporte le pipeline terminé en JavaScript pour ton application. Quand le résultat est faux, retire les étapes en partant de la fin jusqu'à trouver celle qui déforme les documents.

Quelques limites à connaître : une étape dispose de 100 Mio de mémoire avant de devoir écrire sur le disque (ce que MongoDB fait automatiquement depuis la version 6.0, mais plus lentement), et le résultat d'un aggregate qui renvoie un seul document (après un $facet, par exemple) ne doit pas dépasser 16 Mio. MongoDB 9.0 ajoute un plafond de mémoire par opération (1 Gio, ou 20 % de la mémoire du serveur si c'est davantage) : un pipeline mal conçu échoue au lieu de saturer le serveur. Enfin, explain() fonctionne aussi sur un pipeline : db.fiches.explain('executionStats').aggregate([…]) montre si le $match de tête utilise un index.

Exercices

Les résultats attendus correspondent au jeu de données d'origine.

Exercice 1 sur 3 Statistiques simples Réussi À revoir

Écris les pipelines qui donnent :

  1. le nombre de visiteurs (pas la comptable) par région, avec la liste de leurs noms, de la région la plus peuplée à la moins peuplée ;
  2. le total et le nombre des frais hors forfait de chaque visiteur, avec son prénom et son nom dans un seul champ, du plus gros total au plus petit ;
  3. la moyenne et le maximum des kilomètres déclarés dans une ligne KM.
Voir la réponseMasquer la réponse de l'exercice 1
js
// 1.
db.visiteurs.aggregate([
  { $match: { role: 'visiteur' } },
  { $group: { _id: '$region', nb: { $sum: 1 }, noms: { $push: '$nom' } } },
  { $sort: { nb: -1, _id: 1 } }
])
// [ { _id: 'Rhône', nb: 2, noms: [ 'Martin', 'Girard' ] },
//   { _id: 'Isère', nb: 1, noms: [ 'Benali' ] },
//   { _id: 'Savoie', nb: 1, noms: [ 'Roux' ] } ]

// 2.
db.fiches.aggregate([
  { $unwind: '$fraisHorsForfait' },
  { $group: { _id: '$visiteur', total: { $sum: '$fraisHorsForfait.montant' }, nb: { $sum: 1 } } },
  { $lookup: { from: 'visiteurs', localField: '_id', foreignField: '_id', as: 'v' } },
  { $unwind: '$v' },
  { $project: { _id: 0, visiteur: { $concat: ['$v.prenom', ' ', '$v.nom'] }, total: 1, nb: 1 } },
  { $sort: { total: -1 } }
])
// [ { total: 168, nb: 2, visiteur: 'Inès Benali' },
//   { total: 32.4, nb: 1, visiteur: 'Thomas Girard' },
//   { total: 23.5, nb: 1, visiteur: 'Lucas Martin' } ]

// 3.
db.fiches.aggregate([
  { $unwind: '$lignesForfait' },
  { $match: { 'lignesForfait.code': 'KM' } },
  { $group: { _id: null, moyenne: { $avg: '$lignesForfait.quantite' }, max: { $max: '$lignesForfait.quantite' } } }
])
// [ { _id: null, moyenne: 295.625, max: 640 } ]

Pour la 3, les 8 lignes KM totalisent 2 365 km : 2 365 ÷ 8 = 295,625. Camille Roux n'a aucune ligne ; dans la 2, elle n'apparaît pas, faute de frais hors forfait.

Tu avais trouvé ?

Exercice 2 sur 3 Contrôler les montants validés Réussi À revoir

Le comptable veut vérifier que le montantValide de chaque fiche validée, mise en paiement ou remboursée correspond bien au total de ses frais (forfaitisés et hors forfait). Écris un pipeline qui affiche, pour ces fiches, le mois, le montant validé, le total recalculé et l'écart.

Voir la réponseMasquer la réponse de l'exercice 2
js
db.fiches.aggregate([
  { $match: { montantValide: { $ne: null } } },
  { $set: { calcule: { $round: [ { $add: [
      { $sum: { $map: { input: '$lignesForfait', as: 'l',
                        in: { $multiply: ['$$l.quantite', '$$l.montantUnitaire'] } } } },
      { $sum: '$fraisHorsForfait.montant' }
  ] }, 2 ] } } },
  { $project: { _id: 0, mois: 1, montantValide: 1, calcule: 1,
                ecart: { $round: [ { $subtract: ['$montantValide', '$calcule'] }, 2 ] } } }
])

Les quatre fiches ont un écart nul : 512,20 ; 898,90 ; 184,40 ; 562,40. Deux remarques : { $sum: '$fraisHorsForfait.montant' } additionne directement un tableau de valeurs (le chemin fraisHorsForfait.montant sur un tableau donne le tableau des montants), et vaut 0 pour un tableau vide ; l'arrondi est indispensable, sinon de minuscules écarts dus aux double apparaîtraient. Un $match: { ecart: { $ne: 0 } } final ne garderait que les anomalies.

Tu avais trouvé ?

Exercice 3 sur 3 Traduire du SQL Réussi À revoir

Traduis cette requête SQL en pipeline, sur les collections du cours :

sql
SELECT v.nom, COUNT(*) AS nb, SUM(f.montant_valide) AS total
FROM fiche_frais f
JOIN visiteur v ON v.id = f.id_visiteur
WHERE f.mois BETWEEN '2026-08' AND '2026-09'
GROUP BY v.id, v.nom
HAVING COUNT(*) >= 2
ORDER BY total DESC;
Voir la réponseMasquer la réponse de l'exercice 3
js
db.fiches.aggregate([
  { $match: { mois: { $gte: '2026-08', $lte: '2026-09' } } },        // WHERE
  { $group: { _id: '$visiteur', nb: { $sum: 1 },                      // GROUP BY
              total: { $sum: '$montantValide' } } },
  { $match: { nb: { $gte: 2 } } },                                     // HAVING
  { $lookup: { from: 'visiteurs', localField: '_id',                  // JOIN
               foreignField: '_id', as: 'v' } },
  { $unwind: '$v' },
  { $project: { _id: 0, nom: '$v.nom', nb: 1, total: 1 } },           // SELECT
  { $sort: { total: -1 } }                                             // ORDER BY
])
// [ { nb: 2, total: 1411.1, nom: 'Martin' },
//   { nb: 2, total: 746.8, nom: 'Girard' } ]

Inès Benali n'a qu'une fiche sur la période : le HAVING l'écarte. En SQL, SUM ignore les NULL ; $sum aussi (il les compte pour 0). Le $lookup est placé après le regroupement : on ne cherche que les deux visiteurs retenus.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Node.js et MongoDB : le driver officielRevoir le sommaire du cours