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
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.
[
{ _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 €.
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égation | Rôle |
|---|---|---|
WHERE | $match | Filtrer 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… | $group | Regrouper et calculer par groupe |
HAVING | $match placé après $group | Filtrer les groupes |
ORDER BY | $sort | Trier |
LIMIT, OFFSET | $limit, $skip | Paginer |
LEFT JOIN | $lookup | Joindre une autre collection |
| (pas d'équivalent direct) | $unwind | Faire un document par élément d'un tableau |
COUNT(*) | $count | Compter 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.
// 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.
// Le nombre de fiches par état
db.fiches.aggregate([
{ $group: { _id: '$etat', nb: { $sum: 1 } } },
{ $sort: { nb: -1, _id: 1 } }
])
[
{ _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.
| Accumulateur | Calcule, pour chaque groupe |
|---|---|
$sum | La somme d'un champ, ou le nombre de documents avec $sum: 1 |
$avg | La moyenne |
$min, $max | La plus petite, la plus grande valeur |
$first, $last | La valeur du premier, du dernier document du groupe (après un $sort) |
$push | Le tableau de toutes les valeurs |
$addToSet | Le tableau des valeurs distinctes (dans un ordre quelconque) |
$count | Le 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 :
// 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.
// 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 } }
])
[
{ 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 :
| Famille | Opé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 |
// 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.
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 } } ]
// 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 } }
])
[
{ _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ètre | Rôle | Pour joindre fiches et visiteurs |
|---|---|---|
from | La collection à joindre | 'visiteurs' |
localField | Le champ du document courant | 'visiteur' (dans la fiche) |
foreignField | Le champ de l'autre collection | '_id' (du visiteur) |
as | Le nom du tableau ajouté | 'visiteur' (on remplace la référence) |
// 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 } }
])
[
{ 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 » :
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 :
// 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.
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 } }
]
} }
])
[
{
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 :
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é.
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 :
- 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 ;
- 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 ;
- 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
// 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
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 :
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
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é ?