Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 1021 minIntermédiaire

Mongoose : schéma, validation, middleware et populate

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

À la fin de ce chapitre, tu sauras

  • écrire un schéma et un modèle Mongoose ;
  • valider les données et traiter les erreurs de validation ;
  • placer une règle dans un hook pre ou post ;
  • remplacer une référence par son document avec populate.

3 exercices corrigés

Mongoose est une bibliothèque Node.js qui ajoute à MongoDB ce que la base n'impose pas : des schémas qui décrivent la forme des documents, la validation des données avant l'enregistrement, des valeurs par défaut, des hooks (middleware) exécutés avant ou après une opération, et populate pour remplacer une référence par le document référencé. C'est un ODM (Object Document Mapper), l'équivalent pour MongoDB d'un ORM comme Doctrine ou Entity Framework. Ce chapitre écrit les modèles d'AppliFrais avec Mongoose 9, la version actuelle, et passe en revue le CRUD, les erreurs à traiter et les pièges classiques.

Installer et se connecter

bash
npm install mongoose     # Mongoose 9, construit sur le driver mongodb 7
js
import mongoose from 'mongoose';

await mongoose.connect(process.env.MONGODB_URI, { serverSelectionTimeoutMS: 5000 });
console.log(`Connecté à ${mongoose.connection.name}`);   // Connecté à appli_frais

Comme pour le driver, on se connecte une fois au démarrage : mongoose.connect crée le pool de connexions que tous les modèles partagent. Le chapitre 12 montre où placer cet appel dans une API.

Le schéma et le modèle

Un schéma décrit les champs d'un document, leur type et leurs règles. Un modèle, créé à partir du schéma, est la classe qui lit et écrit dans la collection.

js
// src/models/Visiteur.js
import mongoose from 'mongoose';

const visiteurSchema = new mongoose.Schema({
  matricule: { type: String, required: true, unique: true, match: /^[a-z]\d{3}$/ },
  nom: { type: String, required: true, trim: true },
  prenom: { type: String, required: true, trim: true },
  email: { type: String, required: true, unique: true, lowercase: true, trim: true },
  role: { type: String, enum: ['visiteur', 'comptable'], default: 'visiteur' },
  region: String,
  dateEmbauche: Date,
});

export const Visiteur = mongoose.model('Visiteur', visiteurSchema);

Le nom du modèle, 'Visiteur', donne le nom de la collection : Mongoose le met en minuscules et au pluriel, visiteurs. Pour imposer un autre nom, passe-le en troisième argument : mongoose.model('Visiteur', visiteurSchema, 'utilisateurs').

