Consultando después de poblar en Mongoose

81

Soy bastante nuevo en Mongoose y MongoDB en general, así que estoy teniendo dificultades para averiguar si algo como esto es posible:

Item = new Schema({
    id: Schema.ObjectId,
    dateCreated: { type: Date, default: Date.now },
    title: { type: String, default: 'No Title' },
    description: { type: String, default: 'No Description' },
    tags: [ { type: Schema.ObjectId, ref: 'ItemTag' }]
});

ItemTag = new Schema({
    id: Schema.ObjectId,
    tagId: { type: Schema.ObjectId, ref: 'Tag' },
    tagName: { type: String }
});



var query = Models.Item.find({});

query
    .desc('dateCreated')
    .populate('tags')
    .where('tags.tagName').in(['funny', 'politics'])
    .run(function(err, docs){
       // docs is always empty
    });

¿Existe una mejor manera de hacer esto?

Editar

Disculpas por cualquier confusión. Lo que intento hacer es obtener todos los elementos que contienen la etiqueta divertida o la etiqueta política.

Editar

Documento sin cláusula where:

[{ 
    _id: 4fe90264e5caa33f04000012,
    dislikes: 0,
    likes: 0,
    source: '/uploads/loldog.jpg',
    comments: [],
    tags: [{
        itemId: 4fe90264e5caa33f04000012,
        tagName: 'movies',
        tagId: 4fe64219007e20e644000007,
        _id: 4fe90270e5caa33f04000015,
        dateCreated: Tue, 26 Jun 2012 00:29:36 GMT,
        rating: 0,
        dislikes: 0,
        likes: 0 
    },
    { 
        itemId: 4fe90264e5caa33f04000012,
        tagName: 'funny',
        tagId: 4fe64219007e20e644000002,
        _id: 4fe90270e5caa33f04000017,
        dateCreated: Tue, 26 Jun 2012 00:29:36 GMT,
        rating: 0,
        dislikes: 0,
        likes: 0 
    }],
    viewCount: 0,
    rating: 0,
    type: 'image',
    description: null,
    title: 'dogggg',
    dateCreated: Tue, 26 Jun 2012 00:29:24 GMT 
 }, ... ]

Con la cláusula where, obtengo una matriz vacía.

jschr
fuente

Respuestas:

59

Con un MongoDB moderno superior a 3.2, puede usarlo $lookupcomo 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 $lookupoperador, 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) {
    // "tags" is now filtered by condition and "joined"
  }
)

NB El .collection.nameaquí 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 $lookupnecesita 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 $filteren 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 $lookupseguida de una $unwindy una $matchcondició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 $groupaquí, ya que reconstruye el formato del documento original.

También es lamentable que simplemente no podamos en este momento escribir $lookupen 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, $lookupy las acciones de populate()se niegan por el uso "óptimo" de $unwindaquí que no conserva matrices vacías. Puede agregar la preserveNullAndEmptyArraysopció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 $lookuppermitir 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 $exprusado para hacer coincidir el valor "local" declarado con el valor "externo" es en realidad lo que MongoDB hace "internamente" ahora con la $lookupsintaxis original . Al expresarlo de esta forma, podemos adaptar la $matchexpresió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 $lookupotras 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 $lookuppermite 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(
  [
    // 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();
  }
)

O un poco más moderno para el Nodo 8.xy superior async/awaitsin 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);

    // 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()
  }
})()

Y desde MongoDB 3.6 en adelante, incluso sin el edificio $unwindy $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);

  // 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()
  }

})()
Neil Lunn
fuente
3
Ya no uso Mongo / Mongoose, pero acepté su respuesta, ya que esta es una pregunta popular y parece que ha sido útil para otros. Me alegra ver que este problema ahora tiene una solución más escalable. Gracias por proporcionar una respuesta actualizada.
jschr
40

lo que solicita no se admite directamente, pero se puede lograr agregando otro paso de filtro después de que la consulta regrese.

primero, .populate( 'tags', null, { tagName: { $in: ['funny', 'politics'] } } )es definitivamente lo que debe hacer para filtrar los documentos de etiquetas. luego, después de que la consulta regrese, deberá filtrar manualmente los documentos que no tengan ningún tagsdocumento que coincida con los criterios de llenado. algo como:

