【问题标题】:Filter by joined sub-document按加入的子文档过滤
【发布时间】:2019-03-04 09:07:18
【问题描述】:

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

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)。

问题

如何返回所有价格(作为一个数组),其中 store 字段由 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
    }]
}

编辑

我想过滤包含给定商店中价格的产品。它应该将产品及其价格全部退回。

【问题讨论】:

  • 您说要“按商店过滤”似乎自相矛盾,但随后您在输出中显示了不同的商店。此外,$lookup always 返回一个数组,因此您在 $lookup 之后的 $match 不会按照您的想法或您声称的那样做。我觉得你在这里进行了大量的编辑,而且重要的细节也被忽略了。您可以澄清一下,并确保呈现的逻辑确实是您想要的。
  • 我想过滤包含商店的产品。例如查找在沃尔玛有价格的所有产品。

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


【解决方案1】:

我并不完全相信您现有的管道是最优化的,但如果没有样本数据可用于工作,很难真正分辨出其他情况。所以从你所拥有的开始:

使用 $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 after $unwind - 实际上是管道处理的“优化”,实际上是“过滤”的要求时间>。如果没有$unwind,它只是验证“有东西”,但不匹配的项目不会被删除。

  • $push in $group - 这是重新构建 "prices"array 的实际部分。

您基本上缺少的关键点是将$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,然后在连接上进行操作,只有“价格”的匹配项然后被“连接” " 到 "stores" 集合,before 将它们返回到数组中。当然既然和“store”是“一对一”的关系,这其实就是$unwind

简而言之,它的输出只是包含带有"prices" 数组的原始文档。因此,无需通过$group 重新构建,也无需混淆您使用的$first 和您使用的$push


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

如果我怀疑你有什么可能是指一个“商店列表”,它更像是:

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

在这两种情况下,您将如何处理 “字符串列表”,使用 $in 匹配“列表”,使用 Array.map() 处理提供的列表和将每个返回为 ObjectId() 值。

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

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

   Model.collection.name

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


完整演示

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

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
      }
    ]
  }
]

【讨论】:

  • 第一种方法有效,但优化后的提案返回所有价格为空数组的值。关于问题的混淆,我希望产品包含来自给定商店的价格且文档保持不变(数组中的所有价格不仅仅是与 storeId 匹配的价格)。
  • 优化管道中的第一部分做了什么: { $match: { Supermarket: mongoose.Types.ObjectId(id), $expr: { $in: ['$_id', '$ $prices'] } } }
  • @DiegoGallegos 添加了一个完整列表,显示了两种方法在实践中的工作示例,以及“单个”和“多个”商店匹配。这应该可以帮助您更好地理解这一点。正如所包含的结果所示,它们确实都有效。请注意,您的“所有这些”声明依赖于“同一商店”中具有多个价格的产品。这就是提出的逻辑。我认为您根本不需要任何商店过滤,因此您应该尝试省略过滤的行。代码 cmets 应该可以帮助您做到这一点。
猜你喜欢
  • 2015-07-18
  • 1970-01-01
  • 2017-10-07
  • 2023-03-20
  • 2019-07-13
  • 2020-02-23
  • 1970-01-01
  • 1970-01-01
  • 2020-12-29
相关资源
最近更新 更多