按加入的子文档过滤

时间:2019-03-03 08:09:19

标签: node.js mongodb mongoose mongodb-query aggregation-framework

我正在尝试通过子文档引用的属性来过滤文档。假设我已经为每个模式创建了模型。简化的模式如下:

const store = new Schema({
    name: { type: String }
})

const price = new Schema({
    price: { type: Number },
    store: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Store'
    },
})

const product = new Schema({
    name: {type: String},
    prices: [{
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Price'
    }] 
})
/* 
Notation: 
lowercase for schemas: product
uppercase for models: Product
*/

我尝试的第一种方法:

Product.find({'prices.store':storeId}).populate('prices')

,但是这不起作用,因为猫鼬不支持按子文档属性进行过滤。

我当前的方法是使用聚合框架。聚合的外观如下:

{
  $unwind: '$prices'
},
{
  $lookup: {
    from: 'prices',
    localField: 'prices',
    foreignField: '_id',
    as: 'prices'
  }
},
{
  $unwind: '$prices'
},
{
  $lookup: {
    from: 'stores',
    localField: 'prices.store',
    foreignField: '_id',
    as: 'prices.store'
  }
}, // populate
{
  $match: {
    'prices.store._id': new mongoose.Types.ObjectId(storeId)
  }
}, // filter by store id
{ $group: { _id: '$id', doc: { $first: '$$ROOT' } } },
{ $replaceRoot: { newRoot: '$doc' } }
// Error occurs in $group & $replaceRoot

例如,在最后两个阶段之前,如果要保存的记录是:

{
    name: 'Milk', 
    prices: [
        {store: 1, price: 3.2}, 
        {store: 2, price: 4.0}
    ]
}

然后返回汇总:(请注意,产品相同,但在不同结果中显示每个价格)

[ 
    {
        id: 4,
        name: 'Milk', 
        prices: {
           id: 10,
           store: { _id: 1, name : 'Walmart' }, 
           price: 3.2
        }
    },
    {
        id: 4,
        name: 'Milk', 
        prices: {
           id: 11,
           store: { _id: 2, name : 'CVS' }, 
           price: 4.0
        },
    }
]

为解决此问题,我添加了最后一部分:

{ $group: { _id: '$id', doc: { $first: '$$ROOT' } } },
{ $replaceRoot: { newRoot: '$doc' } }

但是最后一部分仅返回以下内容:

{
    id: 4,
    name: 'Milk', 
    prices: {
        id: 10,
        store: { _id: 1, name : 'Walmart' }, 
        price: 3.2
    }
}

现在prices是一个对象,它应该是一个数组,并且应包含所有价格(本例中为2)。

问题

如何返回所有价格(以数组的形式),其中商店字段由storeId填充和过滤?

预期结果:

{
    id: 4,
    name: 'Milk', 
    prices: [
    {
        id: 10,
        store: { _id: 1, name : 'Walmart' }, 
        price: 3.2
    },
    {
        id: 11,
        store: { _id: 2, name : 'CVS' }, 
        price: 4.0
    }]
}

编辑

我想过滤包含给定商店价格的产品。它应该以所有价格返回产品。

1 个答案:

答案 0 :(得分:0)

我并不完全相信您现有的管道是最佳的,但是如果没有样本数据来工作就很难说清楚。因此,从已有的工作着手:

使用$ unwind

var pipeline =  [
    // { $unwind: '$prices' }, // note: should not need this past MongoDB 3.0
    { $lookup: {
        from: 'prices',
        localField: 'prices',
        foreignField: '_id',
        as: 'prices'
     }},
     { $unwind: '$prices' },
     { $lookup: {
        from: 'stores',
        localField: 'prices.store',
        foreignField: '_id',
        as: 'prices.store'
      }},
      // Changes from here
      { $unwind: '$prices.store' },
      { $match: {'prices.store._id': mongoose.Types.ObjectId(storeId) } },
      { $group: {
        _id: '$_id',
        name: { $first: '$name' },
        prices: { $push: '$prices' }
      }}
];