Option d'un champEffetExemple
typeLe type : String, Number, Date, Boolean, mongoose.Schema.Types.ObjectId, [String]…{ type: Date }
requiredChamp obligatoirerequired: [true, 'Le nom est obligatoire']
defaultValeur si le champ est absentdefault: 'CR', default: Date.now
enumListe des valeurs permisesenum: ['CR', 'CL', 'VA', 'MP', 'RB']
min, maxBornes d'un nombre ou d'une datemin: 0
minlength, maxlength, matchLongueur et motif d'une chaînematch: /^\d{4}-(0[1-9]|1[0-2])$/
trim, lowercase, uppercaseNettoient la chaîne avant l'enregistrementlowercase: true pour une adresse électronique
uniqueCrée un index unique (ce n'est pas un validateur)unique: true
select: falseChamp exclu des lectures, sauf demande explicitePour une empreinte de mot de passe
refLe modèle visé par une référence, pour populateref: 'Visiteur'

Et le modèle de la fiche, avec ses sous-documents :

js
// src/models/Fiche.js
import mongoose from 'mongoose';
import './Visiteur.js';        // enregistre le modèle Visiteur, nécessaire à populate('visiteur')

export const ETATS = ['CR', 'CL', 'VA', 'MP', 'RB'];
export const FORFAITS = { ETP: 110, KM: 0.62, NUI: 80, REP: 25 };

const ligneForfaitSchema = new mongoose.Schema(
  {
    code: { type: String, enum: Object.keys(FORFAITS), required: true },
    quantite: {
      type: Number, required: true, min: 0,
      validate: { validator: Number.isInteger, message: 'La quantité doit être un entier' },
    },
    montantUnitaire: { type: Number, required: true, min: 0 },
  },
  { _id: false },           // pas d'_id pour chaque ligne : le code suffit à la désigner
);

const fraisHorsForfaitSchema = new mongoose.Schema({
  date: { type: Date, required: true },
  libelle: { type: String, required: true, trim: true, maxlength: 100 },
  montant: { type: Number, required: true, min: 0 },
});

const ficheSchema = new mongoose.Schema(
  {
    visiteur: { type: mongoose.Schema.Types.ObjectId, ref: 'Visiteur', required: true },
    mois: { type: String, required: true, match: [/^\d{4}-(0[1-9]|1[0-2])$/, 'Le mois doit être au format AAAA-MM'] },
    etat: { type: String, enum: ETATS, default: 'CR' },
    lignesForfait: { type: [ligneForfaitSchema], default: [] },
    fraisHorsForfait: { type: [fraisHorsForfaitSchema], default: [] },
    montantValide: { type: Number, default: null, min: 0 },
  },
  { timestamps: true },     // ajoute createdAt et updatedAt, tenus à jour par Mongoose
);

ficheSchema.index({ visiteur: 1, mois: 1 }, { unique: true });   // une fiche par visiteur et par mois
ficheSchema.index({ etat: 1, mois: -1 });                        // la liste du comptable

export const Fiche = mongoose.model('Fiche', ficheSchema);

Mongoose crée les index déclarés au démarrage de l'application (option autoIndex, active par défaut). En production, sur de grosses collections, on préfère souvent la désactiver et créer les index par un script de déploiement, car la création d'un index sollicite le serveur.

Le CRUD avec Mongoose

js
// Créer : create valide puis enregistre (ou new Fiche({...}) puis await fiche.save())
const fiche = await Fiche.create({ visiteur: idLucas, mois: '2026-11' });
// etat vaut 'CR', les tableaux sont vides, montantValide vaut null : les valeurs par défaut

// Lire
await Fiche.find({ etat: 'CL' }).sort({ mois: -1 }).limit(20);
await Fiche.findById('6ae8a9d8fc828abd94f81c6f');           // la chaîne est convertie en ObjectId
await Fiche.findOne({ visiteur: idLucas, mois: '2026-10' });
await Visiteur.findById(idLucas).select('nom prenom');      // projection : les champs séparés par des espaces
await Fiche.countDocuments({ etat: 'CR' });
await Fiche.exists({ visiteur: idLucas, mois: '2026-10' }); // { _id: … } ou null

// Modifier
await Fiche.updateOne({ _id: id, etat: 'CR' }, { $set: { etat: 'CL' } }, { runValidators: true });
await Fiche.findByIdAndUpdate(id, { $set: { etat: 'CL' } }, { returnDocument: 'after', runValidators: true });

// Ou charger, modifier l'objet, enregistrer (validation et hooks de save inclus)
const f = await Fiche.findById(id);
f.lignesForfait.push({ code: 'REP', quantite: 2, montantUnitaire: 25 });
await f.save();

// Supprimer
await Fiche.deleteOne({ _id: id, etat: 'CR' });
await Fiche.findByIdAndDelete(id);                          // renvoie le document supprimé, ou null

Mongoose convertit les valeurs des filtres d'après le schéma (casting) : Fiche.find({ visiteur: '6a0000000000000000000001' }) trouve bien les 3 fiches de Lucas Martin, la chaîne étant transformée en ObjectId. C'est un gros confort par rapport au driver.

La validation et ses erreurs

À chaque save() ou create(), Mongoose vérifie tous les champs et rassemble les erreurs dans une ValidationError :

js
try {
  await Fiche.create({
    visiteur: idLucas, mois: '2026-13', etat: 'XX',
    lignesForfait: [{ code: 'KM', quantite: -3, montantUnitaire: 0.62 }],
  });
} catch (err) {
  console.log(err.name);                      // ValidationError
  for (const [chemin, e] of Object.entries(err.errors)) console.log(chemin, '→', e.message);
}
text
ValidationError
lignesForfait.0.quantite → Path `quantite` (-3) is less than minimum allowed value (0).
mois → Le mois doit être au format AAAA-MM
etat → `XX` is not a valid enum value for path `etat`.

err.errors contient une entrée par champ en faute, avec son chemin (lignesForfait.0.quantite) : c'est ce que l'API transforme en réponse 422 champ par champ. Les messages par défaut sont en anglais ; donne les tiens dans le schéma (match: [regex, 'message'], required: [true, 'message'], enum: { values: […], message: '…' }).

Trois autres erreurs reviennent souvent :

ErreurCauseRéponse HTTP habituelle
CastErrorUne valeur impossible à convertir : Fiche.findById('123') donne Cast to ObjectId failed for value "123"400 (ou 404 pour un identifiant)
MongoServerError, code 11000Un index unique violé : une deuxième fiche pour le même mois409 Conflict
MissingSchemaErrorpopulate('visiteur') alors que le modèle Visiteur n'a jamais été chargé500 : une erreur de programmation

Pour une règle qui ne tient pas dans les options, écris un validateur personnalisé : une fonction qui renvoie true si la valeur est acceptable, comme Number.isInteger pour la quantité ci-dessus.

Les hooks : pre et post

Un hook (ou middleware Mongoose) est une fonction que Mongoose exécute avant (pre) ou après (post) une opération : validate, save, deleteOne, findOneAndUpdate… C'est la place des règles qui doivent s'appliquer à chaque enregistrement, quel que soit le contrôleur qui l'a demandé.

js
// Avant chaque enregistrement : une fiche en cours n'a pas de montant validé
ficheSchema.pre('save', function () {
  if (this.etat === 'CR' && this.montantValide !== null) {
    throw new Error('Une fiche en cours ne peut pas avoir de montant validé');
  }
});

// Après chaque enregistrement : journaliser
ficheSchema.post('save', function (fiche) {
  console.log(`Fiche ${fiche.mois} enregistrée (état ${fiche.etat})`);
});

Dans un hook de document (save, validate), this est le document : écris une function classique, pas une fonction fléchée, qui n'a pas son propre this. Pour refuser l'opération, lance une erreur (ou rends une promesse rejetée, dans une fonction async) : elle arrive dans le catch de l'appelant.

L'usage le plus connu des hooks est le hachage du mot de passe dans un pre('save'), quand le champ a été modifié (this.isModified('mdp')). Le principe et le choix de l'algorithme (Argon2id, bcrypt) sont expliqués dans le cours de cryptographie, et la mise en œuvre dans le cours Node.js (argon2 et JWT).

Les champs virtuels

Un virtuel est un champ calculé, défini dans le schéma mais jamais enregistré en base :

js
ficheSchema.virtual('totalForfait').get(function () {
  const total = this.lignesForfait.reduce((t, l) => t + l.quantite * l.montantUnitaire, 0);
  return Math.round(total * 100) / 100;
});

const fiche = await Fiche.findOne({ visiteur: idLucas, mois: '2026-10' });
fiche.totalForfait;   // 203 : 1 × 110 + 150 × 0,62

Par défaut, les virtuels n'apparaissent pas dans JSON.stringify, donc pas dans les réponses de l'API ; ajoute l'option { toJSON: { virtuals: true } } au schéma pour les inclure. Un virtuel n'existe que dans l'application : on ne peut ni filtrer ni trier dessus dans une requête. Si le total sert de critère de recherche, stocke-le (modèle de la valeur calculée, chapitre 6) ou calcule-le dans une agrégation.

populate : remplacer une référence par le document

populate remplace un champ qui contient un ObjectId par le document qu'il référence, lu dans la collection désignée par l'option ref du schéma :

js
const fiche = await Fiche.findOne({ mois: '2026-10', etat: 'CR' })
  .populate('visiteur', 'nom prenom -_id');      // seulement ces champs du visiteur
fiche.visiteur;   // { nom: 'Martin', prenom: 'Lucas' } au lieu de ObjectId('6a00…0001')

Le deuxième argument est une projection sur le document référencé : ne charge que ce que tu affiches. Pour plusieurs références, ou des options, passe un objet : populate({ path: 'visiteur', select: 'nom prenom', match: { role: 'visiteur' } }).

Une relation dans l'autre sens (« les fiches d'un visiteur ») se déclare avec un virtuel de population, sans rien stocker dans le visiteur :

