Avec un MongoDB moderne supérieur à 3.2, vous pouvez utiliser $lookup
comme alternative à .populate()
dans la plupart des cas. Cela a également l'avantage de faire la jointure "sur le serveur" par opposition à ce que .populate()
fait ce qui est en fait "plusieurs requêtes" à "émuler" une jointure.
Donc .populate()
n'est pas vraiment une "jointure" dans le sens de la façon dont une base de données relationnelle le fait. La $lookup
d'autre part, fait réellement le travail sur le serveur, et est plus ou moins analogue à un "LEFT JOIN" :
Item.aggregate(
[
{ "$lookup": {
"from": ItemTags.collection.name,
"localField": "tags",
"foreignField": "_id",
"as": "tags"
}},
{ "$unwind": "$tags" },
{ "$match": { "tags.tagName": { "$in": [ "funny", "politics" ] } } },
{ "$group": {
"_id": "$_id",
"dateCreated": { "$first": "$dateCreated" },
"title": { "$first": "$title" },
"description": { "$first": "$description" },
"tags": { "$push": "$tags" }
}}
],
function(err, result) {
// "tags" is now filtered by condition and "joined"
}
)
N.B. Le
.collection.name
ici correspond en fait à la "chaîne" qui est le nom réel de la collection MongoDB telle qu'assignée au modèle. Puisque la mangouste "pluralise" les noms de collection par défaut et$lookup
a besoin du nom réel de la collection MongoDB comme argument (puisqu'il s'agit d'une opération de serveur), alors c'est une astuce pratique à utiliser dans le code mongoose, par opposition au "codage en dur" directement du nom de la collection.
Bien que nous puissions également utiliser $filter
sur les tableaux pour supprimer les éléments indésirables, il s'agit en fait de la forme la plus efficace en raison de l'optimisation du pipeline d'agrégation pour la condition spéciale de $lookup
suivi à la fois d'un $unwind
et un $match
état.
Cela se traduit en fait par le regroupement des trois étapes du pipeline en une seule :
{ "$lookup" : {
"from" : "itemtags",
"as" : "tags",
"localField" : "tags",
"foreignField" : "_id",
"unwinding" : {
"preserveNullAndEmptyArrays" : false
},
"matching" : {
"tagName" : {
"$in" : [
"funny",
"politics"
]
}
}
}}
Ceci est hautement optimal car l'opération réelle "filtre la collection à joindre en premier", puis elle renvoie les résultats et "déroule" le tableau. Les deux méthodes sont utilisées afin que les résultats ne dépassent pas la limite BSON de 16 Mo, qui est une contrainte que le client n'a pas.
Le seul problème est que cela semble "contre-intuitif" à certains égards, en particulier lorsque vous voulez les résultats dans un tableau, mais c'est ce que le $group
est pour ici, car il reconstruit le formulaire de document original.
Il est également regrettable que nous ne puissions tout simplement pas pour le moment écrire $lookup
dans la même syntaxe éventuelle que le serveur utilise. A mon humble avis, c'est un oubli à corriger. Mais pour l'instant, le simple fait d'utiliser la séquence fonctionnera et constitue l'option la plus viable avec les meilleures performances et évolutivité.
Addendum - MongoDB 3.6 et versions ultérieures
Bien que le modèle présenté ici soit assez optimisé en raison de la façon dont les autres étapes sont intégrées dans le $lookup
, il a un défaut en ce que le "LEFT JOIN" qui est normalement inhérent à la fois à $lookup
et les actions de populate()
est nié par le "optimal" utilisation de $unwind
ici qui ne conserve pas les tableaux vides. Vous pouvez ajouter le preserveNullAndEmptyArrays
option, mais cela annule l'option "optimisé" séquence décrite ci-dessus et laisse essentiellement intactes les trois étapes qui seraient normalement combinées dans l'optimisation.
MongoDB 3.6 s'agrandit avec un "plus expressif" forme de $lookup
permettant une expression "sous-pipeline". Ce qui non seulement répond à l'objectif de conserver le "LEFT JOIN" mais permet tout de même une requête optimale pour réduire les résultats renvoyés et avec une syntaxe très simplifiée :
Item.aggregate([
{ "$lookup": {
"from": ItemTags.collection.name,
"let": { "tags": "$tags" },
"pipeline": [
{ "$match": {
"tags": { "$in": [ "politics", "funny" ] },
"$expr": { "$in": [ "$_id", "$$tags" ] }
}}
]
}}
])
Le $expr
utilisé pour faire correspondre la valeur "locale" déclarée avec la valeur "étrangère" est en fait ce que MongoDB fait "en interne" maintenant avec le $lookup
d'origine syntaxe. En exprimant sous cette forme, nous pouvons personnaliser le $match
initial expression dans le "sous-pipeline" nous-mêmes.
En fait, en tant que véritable "pipeline d'agrégation", vous pouvez faire à peu près tout ce que vous pouvez faire avec un pipeline d'agrégation dans cette expression de "sous-pipeline", y compris "imbriquer" les niveaux de $lookup
à d'autres collections connexes.
Une utilisation ultérieure dépasse un peu la portée de ce que la question demande ici, mais en ce qui concerne même la "population imbriquée", le nouveau modèle d'utilisation de $lookup
permet que cela soit à peu près le même, et un "beaucoup" plus puissant dans son utilisation complète.
Exemple de travail
Ce qui suit donne un exemple utilisant une méthode statique sur le modèle. Une fois cette méthode statique implémentée, l'appel devient simplement :
Item.lookup(
{
path: 'tags',
query: { 'tags.tagName' : { '$in': [ 'funny', 'politics' ] } }
},
callback
)
Ou améliorer pour être un peu plus moderne devient même :
let results = await Item.lookup({
path: 'tags',
query: { 'tagName' : { '$in': [ 'funny', 'politics' ] } }
})
Le rendant très similaire à .populate()
dans la structure, mais il fait en fait la jointure sur le serveur à la place. Pour être complet, l'utilisation ici renvoie les données renvoyées vers des instances de document mongoose en fonction des cas parent et enfant.
C'est assez trivial et facile à adapter ou à utiliser tel quel dans la plupart des cas courants.
N.B L'utilisation d'async ici est juste pour la brièveté de l'exécution de l'exemple ci-joint. L'implémentation réelle est exempte de cette dépendance.
const async = require('async'),
mongoose = require('mongoose'),
Schema = mongoose.Schema;
mongoose.Promise = global.Promise;
mongoose.set('debug', true);
mongoose.connect('mongodb://localhost/looktest');
const itemTagSchema = new Schema({
tagName: String
});
const itemSchema = new Schema({
dateCreated: { type: Date, default: Date.now },
title: String,
description: String,
tags: [{ type: Schema.Types.ObjectId, ref: 'ItemTag' }]
});
itemSchema.statics.lookup = function(opt,callback) {
let rel =
mongoose.model(this.schema.path(opt.path).caster.options.ref);
let group = { "$group": { } };
this.schema.eachPath(p =>
group.$group[p] = (p === "_id") ? "$_id" :
(p === opt.path) ? { "$push": `$${p}` } : { "$first": `$${p}` });
let pipeline = [
{ "$lookup": {
"from": rel.collection.name,
"as": opt.path,
"localField": opt.path,
"foreignField": "_id"
}},
{ "$unwind": `$${opt.path}` },
{ "$match": opt.query },
group
];
this.aggregate(pipeline,(err,result) => {
if (err) callback(err);
result = result.map(m => {
m[opt.path] = m[opt.path].map(r => rel(r));
return this(m);
});
callback(err,result);
});
}
const Item = mongoose.model('Item', itemSchema);
const ItemTag = mongoose.model('ItemTag', itemTagSchema);
function log(body) {
console.log(JSON.stringify(body, undefined, 2))
}
async.series(
[
// Clean data
(callback) => async.each(mongoose.models,(model,callback) =>
model.remove({},callback),callback),
// Create tags and items
(callback) =>
async.waterfall(
[
(callback) =>
ItemTag.create([{ "tagName": "movies" }, { "tagName": "funny" }],
callback),
(tags, callback) =>
Item.create({ "title": "Something","description": "An item",
"tags": tags },callback)
],
callback
),
// Query with our static
(callback) =>
Item.lookup(
{
path: 'tags',
query: { 'tags.tagName' : { '$in': [ 'funny', 'politics' ] } }
},
callback
)
],
(err,results) => {
if (err) throw err;
let result = results.pop();
log(result);
mongoose.disconnect();
}
)
Ou un peu plus moderne pour Node 8.x et supérieur avec async/await
et aucune dépendance supplémentaire :
const { Schema } = mongoose = require('mongoose');
const uri = 'mongodb://localhost/looktest';
mongoose.Promise = global.Promise;
mongoose.set('debug', true);
const itemTagSchema = new Schema({
tagName: String
});
const itemSchema = new Schema({
dateCreated: { type: Date, default: Date.now },
title: String,
description: String,
tags: [{ type: Schema.Types.ObjectId, ref: 'ItemTag' }]
});
itemSchema.statics.lookup = function(opt) {
let rel =
mongoose.model(this.schema.path(opt.path).caster.options.ref);
let group = { "$group": { } };
this.schema.eachPath(p =>
group.$group[p] = (p === "_id") ? "$_id" :
(p === opt.path) ? { "$push": `$${p}` } : { "$first": `$${p}` });
let pipeline = [
{ "$lookup": {
"from": rel.collection.name,
"as": opt.path,
"localField": opt.path,
"foreignField": "_id"
}},
{ "$unwind": `$${opt.path}` },
{ "$match": opt.query },
group
];
return this.aggregate(pipeline).exec().then(r => r.map(m =>
this({ ...m, [opt.path]: m[opt.path].map(r => rel(r)) })
));
}
const Item = mongoose.model('Item', itemSchema);
const ItemTag = mongoose.model('ItemTag', itemTagSchema);
const log = body => console.log(JSON.stringify(body, undefined, 2));
(async function() {
try {
const conn = await mongoose.connect(uri);
// Clean data
await Promise.all(Object.entries(conn.models).map(([k,m]) => m.remove()));
// Create tags and items
const tags = await ItemTag.create(
["movies", "funny"].map(tagName =>({ tagName }))
);
const item = await Item.create({
"title": "Something",
"description": "An item",
tags
});
// Query with our static
const result = (await Item.lookup({
path: 'tags',
query: { 'tags.tagName' : { '$in': [ 'funny', 'politics' ] } }
})).pop();
log(result);
mongoose.disconnect();
} catch (e) {
console.error(e);
} finally {
process.exit()
}
})()
Et à partir de MongoDB 3.6 et supérieur, même sans le $unwind
et $group
bâtiment :
const { Schema, Types: { ObjectId } } = mongoose = require('mongoose');
const uri = 'mongodb://localhost/looktest';
mongoose.Promise = global.Promise;
mongoose.set('debug', true);
const itemTagSchema = new Schema({
tagName: String
});
const itemSchema = new Schema({
title: String,
description: String,
tags: [{ type: Schema.Types.ObjectId, ref: 'ItemTag' }]
},{ timestamps: true });
itemSchema.statics.lookup = function({ path, query }) {
let rel =
mongoose.model(this.schema.path(path).caster.options.ref);
// MongoDB 3.6 and up $lookup with sub-pipeline
let pipeline = [
{ "$lookup": {
"from": rel.collection.name,
"as": path,
"let": { [path]: `$${path}` },
"pipeline": [
{ "$match": {
...query,
"$expr": { "$in": [ "$_id", `$$${path}` ] }
}}
]
}}
];
return this.aggregate(pipeline).exec().then(r => r.map(m =>
this({ ...m, [path]: m[path].map(r => rel(r)) })
));
};
const Item = mongoose.model('Item', itemSchema);
const ItemTag = mongoose.model('ItemTag', itemTagSchema);
const log = body => console.log(JSON.stringify(body, undefined, 2));
(async function() {
try {
const conn = await mongoose.connect(uri);
// Clean data
await Promise.all(Object.entries(conn.models).map(([k,m]) => m.remove()));
// Create tags and items
const tags = await ItemTag.insertMany(
["movies", "funny"].map(tagName => ({ tagName }))
);
const item = await Item.create({
"title": "Something",
"description": "An item",
tags
});
// Query with our static
let result = (await Item.lookup({
path: 'tags',
query: { 'tagName': { '$in': [ 'funny', 'politics' ] } }
})).pop();
log(result);
await mongoose.disconnect();
} catch(e) {
console.error(e)
} finally {
process.exit()
}
})()