Con un MongoDB moderno superior a 3.2, puede usarlo $lookup
como una alternativa .populate()
en la mayoría de los casos. Esto también tiene la ventaja de realizar la unión "en el servidor" en lugar de lo .populate()
que hace, que es en realidad "consultas múltiples" para "emular" una unión.
Así .populate()
que no es realmente una "unión" en el sentido de cómo lo hace una base de datos relacional. El $lookup
operador, por otro lado, realmente hace el trabajo en el servidor, y es más o menos análogo a 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) {
}
)
NB El .collection.name
aquí en realidad evalúa a la "cadena" que es el nombre real de la colección MongoDB asignada al modelo. Dado que mangosta "pluraliza" los nombres de las colecciones de forma predeterminada y $lookup
necesita el nombre real de la colección MongoDB como argumento (ya que es una operación del servidor), entonces este es un truco útil para usar en el código de mangosta, en lugar de "codificar" el nombre de la colección directamente .
Si bien también podríamos usar $filter
en matrices para eliminar los elementos no deseados, esta es en realidad la forma más eficiente debido a Aggregation Pipeline Optimization para la condición especial $lookup
seguida de una $unwind
y una $match
condición.
En realidad, esto da como resultado que las tres etapas de la canalización se unan en una:
{ "$lookup" : {
"from" : "itemtags",
"as" : "tags",
"localField" : "tags",
"foreignField" : "_id",
"unwinding" : {
"preserveNullAndEmptyArrays" : false
},
"matching" : {
"tagName" : {
"$in" : [
"funny",
"politics"
]
}
}
}}
Esto es muy óptimo ya que la operación real "filtra la colección para unirse primero", luego devuelve los resultados y "desenrolla" la matriz. Se emplean ambos métodos para que los resultados no superen el límite de BSON de 16 MB, que es una restricción que el cliente no tiene.
El único problema es que parece "contrario a la intuición" de alguna manera, particularmente cuando quiere los resultados en una matriz, pero para eso es $group
aquí, ya que reconstruye el formato del documento original.
También es lamentable que simplemente no podamos en este momento escribir $lookup
en la misma sintaxis eventual que usa el servidor. En mi humilde opinión, este es un descuido que debe corregirse. Pero por ahora, simplemente usar la secuencia funcionará y es la opción más viable con el mejor rendimiento y escalabilidad.
Anexo: MongoDB 3.6 y versiones posteriores
Aunque el patrón que se muestra aquí está bastante optimizado debido a la forma en que las otras etapas se incorporan $lookup
, tiene una falla en el sentido de que "LEFT JOIN", que normalmente es inherente a ambos, $lookup
y las acciones de populate()
se niegan por el uso "óptimo" de $unwind
aquí que no conserva matrices vacías. Puede agregar la preserveNullAndEmptyArrays
opción, pero esto niega la secuencia "optimizada" descrita anteriormente y esencialmente deja intactas las tres etapas que normalmente se combinarían en la optimización.
MongoDB 3.6 se expande con una forma "más expresiva" de $lookup
permitir una expresión "sub-pipeline". Lo cual no solo cumple con el objetivo de retener el "LEFT JOIN" sino que también permite una consulta óptima para reducir los resultados devueltos y con una sintaxis mucho más simplificada:
Item.aggregate([
{ "$lookup": {
"from": ItemTags.collection.name,
"let": { "tags": "$tags" },
"pipeline": [
{ "$match": {
"tags": { "$in": [ "politics", "funny" ] },
"$expr": { "$in": [ "$_id", "$$tags" ] }
}}
]
}}
])
El $expr
usado para hacer coincidir el valor "local" declarado con el valor "externo" es en realidad lo que MongoDB hace "internamente" ahora con la $lookup
sintaxis original . Al expresarlo de esta forma, podemos adaptar la $match
expresión inicial dentro del "subproceso" nosotros mismos.
De hecho, como una verdadera "canalización de agregación", puede hacer casi cualquier cosa que pueda hacer con una canalización de agregación dentro de esta expresión de "sub-canalización", incluido "anidar" los niveles de $lookup
otras colecciones relacionadas.
El uso posterior está un poco más allá del alcance de lo que plantea la pregunta aquí, pero incluso en relación con la "población anidada", el nuevo patrón de uso $lookup
permite que esto sea más o menos igual y mucho más poderoso en su uso completo.
Ejemplo de trabajo
A continuación, se ofrece un ejemplo que utiliza un método estático en el modelo. Una vez que se implementa ese método estático, la llamada simplemente se convierte en:
Item.lookup(
{
path: 'tags',
query: { 'tags.tagName' : { '$in': [ 'funny', 'politics' ] } }
},
callback
)
O mejorar para ser un poco más moderno incluso se convierte en:
let results = await Item.lookup({
path: 'tags',
query: { 'tagName' : { '$in': [ 'funny', 'politics' ] } }
})
Haciéndolo muy similar a la .populate()
estructura, pero en realidad está haciendo la unión en el servidor. Para completar, el uso aquí devuelve los datos devueltos a instancias de documentos de mangosta según los casos principal y secundario.
Es bastante trivial y fácil de adaptar o simplemente usar como es para los casos más comunes.
NB El uso de async aquí es solo para abreviar la ejecución del ejemplo adjunto. La implementación real está libre de esta dependencia.
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(
[
(callback) => async.each(mongoose.models,(model,callback) =>
model.remove({},callback),callback),
(callback) =>
async.waterfall(
[
(callback) =>
ItemTag.create([{ "tagName": "movies" }, { "tagName": "funny" }],
callback),
(tags, callback) =>
Item.create({ "title": "Something","description": "An item",
"tags": tags },callback)
],
callback
),
(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();
}
)
O un poco más moderno para el Nodo 8.xy superior async/await
sin dependencias adicionales:
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);
await Promise.all(Object.entries(conn.models).map(([k,m]) => m.remove()));
const tags = await ItemTag.create(
["movies", "funny"].map(tagName =>({ tagName }))
);
const item = await Item.create({
"title": "Something",
"description": "An item",
tags
});
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()
}
})()
Y desde MongoDB 3.6 en adelante, incluso sin el edificio $unwind
y $group
:
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);
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);
await Promise.all(Object.entries(conn.models).map(([k,m]) => m.remove()));
const tags = await ItemTag.insertMany(
["movies", "funny"].map(tagName => ({ tagName }))
);
const item = await Item.create({
"title": "Something",
"description": "An item",
tags
});
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()
}
})()