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
npm install mongoose # Mongoose 9, construit sur le driver mongodb 7
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.
// 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 champ | Effet | Exemple |
|---|---|---|
type | Le type : String, Number, Date, Boolean, mongoose.Schema.Types.ObjectId, [String]… | { type: Date } |
required | Champ obligatoire | required: [true, 'Le nom est obligatoire'] |
default | Valeur si le champ est absent | default: 'CR', default: Date.now |
enum | Liste des valeurs permises | enum: ['CR', 'CL', 'VA', 'MP', 'RB'] |
min, max | Bornes d'un nombre ou d'une date | min: 0 |
minlength, maxlength, match | Longueur et motif d'une chaîne | match: /^\d{4}-(0[1-9]|1[0-2])$/ |
trim, lowercase, uppercase | Nettoient la chaîne avant l'enregistrement | lowercase: true pour une adresse électronique |
unique | Crée un index unique (ce n'est pas un validateur) | unique: true |
select: false | Champ exclu des lectures, sauf demande explicite | Pour une empreinte de mot de passe |
ref | Le modèle visé par une référence, pour populate | ref: 'Visiteur' |
Et le modèle de la fiche, avec ses sous-documents :
// 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
// 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 :
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);
}
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 :
| Erreur | Cause | Réponse HTTP habituelle |
|---|---|---|
CastError | Une valeur impossible à convertir : Fiche.findById('123') donne Cast to ObjectId failed for value "123" | 400 (ou 404 pour un identifiant) |
MongoServerError, code 11000 | Un index unique violé : une deuxième fiche pour le même mois | 409 Conflict |
MissingSchemaError | populate('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é.
// 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 :
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 :
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 :
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 :
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
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 ?
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
- Le hook utilise
next, que Mongoose 9 ne fournit plus : écrisficheSchema.pre('save', function () { if (this.lignesForfait.length === 0) throw new Error('Fiche vide'); });. - De toute façon, ce hook ne s'exécute pas :
findOneAndUpdatene déclenche pas les hooks desave. La règle « une fiche vide ne se clôture pas » est contournée. Il faut soit charger la fiche, modifieretatet appelersave(), soit mettre la condition dans le filtre :{ _id, visiteur, etat: 'CR', 'lignesForfait.0': { $exists: true } }. - 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,fichevautnullet l'API répond200avecnullau lieu d'un404ou d'un409.
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
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é ?