Chapitre 1519 minIntermédiaire
Exercices MongoDB corrigés, méthode pour l'E6 et aide-mémoire
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- enchaîner des requêtes MongoDB de difficulté croissante ;
- traiter un cas complet de modélisation et d'agrégation ;
- réaliser une évolution d'API en une heure, comme à l'E6 ;
- retrouver vite une commande et son équivalent SQL.
3 exercices corrigés
Ce dernier chapitre rassemble des exercices MongoDB corrigés de synthèse : une série de requêtes progressives sur le jeu de données d'AppliFrais, un cas complet de modélisation et d'agrégation, et une évolution d'application à réaliser en une heure, comme à l'E6 SLAM. Il se termine par la méthode à suivre le jour de l'épreuve et par un aide-mémoire des commandes MongoDB, avec leur équivalent SQL. Recharge le jeu de données avant de commencer.
Où MongoDB intervient au BTS SIO
| Épreuve | Ce qu'on peut te demander |
|---|---|
| E6 SLAM, Conception et développement d'applications | Si une de tes réalisations utilise MongoDB : justifier le choix d'une base orientée documents (« le type de base est pertinent »), expliquer ton modèle (imbrication, références, index), et pendant l'heure de réalisation, faire évoluer l'application : un champ, une règle de validation, une requête, une agrégation, un contrôle d'accès, un test (les évolutions types de l'E6) |
| E7 SLAM, Cybersécurité des services informatiques | Les sujets portent surtout sur SQL ; un extrait de code d'API Node.js peut contenir une injection NoSQL, une affectation de masse ou un contrôle d'accès manquant, à repérer et à corriger (sécuriser du code à l'E7) |
| E5, Support et mise à disposition de services | La sauvegarde de tes bases et les tests de restauration, MongoDB compris, dans ton portfolio |
Série de requêtes progressives
Exercice 1 sur 3 Dix requêtes sur AppliFrais Réussi À revoir
Écris chaque requête dans mongosh et donne le résultat attendu sur le jeu de données d'origine.
- Le nom et le prénom du visiteur de matricule
c087. - Le nombre de fiches du mois d'octobre 2026.
- Le nombre de fiches validées, mises en paiement ou remboursées de plus de 500 €.
- Le nombre de fiches qui contiennent un frais hors forfait de plus de 100 €.
- Le nombre de lignes de repas (
REP) et la quantité totale de repas, toutes fiches confondues. - Pour chaque mois, le nombre de fiches et le nombre de fiches encore en cours, par mois croissant.
- Le nom du visiteur qui a déclaré le plus de kilomètres, et ce total.
- Valider la fiche d'octobre de Thomas Girard (
ObjectId('6a0000000000000000000003')) avec un montant de 665,60 €, seulement si elle est clôturée. - Créer l'index qui garantit qu'une adresse électronique ne sert qu'à un visiteur.
- Supprimer les fiches en cours qui n'ont aucune ligne de frais (forfaitisés ou hors forfait).
Voir la réponseMasquer la réponse de l'exercice 1
// 1. { nom: 'Girard', prenom: 'Thomas' }
db.visiteurs.findOne({ matricule: 'c087' }, { _id: 0, nom: 1, prenom: 1 })
// 2. 4
db.fiches.countDocuments({ mois: '2026-10' })
// 3. 3 (512,20 ; 898,90 ; 562,40)
db.fiches.countDocuments({ etat: { $in: ['VA', 'MP', 'RB'] }, montantValide: { $gt: 500 } })
// 4. 1 (le congrès d'Inès Benali, 150 €)
db.fiches.countDocuments({ 'fraisHorsForfait.montant': { $gt: 100 } })
// 5. [ { _id: null, lignes: 6, quantite: 24 } ]
db.fiches.aggregate([
{ $unwind: '$lignesForfait' },
{ $match: { 'lignesForfait.code': 'REP' } },
{ $group: { _id: null, lignes: { $sum: 1 }, quantite: { $sum: '$lignesForfait.quantite' } } }
])
// 6. [ { _id: '2026-08', nb: 2, enCours: 0 }, { _id: '2026-09', nb: 3, enCours: 0 },
// { _id: '2026-10', nb: 4, enCours: 3 } ]
db.fiches.aggregate([
{ $group: { _id: '$mois', nb: { $sum: 1 },
enCours: { $sum: { $cond: [ { $eq: ['$etat', 'CR'] }, 1, 0 ] } } } },
{ $sort: { _id: 1 } }
])
// 7. [ { km: 880, nom: 'Martin' } ] (310 + 420 + 150)
db.fiches.aggregate([
{ $unwind: '$lignesForfait' },
{ $match: { 'lignesForfait.code': 'KM' } },
{ $group: { _id: '$visiteur', km: { $sum: '$lignesForfait.quantite' } } },
{ $sort: { km: -1 } },
{ $limit: 1 },
{ $lookup: { from: 'visiteurs', localField: '_id', foreignField: '_id', as: 'v' } },
{ $project: { _id: 0, nom: { $first: '$v.nom' }, km: 1 } }
])
// 8. matchedCount: 1, modifiedCount: 1
db.fiches.updateOne(
{ visiteur: ObjectId('6a0000000000000000000003'), mois: '2026-10', etat: 'CL' },
{ $set: { etat: 'VA', montantValide: 665.6 }, $currentDate: { dateModif: true } }
)
// 9.
db.visiteurs.createIndex({ email: 1 }, { unique: true })
// 10. deletedCount: 1 (la fiche d'octobre de Camille Roux)
db.fiches.deleteMany({ etat: 'CR', lignesForfait: { $size: 0 }, fraisHorsForfait: { $size: 0 } })
Pour la 7, le $lookup est placé après $limit : on ne cherche qu'un visiteur. { $first: '$v.nom' } prend le premier élément du tableau produit par $lookup, sans $unwind. Pour la 10, lance d'abord le même filtre avec find pour voir ce qui sera supprimé.
Tu avais trouvé ?
Cas de synthèse : les visites médicales
Exercice 2 sur 3 Modéliser et interroger les visites Réussi À revoir
Le laboratoire GSB veut suivre les visites de ses visiteurs chez les médecins. Un médecin a un nom, une spécialité et une ville. Lors d'une visite, à une date donnée, le visiteur présente un ou plusieurs produits et laisse pour chacun quelques échantillons (souvent 1 à 3 produits par visite). On veut afficher : l'agenda des visites d'un visiteur avec le nom du médecin ; le nombre de visites par spécialité ; le nombre d'échantillons distribués par produit ; les médecins jamais visités.
- Propose les collections, un document d'exemple pour une visite, et justifie tes choix.
- Avec ce jeu de données, écris les quatre requêtes et donne leur résultat.
db.medecins.insertMany([
{ _id: 1, nom: 'Dr Perrin', specialite: 'cardiologie', ville: 'Lyon' },
{ _id: 2, nom: 'Dr Moreau', specialite: 'médecine générale', ville: 'Villeurbanne' },
{ _id: 3, nom: 'Dr Fabre', specialite: 'pneumologie', ville: 'Grenoble' },
{ _id: 4, nom: 'Dr Chevalier', specialite: 'médecine générale', ville: 'Grenoble' },
{ _id: 5, nom: 'Dr Leroy', specialite: 'cardiologie', ville: 'Chambéry' }
]);
const martin = ObjectId('6a0000000000000000000001');
const benali = ObjectId('6a0000000000000000000002');
const girard = ObjectId('6a0000000000000000000003');
db.visites.insertMany([
{ visiteur: martin, medecin: 1, date: ISODate('2026-10-06T09:00:00Z'),
produits: [{ code: 'CARDIOVEX', echantillons: 3 }, { code: 'DOLINOR', echantillons: 2 }] },
{ visiteur: martin, medecin: 2, date: ISODate('2026-10-14T14:30:00Z'),
produits: [{ code: 'DOLINOR', echantillons: 5 }] },
{ visiteur: girard, medecin: 2, date: ISODate('2026-10-20T10:00:00Z'),
produits: [{ code: 'RESPIFLUX', echantillons: 2 }] },
{ visiteur: benali, medecin: 3, date: ISODate('2026-10-02T11:00:00Z'),
produits: [{ code: 'RESPIFLUX', echantillons: 4 }, { code: 'DOLINOR', echantillons: 1 }] },
{ visiteur: benali, medecin: 4, date: ISODate('2026-10-22T16:00:00Z'), produits: [] },
{ visiteur: martin, medecin: 1, date: ISODate('2026-09-15T09:30:00Z'),
produits: [{ code: 'CARDIOVEX', echantillons: 2 }] }
]);
Les noms de produits sont fictifs.
Voir la réponseMasquer la réponse de l'exercice 2
1. Le modèle. Trois collections : visiteurs (existante), medecins et visites.
- Une visite relie un visiteur et un médecin, et porte ses propres données (date, produits) : c'est une entité à part entière, en nombre non borné. Elle référence le visiteur et le médecin (comme une table d'association qui porte des données).
- Les produits présentés sont imbriqués dans la visite : ils n'existent pas sans elle, sont lus avec elle et sont peu nombreux.
- Le médecin n'est pas recopié dans la visite : il est partagé par de nombreuses visites. Si l'agenda est très consulté, on peut recopier son nom (référence étendue), au prix d'une mise à jour s'il change.
{ visiteur: ObjectId('6a0000000000000000000001'), medecin: 1, date: ISODate('2026-10-06T09:00:00Z'),
produits: [ { code: 'CARDIOVEX', echantillons: 3 }, { code: 'DOLINOR', echantillons: 2 } ] }
Index utiles : { visiteur: 1, date: 1 } pour l'agenda d'un visiteur, { medecin: 1 } pour le $lookup depuis les médecins.
2. Les requêtes.
// Agenda d'octobre de Lucas Martin, avec le médecin
db.visites.aggregate([
{ $match: { visiteur: martin, date: { $gte: ISODate('2026-10-01'), $lt: ISODate('2026-11-01') } } },
{ $sort: { date: 1 } },
{ $lookup: { from: 'medecins', localField: 'medecin', foreignField: '_id', as: 'm' } },
{ $unwind: '$m' },
{ $project: { _id: 0, date: 1, medecin: '$m.nom', ville: '$m.ville' } }
])
// [ { date: ISODate('2026-10-06T09:00:00.000Z'), medecin: 'Dr Perrin', ville: 'Lyon' },
// { date: ISODate('2026-10-14T14:30:00.000Z'), medecin: 'Dr Moreau', ville: 'Villeurbanne' } ]
// Visites par spécialité
db.visites.aggregate([
{ $lookup: { from: 'medecins', localField: 'medecin', foreignField: '_id', as: 'm' } },
{ $unwind: '$m' },
{ $group: { _id: '$m.specialite', nb: { $sum: 1 } } },
{ $sort: { nb: -1, _id: 1 } }
])
// [ { _id: 'médecine générale', nb: 3 }, { _id: 'cardiologie', nb: 2 }, { _id: 'pneumologie', nb: 1 } ]
// Échantillons par produit
db.visites.aggregate([
{ $unwind: '$produits' },
{ $group: { _id: '$produits.code', echantillons: { $sum: '$produits.echantillons' } } },
{ $sort: { echantillons: -1, _id: 1 } }
])
// [ { _id: 'DOLINOR', echantillons: 8 }, { _id: 'RESPIFLUX', echantillons: 6 },
// { _id: 'CARDIOVEX', echantillons: 5 } ]
// Médecins jamais visités
db.medecins.aggregate([
{ $lookup: { from: 'visites', localField: '_id', foreignField: 'medecin', as: 'v' } },
{ $match: { v: { $size: 0 } } },
{ $project: { _id: 0, nom: 1, ville: 1 } }
])
// [ { nom: 'Dr Leroy', ville: 'Chambéry' } ]
La visite sans produit (Inès Benali chez le Dr Chevalier) compte dans les visites par spécialité, mais disparaît au $unwind des échantillons, ce qui est juste ici. Pour les médecins jamais visités, une autre écriture : db.medecins.find({ _id: { $nin: db.visites.distinct('medecin') } }), efficace tant que la liste des médecins visités reste courte.
Tu avais trouvé ?
Évolution en une heure, comme à l'E6
Exercice 3 sur 3 Plafonner les frais kilométriques Réussi À revoir
La direction d'AppliFrais demande une évolution de l'API du chapitre 12 : une fiche ne peut pas dépasser 2 000 km par mois. Une saisie qui dépasse ce plafond doit être refusée avec un message clair. Le comptable veut aussi une route qui liste, pour un mois donné, les visiteurs dont le kilométrage dépasse 1 500 km, pour les contrôler. Décris les modifications, fichier par fichier, et écris le code. Quels tests ajoutes-tu ?
Voir la réponseMasquer la réponse de l'exercice 3
Le plafond, dans le schéma Zod de la route (schemas/fiches.schemas.js) : le corps { ETP, KM, NUI, REP } n'a qu'un champ KM, il suffit de lui donner un maximum avec son message.
export const PLAFOND_KM = 2000;
export const schemaLignesForfait = z.strictObject({
ETP: quantite,
KM: quantite.max(PLAFOND_KM, `Les frais kilométriques sont plafonnés à ${PLAFOND_KM} km par mois`),
NUI: quantite,
REP: quantite,
});
Une saisie de 2 001 km donne 422 avec {"champ":"KM","message":"Les frais kilométriques sont plafonnés à 2000 km par mois"}.
Et dans le modèle, pour qu'aucun autre chemin d'écriture ne le contourne (models/Fiche.js) : un validateur sur le tableau, appliqué par save() et, grâce à runValidators: true, par le findOneAndUpdate du contrôleur.
lignesForfait: {
type: [ligneForfaitSchema],
default: [],
validate: {
validator: (lignes) => lignes.filter((l) => l.code === 'KM').reduce((t, l) => t + l.quantite, 0) <= 2000,
message: 'Les frais kilométriques sont plafonnés à 2000 km par mois',
},
},
La route de contrôle (routes/fiches.routes.js et le contrôleur), réservée au comptable, déclarée avant /:id :
routeurFiches.get('/controle-km', exigerRole('comptable'), valider({ query: schemaStatistiques }), fiches.controleKm);
export async function controleKm(req, res) {
const resultat = await Fiche.aggregate([
{ $match: { mois: req.valide.query.mois } },
{ $unwind: '$lignesForfait' },
{ $match: { 'lignesForfait.code': 'KM', 'lignesForfait.quantite': { $gt: 1500 } } },
{ $lookup: { from: 'visiteurs', localField: 'visiteur', foreignField: '_id', as: 'v' } },
{ $project: { _id: 0, fiche: '$_id', km: '$lignesForfait.quantite',
visiteur: { $concat: [{ $first: '$v.prenom' }, ' ', { $first: '$v.nom' }] } } },
{ $sort: { km: -1 } },
]);
res.json(resultat);
}
Le contrôleur remplacerLignes ne crée qu'une ligne KM : la somme du validateur du modèle reste juste si un autre chemin d'écriture en ajoutait plusieurs. Le $match de tête porte sur mois : il réduit les fiches avant le $unwind. Les opérateurs du pipeline ne sont pas concernés par sanitizeFilter.
Tests (test/fiches.test.js) : une saisie de 2 000 km acceptée (200) ; une saisie de 2 001 km refusée (422, avec le message) ; la route /controle-km refusée à un visiteur (403) ; pour le comptable, une fiche de 1 600 km listée et une de 1 500 km absente (la borne est exclue). Pense aussi à non-régression : les trois tests existants doivent toujours passer.
Devant la commission, montre la règle dans les deux couches (Zod pour un message propre à l'API, Mongoose pour l'intégrité), les tests qui passent, et le commit qui regroupe l'évolution.
Tu avais trouvé ?
La méthode pour une question MongoDB
Que ce soit pendant l'heure de réalisation de l'E6 ou en entraînement :
- Lis le besoin et reformule-le en une phrase : quelles données, quel filtre, quel résultat ?
- Repère la forme des documents :
findOnesur la collection, ou l'onglet Schema de Compass. Note les champs imbriqués, les tableaux et les types (ObjectId, dates, nombres). - Choisis l'outil :
findpour lire des documents tels quels ;aggregatedès qu'il faut regrouper, calculer, dérouler un tableau ou joindre ;updateOne/updateManyavec des opérateurs$pour modifier. - Écris et teste étape par étape : le filtre seul d'abord, puis la projection, puis le tri ; un pipeline étape par étape.
- Vérifie le résultat à la main sur deux ou trois documents : un total, un compte.
- Pense aux index : la requête est-elle servie par un index (
explain) ? Une règle d'unicité est-elle garantie par un index unique ? - Sécurise : dans l'API, le filtre contient-il le propriétaire ? Les entrées sont-elles validées ? Les montants viennent-ils du serveur ?
Aide-mémoire : les commandes MongoDB et leur équivalent SQL
| Besoin | MongoDB (mongosh) | SQL |
|---|---|---|
| Choisir la base | use appli_frais | USE appli_frais; |
| Lister les collections | show collections | SHOW TABLES; |
| Insérer | db.fiches.insertOne({ … }), insertMany([…]) | INSERT INTO … VALUES … |
| Lire tout | db.fiches.find() | SELECT * FROM fiches; |
| Filtrer | find({ etat: 'CL', mois: '2026-10' }) | WHERE etat = 'CL' AND mois = '2026-10' |
| Comparer | { montantValide: { $gt: 500 } } | montantValide > 500 |
| Liste de valeurs | { etat: { $in: ['VA', 'MP'] } } | etat IN ('VA', 'MP') |
| OU | { $or: [ { … }, { … } ] } | … OR … |
| Champ absent ou nul | { region: null }, { region: { $exists: false } } | region IS NULL |
| Motif | { nom: /^Mar/ } | nom LIKE 'Mar%' |
| Élément de tableau | { 'lignesForfait.code': 'KM' }, $elemMatch | Jointure sur la table des lignes |
| Choisir les champs | find(filtre, { mois: 1, etat: 1, _id: 0 }) | SELECT mois, etat |
| Trier, paginer | .sort({ mois: -1 }).skip(20).limit(10) | ORDER BY mois DESC LIMIT 10 OFFSET 20 |
| Compter | countDocuments({ etat: 'CR' }) | SELECT COUNT(*) … WHERE etat = 'CR' |
| Valeurs distinctes | distinct('etat') | SELECT DISTINCT etat |
| Modifier | updateOne(filtre, { $set: { etat: 'CL' } }), updateMany | UPDATE … SET … WHERE … |
| Incrémenter | { $inc: { compteur: 1 } } | SET compteur = compteur + 1 |
| Ajouter à un tableau | { $push: { lignesForfait: { … } } } | INSERT INTO ligne_frais … |
| Insérer ou modifier | updateOne(filtre, modif, { upsert: true }) | INSERT … ON DUPLICATE KEY UPDATE |
| Supprimer | deleteOne(filtre), deleteMany(filtre) | DELETE FROM … WHERE … |
| Regrouper | aggregate([ { $group: { _id: '$etat', nb: { $sum: 1 } } } ]) | GROUP BY etat avec COUNT(*) |
| Filtrer les groupes | $match après $group | HAVING |
| Joindre | $lookup (agrégation), populate (Mongoose) | LEFT JOIN |
| Créer un index | createIndex({ visiteur: 1, mois: 1 }, { unique: true }) | CREATE UNIQUE INDEX … |
| Plan d'exécution | .explain('executionStats') | EXPLAIN |
| Transaction | session.withTransaction(async () => { … }) | START TRANSACTION … COMMIT |
| Compte et droits | db.createUser({ user, pwd, roles: [ … ] }) | CREATE USER … ; GRANT … |
| Sauvegarder, restaurer | mongodump, mongorestore | mysqldump, mysql < fichier.sql |
| Erreur | Signification |
|---|---|
E11000 duplicate key error (code 11000) | Un index unique est violé : doublon |
Document failed validation (code 121) | Le validateur $jsonSchema refuse le document |
Update document requires atomic operators | Il manque $set (ou un autre opérateur) dans une mise à jour |
Cannot do exclusion on field … in inclusion projection | Projection qui mélange 1 et 0 |
Cast to ObjectId failed for value "…" (Mongoose) | Identifiant mal formé |
ValidationError (Mongoose) | Un ou plusieurs champs violent le schéma : voir err.errors |
MissingSchemaError (Mongoose) | populate vers un modèle jamais chargé |
MongoServerSelectionError | Serveur injoignable : arrêté, mauvais port, adresse IP non autorisée (Atlas) |
Authentication failed | Identifiants faux, ou mauvaise base d'authentification (authSource) |
not authorized on … to execute command | Le compte n'a pas le droit : rôle à revoir |
Transaction numbers are only allowed on a replica set member or mongos | Transaction sur un serveur isolé : il faut un replica set |