js
visiteurSchema.virtual('fiches', {
  ref: 'Fiche',                // le modèle à interroger
  localField: '_id',           // le champ du visiteur…
  foreignField: 'visiteur',    // …égal à ce champ de la fiche
});

const lucas = await Visiteur.findOne({ matricule: 'a131' })
  .populate({ path: 'fiches', select: 'mois etat -visiteur', options: { sort: { mois: -1 } } });
lucas.fiches;   // ses 3 fiches, de la plus récente à la plus ancienne

lean() : des objets simples pour les lectures

Une requête Mongoose renvoie des documents Mongoose : des objets riches, avec save(), les virtuels, le suivi des modifications. Ce confort a un coût en mémoire et en temps. Pour une lecture qu'on renvoie telle quelle au client, lean() demande des objets JavaScript simples :

js
const fiches = await Fiche.find({ etat: 'CR' })
  .select('mois etat -_id')
  .populate('visiteur', 'nom -_id')
  .lean();
// [ { visiteur: { nom: 'Martin' }, mois: '2026-10', etat: 'CR' }, … ]

Avec lean(), pas de save(), pas de virtuels, pas de getters : c'est le bon choix pour les listes et les détails en lecture seule d'une API. aggregate renvoie toujours des objets simples.

Exercices

Exercice 1 sur 3 Écrire un schéma Réussi À revoir