要点始于:

  • 初始$unwind -不需要。仅在非常早的MongoDB 3.0版本中,才需要在这些值上使用$lookup之前$unwind个值数组。

  • $unwind之后的
  • $lookup -如果期望“单个”对象匹配,则始终需要该字段,因为$lookup始终返回一个数组。

  • $match之后的
  • $unwind -实际上是管道处理的“优化” ,实际上是< em>“过滤器” 。如果没有$unwind,这只是在验证“那里有东西” ,但不会删除不匹配的项目。

  • $push 中的
  • $group-这是重建"prices"数组的实际部分。

您基本上缺少的要点是将$first用于“整个文档”内容。您确实从未想要过,即使您不只是想要"name",也总是想$push "prices"

实际上,您可能确实希望原始文档中的字段不只是name,但实际上您应该使用以下格式。

富有表现力的$ lookup

自MongoDB 3.6起,大多数现代MongoDB版本都提供了替代方法,坦率地说,您至少应使用:

var pipeline =  [
    { $lookup: {
        from: 'prices',
        let: { prices: '$prices' },
        pipeline: [
          { $match: {
            store: mongoose.Types.ObjectId(storeId),
            $expr: { $in: [ '$_id', '$$prices' ] }
          }},
          { $lookup: {
            from: 'stores',
            let: { store: '$store' },
            pipeline: [
              { $match: { $expr: { $eq: [ '$_id', '$$store' ] } }
            ],
            as: 'store'
          }},
          { $unwind: '$store' }
        ],
        as: 'prices'
    }},
    // remove results with no matching prices
    { $match: { 'prices.0': { $exists: true } } }
];         

因此,首先要注意的是“外部” pipeline实际上只是一个$lookup阶段,因为它真正需要做的只是“加入” prices采集。从加入原始集合的角度来看,这也是正确的,因为上面示例中的附加$lookup实际上是从prices与另一个集合相关的。

这正是此新表单的工作方式,因此,不要在结果数组上使用$unwind,而是在联接之后仅 个匹配“价格”的项目“加入”到“商店”集合,并且在之前将它们返回到数组中。当然,由于与“商店”之间存在“一对一”关系,因此实际上$unwind

简而言之,此输出仅包含原始文档,其中包含"prices"数组。因此,无需通过$group进行重构,也无需混淆您使用的$first和您的$push


  

注意:我对您的“过滤器存储”语句并试图匹配store集合中显示的"prices"字段有点怀疑。即使您指定相等匹配,问题也会显示来自两个不同商店的预期输出。

     

如果我怀疑您可能的意思是“商店清单” ,它将更像是:

store: { $in: storeList.map(store => mongoose.Types.ObjectId(store)) }
     

在这两种情况下,您将如何处理“字符串列表” ,请使用$in与“ list”进行匹配,并使用Array.map()进行处理提供的列表,并分别返回ObjectId()个值。

     

提示:使用mongoose时,您使用的是“模型”而不是集合名称,实际的MongoDB集合名称通常是您注册的模型名称的复数形式。

     

因此,您不必为$lookup的实际集合名称进行“硬编码”,只需使用:

   Model.collection.name

.collection.name是所有模型上的可访问属性,可以为您省去为$lookup实际命名集合的麻烦。如果您通过更改MongoDB更改存储的集合名称的方式来更改mongoose.model()实例注册,也可以保护您。


完整演示

以下是一个独立的清单,展示了两种方法都可以正常工作,以及它们如何产生相同的结果:

const { Schema, Types: { ObjectId } } = mongoose = require('mongoose');

const uri = 'mongodb://localhost:27017/shopping';
const opts = { useNewUrlParser: true };

mongoose.set('useFindAndModify', false);
mongoose.set('useCreateIndex', true);
mongoose.set('debug', true);

const storeSchema = new Schema({
  name: { type: String }
});

const priceSchema = new Schema({
  price: { type: Number },
  store: { type: Schema.Types.ObjectId, ref: 'Store' }
});

const productSchema = new Schema({
  name: { type: String },
  prices: [{ type: Schema.Types.ObjectId, ref: 'Price' }]
});

const Store = mongoose.model('Store', storeSchema);
const Price = mongoose.model('Price', priceSchema);
const Product = mongoose.model('Product', productSchema);

const log = data => console.log(JSON.stringify(data, undefined, 2));

(async function() {

  try {

    const conn = await mongoose.connect(uri, opts);

    // Clean data
    await Promise.all(
      Object.entries(conn.models).map(([k, m]) => m.deleteMany())
    );

    // Insert working data

    let [StoreA, StoreB, StoreC] = await Store.insertMany(
      ["StoreA", "StoreB", "StoreC"].map(name => ({ name }))
    );


    let [PriceA, PriceB, PriceC, PriceD, PriceE, PriceF]
      = await Price.insertMany(
          [[StoreA,1],[StoreB,2],[StoreA,3],[StoreC,4],[StoreB,5],[StoreC,6]]
            .map(([store, price]) => ({ price, store }))
        );


    let [Milk, Cheese, Bread] = await Product.insertMany(
      [
        { name: 'Milk', prices: [PriceA, PriceB] },
        { name: 'Cheese', prices: [PriceC, PriceD] },
        { name: 'Bread', prices: [PriceE, PriceF] }
      ]
    );


    // Test 1
    {
      log("Single Store - expressive")
      const pipeline = [
        { '$lookup': {
          'from': Price.collection.name,
          'let': { prices: '$prices' },
          'pipeline': [
            { '$match': {
              'store': ObjectId(StoreA._id),  // demo - it's already an ObjectId
              '$expr': { '$in': [ '$_id', '$$prices' ] }
            }},
            { '$lookup': {
              'from': Store.collection.name,
              'let': { store: '$store' },
              'pipeline': [
                { '$match': { '$expr': { '$eq': [ '$_id', '$$store' ] } } }
              ],
              'as': 'store'
            }},
            { '$unwind': '$store' }
          ],
          as: 'prices'
        }},
        { '$match': { 'prices.0': { '$exists': true } } }
      ];

      let result = await Product.aggregate(pipeline);
      log(result);
    }

    // Test 2
    {
      log("Dual Store - expressive");
      const pipeline = [
        { '$lookup': {
          'from': Price.collection.name,
          'let': { prices: '$prices' },
          'pipeline': [
            { '$match': {
              'store': { '$in': [StoreA._id, StoreB._id] },
              '$expr': { '$in': [ '$_id', '$$prices' ] }
            }},
            { '$lookup': {
              'from': Store.collection.name,
              'let': { store: '$store' },
              'pipeline': [
                { '$match': { '$expr': { '$eq': [ '$_id', '$$store' ] } } }
              ],
              'as': 'store'
            }},
            { '$unwind': '$store' }
          ],
          as: 'prices'
        }},
        { '$match': { 'prices.0': { '$exists': true } } }
      ];

      let result = await Product.aggregate(pipeline);
      log(result);
    }

    // Test 3
    {
      log("Single Store - legacy");
      const pipeline = [
        { '$lookup': {
          'from': Price.collection.name,
          'localField': 'prices',
          'foreignField': '_id',
          'as': 'prices'
        }},
        { '$unwind': '$prices' },
        // Alternately $match can be done here
        // { '$match': { 'prices.store': StoreA._id } },

        { '$lookup': {
          'from': Store.collection.name,
          'localField': 'prices.store',
          'foreignField': '_id',
          'as': 'prices.store'
        }},
        { '$unwind': '$prices.store' },
        { '$match': { 'prices.store._id': StoreA._id } },
        { '$group': {
          '_id': '$_id',
          'name': { '$first': '$name' },
          'prices': { '$push': '$prices' }
        }}
      ];

      let result = await Product.aggregate(pipeline);
      log(result);
    }

    // Test 4
    {
      log("Dual Store - legacy");
      const pipeline = [
        { '$lookup': {
          'from': Price.collection.name,
          'localField': 'prices',
          'foreignField': '_id',
          'as': 'prices'
        }},
        { '$unwind': '$prices' },
        // Alternately $match can be done here
        { '$match': { 'prices.store': { '$in': [StoreA._id, StoreB._id] } } },

        { '$lookup': {
          'from': Store.collection.name,
          'localField': 'prices.store',
          'foreignField': '_id',
          'as': 'prices.store'
        }},
        { '$unwind': '$prices.store' },
        //{ '$match': { 'prices.store._id': { '$in': [StoreA._id, StoreB._id] } } },
        { '$group': {
          '_id': '$_id',
          'name': { '$first': '$name' },
          'prices': { '$push': '$prices' }
        }}
      ];

      let result = await Product.aggregate(pipeline);
      log(result);
    }

  } catch(e) {
    console.error(e);
  } finally {
    mongoose.disconnect();
  }


})()

哪个产生输出:

Mongoose: stores.deleteMany({}, {})
Mongoose: prices.deleteMany({}, {})
Mongoose: products.deleteMany({}, {})
Mongoose: stores.insertMany([ { _id: 5c7c79bcc78675135c09f54b, name: 'StoreA', __v: 0 }, { _id: 5c7c79bcc78675135c09f54c, name: 'StoreB', __v: 0 }, { _id: 5c7c79bcc78675135c09f54d, name: 'StoreC', __v: 0 } ], {})
Mongoose: prices.insertMany([ { _id: 5c7c79bcc78675135c09f54e, price: 1, store: 5c7c79bcc78675135c09f54b, __v: 0 }, { _id: 5c7c79bcc78675135c09f54f, price: 2, store: 5c7c79bcc78675135c09f54c, __v: 0 }, { _id: 5c7c79bcc78675135c09f550, price: 3, store: 5c7c79bcc78675135c09f54b, __v: 0 }, { _id: 5c7c79bcc78675135c09f551, price: 4, store: 5c7c79bcc78675135c09f54d, __v: 0 }, { _id: 5c7c79bcc78675135c09f552, price: 5, store: 5c7c79bcc78675135c09f54c, __v: 0 }, { _id: 5c7c79bcc78675135c09f553, price: 6, store: 5c7c79bcc78675135c09f54d, __v: 0 } ], {})
Mongoose: products.insertMany([ { prices: [ 5c7c79bcc78675135c09f54e, 5c7c79bcc78675135c09f54f ], _id: 5c7c79bcc78675135c09f554, name: 'Milk', __v: 0 }, { prices: [ 5c7c79bcc78675135c09f550, 5c7c79bcc78675135c09f551 ], _id: 5c7c79bcc78675135c09f555, name: 'Cheese', __v: 0 }, { prices: [ 5c7c79bcc78675135c09f552, 5c7c79bcc78675135c09f553 ], _id: 5c7c79bcc78675135c09f556, name: 'Bread', __v: 0 } ], {})
"Single Store - expressive"
Mongoose: products.aggregate([ { '$lookup': { from: 'prices', let: { prices: '$prices' }, pipeline: [ { '$match': { store: 5c7c79bcc78675135c09f54b, '$expr': { '$in': [ '$_id', '$$prices' ] } } }, { '$lookup': { from: 'stores', let: { store: '$store' }, pipeline: [ { '$match': { '$expr': { '$eq': [ '$_id', '$$store' ] } } } ], as: 'store' } }, { '$unwind': '$store' } ], as: 'prices' } }, { '$match': { 'prices.0': { '$exists': true } } } ], {})
[
  {
    "_id": "5c7c79bcc78675135c09f554",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f54e",
        "price": 1,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      }
    ],
    "name": "Milk",
    "__v": 0
  },
  {
    "_id": "5c7c79bcc78675135c09f555",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f550",
        "price": 3,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      }
    ],
    "name": "Cheese",
    "__v": 0
  }
]
"Dual Store - expressive"
Mongoose: products.aggregate([ { '$lookup': { from: 'prices', let: { prices: '$prices' }, pipeline: [ { '$match': { store: { '$in': [ 5c7c79bcc78675135c09f54b, 5c7c79bcc78675135c09f54c ] }, '$expr': { '$in': [ '$_id', '$$prices' ] } } }, { '$lookup': { from: 'stores', let: { store: '$store' }, pipeline: [ { '$match': { '$expr': { '$eq': [ '$_id', '$$store' ] } } } ], as: 'store' } }, { '$unwind': '$store' } ], as: 'prices' } }, { '$match': { 'prices.0': { '$exists': true } } } ], {})
[
  {
    "_id": "5c7c79bcc78675135c09f554",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f54e",
        "price": 1,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      },
      {
        "_id": "5c7c79bcc78675135c09f54f",
        "price": 2,
        "store": {
          "_id": "5c7c79bcc78675135c09f54c",
          "name": "StoreB",
          "__v": 0
        },
        "__v": 0
      }
    ],
    "name": "Milk",
    "__v": 0
  },
  {
    "_id": "5c7c79bcc78675135c09f555",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f550",
        "price": 3,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      }
    ],
    "name": "Cheese",
    "__v": 0
  },
  {
    "_id": "5c7c79bcc78675135c09f556",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f552",
        "price": 5,
        "store": {
          "_id": "5c7c79bcc78675135c09f54c",
          "name": "StoreB",
          "__v": 0
        },
        "__v": 0
      }
    ],
    "name": "Bread",
    "__v": 0
  }
]
"Single Store - legacy"
Mongoose: products.aggregate([ { '$lookup': { from: 'prices', localField: 'prices', foreignField: '_id', as: 'prices' } }, { '$unwind': '$prices' }, { '$lookup': { from: 'stores', localField: 'prices.store', foreignField: '_id', as: 'prices.store' } }, { '$unwind': '$prices.store' }, { '$match': { 'prices.store._id': 5c7c79bcc78675135c09f54b } }, { '$group': { _id: '$_id', name: { '$first': '$name' }, prices: { '$push': '$prices' } } } ], {})
[
  {
    "_id": "5c7c79bcc78675135c09f555",
    "name": "Cheese",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f550",
        "price": 3,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      }
    ]
  },
  {
    "_id": "5c7c79bcc78675135c09f554",
    "name": "Milk",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f54e",
        "price": 1,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      }
    ]
  }
]
"Dual Store - legacy"
Mongoose: products.aggregate([ { '$lookup': { from: 'prices', localField: 'prices', foreignField: '_id', as: 'prices' } }, { '$unwind': '$prices' }, { '$match': { 'prices.store': { '$in': [ 5c7c79bcc78675135c09f54b, 5c7c79bcc78675135c09f54c ] } } }, { '$lookup': { from: 'stores', localField: 'prices.store', foreignField: '_id', as: 'prices.store' } }, { '$unwind': '$prices.store' }, { '$group': { _id: '$_id', name: { '$first': '$name' }, prices: { '$push': '$prices' } } } ], {})
[
  {
    "_id": "5c7c79bcc78675135c09f555",
    "name": "Cheese",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f550",
        "price": 3,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      }
    ]
  },
  {
    "_id": "5c7c79bcc78675135c09f556",
    "name": "Bread",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f552",
        "price": 5,
        "store": {
          "_id": "5c7c79bcc78675135c09f54c",
          "name": "StoreB",
          "__v": 0
        },
        "__v": 0
      }
    ]
  },
  {
    "_id": "5c7c79bcc78675135c09f554",
    "name": "Milk",
    "prices": [
      {
        "_id": "5c7c79bcc78675135c09f54e",
        "price": 1,
        "store": {
          "_id": "5c7c79bcc78675135c09f54b",
          "name": "StoreA",
          "__v": 0
        },
        "__v": 0
      },
      {
        "_id": "5c7c79bcc78675135c09f54f",
        "price": 2,
        "store": {
          "_id": "5c7c79bcc78675135c09f54c",
          "name": "StoreB",
          "__v": 0
        },
        "__v": 0
      }
    ]
  }
]