query....
.exec(function(err, docs){
   docs = docs.filter(function(doc){
     return doc.tags.length;
   })
   // do stuff with docs
});
Aaronheckmann
fuente
1
Hola Aaron, gracias por la respuesta. Puedo estar equivocado, pero ¿el $ in en populate () solo completará las etiquetas coincidentes? Por lo tanto, se filtrarán todas las etiquetas adicionales del artículo. Parece que tendré que completar todos los elementos y hacer que el segundo paso del filtro lo reduzca en función del nombre de la etiqueta.
jschr
@aaronheckmann He implementado su solución sugerida, está a punto de hacer un filtro después de .exec, porque aunque la consulta de llenado solo está completando los objetos requeridos, pero sigue devolviendo el conjunto de datos completo. ¿Crees que en la versión más nueva de Mongoose hay alguna opción para devolver solo el conjunto de datos poblado para que no tengamos que buscar otro filtrado?
Aqib Mumtaz
También tengo curiosidad por saber sobre el rendimiento. Si la consulta devuelve un conjunto de datos completo al final, ¿no hay ningún propósito en el filtrado de población? ¿Qué dices? Estoy adaptando la consulta de población para la optimización del rendimiento, pero de esta manera, ¿el rendimiento no mejorará para un gran conjunto de datos?
Aqib Mumtaz
mongoosejs.com/docs/api.html#query_Query-populate tiene todos los detalles si alguien más está interesado
samazi
¿Cómo coincidir en diferentes campos cuando se rellenan?
nicogaldo
20

Intenta reemplazar

.populate('tags').where('tags.tagName').in(['funny', 'politics']) 

por

.populate( 'tags', null, { tagName: { $in: ['funny', 'politics'] } } )
Aafreen Sheikh
fuente
1
Gracias por la respuesta. Creo que lo que hace es rellenar cada elemento con humor o política, lo que no reduciría la lista de padres. Lo que en realidad me gustaría son solo artículos que tengan gracioso o política en su etiqueta.
jschr
¿Puede mostrar cómo se ve su documento? Porque un 'dónde' dentro de la matriz de etiquetas me parece una operación válida ... ¿Estamos entendiendo mal la sintaxis ... Ha intentado eliminar esa cláusula 'dónde' por completo y comprobado si se devuelve algo? Alternativamente, solo para probar si escribir 'tags.tagName' está sintácticamente bien, puede olvidarse de la referencia por un tiempo y probar su consulta con una matriz incrustada dentro del documento 'Item'.
Aafreen Sheikh
Edité mi publicación original con el documento. Pude probarlo con el modelo como una matriz incrustada dentro de Item con éxito, pero desafortunadamente necesito que sea un DBRef ya que ItemTag se actualiza con frecuencia. Gracias de nuevo por la ayuda.
jschr
15

Actualización: Eche un vistazo a los comentarios: esta respuesta no coincide correctamente con la pregunta, pero tal vez responda a otras preguntas de los usuarios que se encontraron (creo que debido a los votos a favor), por lo que no eliminaré esta "respuesta":

Primero: sé que esta pregunta está realmente desactualizada, pero busqué exactamente este problema y esta publicación SO fue la entrada de Google # 1. Así que implementé la docs.filterversión (respuesta aceptada) pero como leí en los documentos de mongoose v4.6.0 ahora podemos simplemente usar:

Item.find({}).populate({
    path: 'tags',
    match: { tagName: { $in: ['funny', 'politics'] }}
}).exec((err, items) => {
  console.log(items.tags) 
  // contains only tags where tagName is 'funny' or 'politics'
})

Espero que esto ayude a los futuros usuarios de máquinas de búsqueda.

Fabian
fuente
3
¿Pero esto solo filtrará la matriz items.tags seguramente? Los artículos serán devueltos independientemente del tagName ...
OllyBarca
1
Eso es correcto, @OllyBarca. Según los documentos, la coincidencia afecta solo a la consulta de población.
andreimarinescu
1
Creo que esto no responde a la pregunta
Z.Alpha
1
@Fabian eso no es un error. Solo fansse filtra la consulta de población (en este caso ). El documento real devuelto (que es Story, contiene fanscomo una propiedad) no se ve afectado ni filtrado.
EnKrypt
2
Por tanto, esta respuesta no es correcta, por las razones mencionadas en los comentarios. Cualquiera que mire esto en el futuro debe tener cuidado.
EnKrypt
3

Después de tener el mismo problema recientemente, se me ocurrió la siguiente solución:

Primero, busque todas las ItemTags donde tagName sea 'divertido' o 'política' y devuelva una matriz de ItemTag _ids.

Luego, busque elementos que contengan todos los _ids ItemTag en la matriz de etiquetas

ItemTag
  .find({ tagName : { $in : ['funny','politics'] } })
  .lean()
  .distinct('_id')
  .exec((err, itemTagIds) => {
     if (err) { console.error(err); }
     Item.find({ tag: { $all: itemTagIds} }, (err, items) => {
        console.log(items); // Items filtered by tagName
     });
  });
OllyBarca
fuente
Cómo lo hice const tagsIds = await this.tagModel .find ({nombre: {$ en: etiquetas}}) .lean () .distinct ('_ id'); return this.adviceModel.find ({etiquetas: {$ all: tagsIds}});
Dragos Lupei
1

respuesta @aaronheckmann 's funcionado para mí, pero tuve que reemplazar return doc.tags.length;a return doc.tags != null;causa de que el campo contiene nula si no coincide con las condiciones escritas en el interior poblar. Entonces el código final:

query....
.exec(function(err, docs){
   docs = docs.filter(function(doc){
     return doc.tags != null;
   })
   // do stuff with docs
});
HernanFila
fuente