AppliFrais ajoute la gestion des véhicules de fonction. Écris le schéma et le modèle Mongoose Vehicule avec : une immatriculation obligatoire, unique, au format AB-123-CD (enregistrée en majuscules) ; un modèle obligatoire ; une date de mise en circulation ; un kilométrage entier, positif, à 0 par défaut ; un type d'énergie parmi essence, diesel, electrique, hybride ; une référence facultative au visiteur qui l'utilise ; les dates de création et de modification.

Voir la réponseMasquer la réponse de l'exercice 1
js
import mongoose from 'mongoose';

const vehiculeSchema = new mongoose.Schema(
  {
    immatriculation: {
      type: String, required: true, unique: true, uppercase: true, trim: true,
      match: [/^[A-Z]{2}-\d{3}-[A-Z]{2}$/, "L'immatriculation doit être au format AB-123-CD"],
    },
    modele: { type: String, required: true, trim: true },
    miseEnCirculation: Date,
    kilometrage: {
      type: Number, default: 0, min: 0,
      validate: { validator: Number.isInteger, message: 'Le kilométrage doit être un entier' },
    },
    energie: { type: String, enum: ['essence', 'diesel', 'electrique', 'hybride'] },
    utilisateur: { type: mongoose.Schema.Types.ObjectId, ref: 'Visiteur', default: null },
  },
  { timestamps: true },
);

export const Vehicule = mongoose.model('Vehicule', vehiculeSchema);

uppercase: true s'applique avant la validation : gh-482-kl est converti en GH-482-KL, puis accepté par l'expression régulière. La collection s'appellera vehicules.

Tu avais trouvé ?

Exercice 2 sur 3 Corriger un contrôleur Réussi À revoir

Ce code de clôture d'une fiche a trois défauts. Lesquels ?

js
ficheSchema.pre('save', function (next) {
  if (this.lignesForfait.length === 0) return next(new Error('Fiche vide'));
  next();
});

export async function cloturer(req, res) {
  const fiche = await Fiche.findOneAndUpdate(
    { _id: req.params.id, visiteur: req.utilisateur.id },
    { etat: 'CL' },
    { new: true },
  );
  res.json(fiche);
}
Voir la réponseMasquer la réponse de l'exercice 2
  1. Le hook utilise next, que Mongoose 9 ne fournit plus : écris ficheSchema.pre('save', function () { if (this.lignesForfait.length === 0) throw new Error('Fiche vide'); });.
  2. De toute façon, ce hook ne s'exécute pas : findOneAndUpdate ne déclenche pas les hooks de save. La règle « une fiche vide ne se clôture pas » est contournée. Il faut soit charger la fiche, modifier etat et appeler save(), soit mettre la condition dans le filtre : { _id, visiteur, etat: 'CR', 'lignesForfait.0': { $exists: true } }.
  3. Il manque la condition etat: 'CR' : une fiche déjà validée (VA) repasserait à CL. Et { new: true } est déprécié : { returnDocument: 'after', runValidators: true }. Enfin, si aucune fiche ne correspond, fiche vaut null et l'API répond 200 avec null au lieu d'un 404 ou d'un 409.

Tu avais trouvé ?

Exercice 3 sur 3 Lire avec populate Réussi À revoir

Écris la requête Mongoose qui renvoie, pour le comptable, les fiches clôturées (CL), de la plus récente à la plus ancienne, avec seulement le mois, l'état et le nom et le prénom du visiteur, sous forme d'objets simples. Quelles requêtes MongoDB Mongoose envoie-t-il ? Combien de fiches obtiens-tu sur le jeu de données ?

Voir la réponseMasquer la réponse de l'exercice 3
js
const aValider = await Fiche.find({ etat: 'CL' })
  .sort({ mois: -1 })
  .select('mois etat visiteur')
  .populate('visiteur', 'nom prenom')
  .lean();

Le champ visiteur doit figurer dans le select, sinon populate n'a rien à remplacer. Mongoose envoie deux requêtes : find({ etat: 'CL' }) triée sur les fiches (servie par l'index { etat: 1, mois: -1 }), puis find({ _id: { $in: [les identifiants des visiteurs trouvés] } }) sur les visiteurs. On obtient 2 fiches : celle d'octobre de Thomas Girard, puis celle de septembre d'Inès Benali.

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 : Transactions MongoDB : quand et comment les utiliserRevoir le sommaire du cours