Aller au contenu principal
~/GiwiSoft
Mongodb database

Jointure de table avec MongoDB

Giwi 5 min de lecture MongoDB

Dans nos modèles Meurise, MCD, SGBDR, la relation entre tables est naturelle.  Avec du NoSQL,  ça l’est moins. Mongo nous propose des pistes de modélisation, mais je ne trouvais pas les solutions satisfaisantes. Je m’explique. Imaginons, une collection d’auteurs et une collection d’articles. Avec un SGBDR classique on aurait : mcd D’après Mongo, il y a 2 solutions :

  1. On inclue le document auteur (tout ou partie) dans le document article
    • Avantage : quand on veut la liste des articles, on a les infos de l’auteur avec
    • Inconvénient : quand l’auteur change une de ces infos présente dans le document article, il faut faire 2 mise à jour des données
  2. On a une référence de l’auteur (son id) dans le document article.
    • Avantage : les mises à jours sont simplifiées
    • Inconvénient : on est forcé de récupérer la liste des articles, puis pour chacun d’eux, récupérer l’id de l’auteur, rechercher tous les auteurs dont l’id se trouve dans cette liste et enfin raccrocher l’article à son auteur. 2 requêtes et des manipulations de listes côté serveur.

Mais alors est-ce possible de tout récupérer en un seul appel avec une référence de l’auteur dans l’article? Après un peu de recherche oui.

Reprenons nos structures Json : Collection User :

{
"\_id" : "1",
"name" : "Marin",
"forname" : "Xavier",
"nickname" : "xav"
}

Collection Blogs :

{
"\_id" : "1",
"timestamp" : NumberLong(1401867847649),
"title" : "Mon article sur la vie trépidante des loutres",
"content" : "Les loutres ont une vie
absolument passionnante mais se droguent
beaucoup trop.",
"user\_id" : "1"
}

Il existe quelques pistes sur le Web, une a retenu mon attention et portait sur la fonction de MapReduce de Mongo, la voici :

Je me suis arraché les 3 cheveux qui me restent et je pense avoir trouver une solution satisfaisante :

db.runCommand({eval : function() {
var f = db.blogs.find().sort({timestamp : -10}), out = \[\];
for (var i = 0; i < f.length(); i++) {
f\[i\].user\_id = db.user.findOne({\_id :f\[i\].user\_id});
out.push(f\[i\]);
}
return out;
}
})

Qui dit mieux? Je suis ouvert au débat.

L’agrégation $lookup (MongoDB 3.2+)

Depuis MongoDB 3.2, l’agrégation propose $lookup, qui permet de faire une vraie jointure côté serveur, sans MapReduce ni boucle manuelle. C’est l’équivalent du LEFT JOIN en SQL.

db.blogs.aggregate([
{
$lookup: {
from: "user",
localField: "user_id",
foreignField: "_id",
as: "auteur"
}
},
{ $unwind: "$auteur" },
{ $sort: { timestamp: -1 } },
{
$project: {
title: 1,
content: 1,
timestamp: 1,
"auteur.name": 1,
"auteur.forname": 1,
"auteur.nickname": 1
}
}
])

Résultat : un seul appel à MongoDB, pas de code côté serveur, performant et maintenable.

Comparaison avec MapReduce

Critère MapReduce (2014) $lookup (moderne)
Complexité Fonctions JS à écrire Une étape d’agrégation
Performance Mono-thread JS Natif C++, indexé
Maintenance Code à part Pipeline déclaratif
Version MongoDB < 2.4 MongoDB 3.2+
Streaming Non Oui (curseur)

$lookup est bien plus performant car il s’exécute directement dans le moteur de requête, sans passer par le moteur JavaScript.

Comment ça marche concrètement

// Avec tableaux imbriqués (plus réaliste)
db.blogs.aggregate([
{
$lookup: {
from: "comments",
localField: "_id",
foreignField: "blog_id",
as: "commentaires"
}
},
{
$addFields: {
nbCommentaires: { $size: "$commentaires" }
}
}
])

Depuis MongoDB 3.6, $lookup supporte aussi les pipelines dans let + pipeline :

db.blogs.aggregate([
{
$lookup: {
from: "comments",
let: { blogId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$blog_id", "$$blogId"] } } },
{ $limit: 5 },
{ $sort: { date: -1 } }
],
as: "commentairesRecents"
}
}
])

Guide : document embarqué vs référence

Comment choisir ?

Privilégier l’embarquement quand :

  • La donnée est toujours lue ensemble
  • La relation est de type “contient” (un article a des lignes de commande)
  • Les écritures sont rares
  • La taille totale reste sous les 16 Mo

Privilégier la référence quand :

  • La donnée est volumineuse (images, longs textes)
  • La donnée est partagée entre plusieurs documents
  • La donnée change fréquemment et doit être cohérente
  • Vous avez besoin d’accéder à l’entité indépendamment

Avec Mongoose (Node.js)

Pour les projets Node.js, Mongoose rend les références et la population très naturelles :

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
name: String,
forname: String,
nickname: String
});

const blogSchema = new mongoose.Schema({
title: String,
content: String,
timestamp: { type: Date, default: Date.now },
user_id: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
});

const User = mongoose.model('User', userSchema);
const Blog = mongoose.model('Blog', blogSchema);

// Populate fait la jointure automatiquement
const articles = await Blog
.find()
.sort({ timestamp: -1 })
.populate('user_id', 'name forname nickname')
.exec();

// Résultat : les articles avec l'auteur embarqué 🎉
console.log(articles[0].user_id.name); // "Marin"

Mongoose populate() effectue en fait une ou plusieurs requêtes supplémentaires, mais l’abstraction est transparente. Pour de très gros volumes, préférez $lookup pur, mais pour 95% des cas, populate() est parfaitement adapté.