sql >> Base de Datos >  >> NoSQL >> MongoDB

Consultando después de poblar en Mongoose

Con un MongoDB moderno superior a 3.2, puede usar $lookup como alternativa a .populate() en la mayoría de los casos. Esto también tiene la ventaja de hacer la unión "en el servidor" en lugar de lo que .populate() hace que en realidad es "consultas múltiples" para "emular" una unión.

Entonces .populate() es no realmente una "unión" en el sentido de cómo lo hace una base de datos relacional. El $lookup operador por otro lado, en realidad 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"
  }
)

N.B. El .collection.name aquí en realidad se evalúa como la "cadena" que es el nombre real de la colección MongoDB asignada al modelo. Dado que mongoose "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 de servidor), entonces este es un truco útil para usar en el código mongoose, en lugar de "codificar" el nombre de la colección directamente.

Aunque 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 la Optimización de canalización de agregación para la condición especial de como $lookup seguido de un $unwind y un $match condición.

En realidad, esto da como resultado que las tres etapas de canalización se integren en una sola:

   { "$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 unirla 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" en algunos aspectos, especialmente cuando desea obtener los resultados en una matriz, pero eso es lo que el $group es para aquí, ya que reconstruye la forma del documento original.

También es desafortunado que simplemente no podamos en este momento escribir $lookup en la misma sintaxis eventual que utiliza el servidor. En mi humilde opinión, esto 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 superior

Aunque el patrón que se muestra aquí está bastante optimizado debido a cómo las otras etapas se incluyen en $lookup , tiene una falla en la "UNIÓN IZQUIERDA" que normalmente es inherente a ambos $lookup y las acciones de populate() es negado por el "óptimo" uso de $unwind here que no conserva matrices vacías. Puede agregar preserveNullAndEmptyArrays opción, pero esto niega el "optimizado" secuencia descrita anteriormente y esencialmente deja intactas las tres etapas que normalmente se combinarían en la optimización.

MongoDB 3.6 se expande con un "más expresivo" forma de $lookup permitiendo una expresión de "subcanalización". Lo que no solo cumple con el objetivo de conservar el "LEFT JOIN", sino que aú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 utilizado para hacer coincidir el valor "local" declarado con el valor "extranjero" es en realidad lo que MongoDB hace "internamente" ahora con el $lookup original sintaxis. Al expresar de esta forma, podemos adaptar el $match inicial expresión dentro de la "subcanalización" 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 "subcanalización", incluido "anidar" los niveles de $lookup a otras colecciones relacionadas.

El uso adicional está un poco más allá del alcance de lo que hace la pregunta aquí, pero en relación incluso con la "población anidada", entonces el nuevo patrón de uso de $lookup permite que esto sea más o menos lo mismo, y un "mucho" más poderoso en su uso completo.

Ejemplo de trabajo

Lo siguiente da un ejemplo usando 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 .populate() en estructura, pero en realidad está haciendo la unión en el servidor. Para completar, el uso aquí convierte los datos devueltos en instancias de documentos mongoose de acuerdo con los casos principal y secundario.

Es bastante trivial y fácil de adaptar o simplemente usar como está para los casos más comunes.

N.B El uso de async aquí es solo por la brevedad de ejecutar el 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 Node 8.x y superior con async/await y 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);

    // 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 y superior, incluso sin el $unwind y $group edificio:

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

})()