在嵌套数组中仅返回匹配的子文档元素

105

主要的集合是 retailer,其中包含一个 stores 数组。每个 store 包含一个 offers 数组 (可以在这个店里购买)。这个 offers 数组又包含了一个 size 数组。(见下面的例子)

现在我要找到所有尺码为 L 的可用 offer。

{
    "_id" : ObjectId("56f277b1279871c20b8b4567"),
    "stores" : [
        {
        "_id" : ObjectId("56f277b5279871c20b8b4783"),
        "offers" : [
            {
                "_id" : ObjectId("56f277b1279871c20b8b4567"),
                "size": [
                    "XS",
                    "S",
                    "M"
                ]
            },
            {
                "_id" : ObjectId("56f277b1279871c20b8b4567"),
                "size": [
                    "S",
                    "L",
                    "XL"
                ]
            }
        ]
    }
}

我尝试了这个查询:db.getCollection('retailers').find({'stores.offers.size': 'L'})

我期望得到类似下面的输出:

 {
"_id" : ObjectId("56f277b1279871c20b8b4567"),
"stores" : [
    {
        "_id" : ObjectId("56f277b5279871c20b8b4783"),
        "offers" : [
            {
                "_id" : ObjectId("56f277b1279871c20b8b4567"),
                "size": [
                    "S",
                    "L",
                    "XL"
                ]
            }
        ]
    }
}

但是我的查询结果还包括了与size为XS、X和M不匹配的报价。

我如何强制MongoDB仅返回符合我的查询条件的报价?

问候和感谢。


你的意思是这样吗? db.getCollection('retailers').find({'stores.offers.size': 'L'}, {'stores.offers': 1})。 但是响应结果也包含了错误的优惠。 - Vico
1
我应该在我的问题中使用$match$unwind聚合吗? - Vico
3个回答

170

你实际上选择了"文档",这是查询应该做的。但你要找的是"过滤包含的数组",以使返回的元素只匹配查询条件。

答案当然是,除非通过过滤掉这样的细节可以节省大量带宽,否则你甚至不应该尝试,或者至少超出第一个位置匹配。

MongoDB有一个位置$运算符,将返回与查询条件匹配的索引处的数组元素。然而,这仅返回"外部"最多数组元素的"第一个"匹配索引。

db.getCollection('retailers').find(
    { 'stores.offers.size': 'L'},
    { 'stores.$': 1 }
)

在这种情况下,它只意味着"stores"数组位置。因此,如果有多个"stores"条目,则只会返回包含匹配条件的元素中的"一个"。但是,这对于"offers"内部数组没有任何作用,因此在匹配的"stores"数组中的每个"offer"仍将被返回。

MongoDB没有标准查询的"过滤"方法,因此以下内容无法正常工作:

db.getCollection('retailers').find(
    { 'stores.offers.size': 'L'},
    { 'stores.$.offers.$': 1 }
)

MongoDB仅拥有聚合框架来进行此类操作。但分析应该向您展示为什么您“可能”不应该这样做,而是在代码中过滤数组。


按版本顺序列出如何实现此操作:

首先,使用MongoDB 3.2.x并使用$filter操作:

db.getCollection('retailers').aggregate([
  { "$match": { "stores.offers.size": "L" } },
  { "$project": {
    "stores": {
      "$filter": {
        "input": {
          "$map": {
            "input": "$stores",
            "as": "store",
            "in": {
              "_id": "$$store._id",
              "offers": {
                "$filter": {
                  "input": "$$store.offers",
                  "as": "offer",
                  "cond": {
                    "$setIsSubset":  [ ["L"], "$$offer.size" ]
                  }
                }
              }
            }
          }
        },
        "as": "store",
        "cond": { "$ne": [ "$$store.offers", [] ]}
      }
    }
  }}
])

使用MongoDB 2.6.x及以上版本,结合$map$setDifference

db.getCollection('retailers').aggregate([
  { "$match": { "stores.offers.size": "L" } },
  { "$project": {
    "stores": {
      "$setDifference": [
        { "$map": {
          "input": {
            "$map": {
              "input": "$stores",
              "as": "store",
              "in": {
                "_id": "$$store._id",
                "offers": {
                  "$setDifference": [
                    { "$map": {
                      "input": "$$store.offers",
                      "as": "offer",
                      "in": {
                        "$cond": {
                          "if": { "$setIsSubset": [ ["L"], "$$offer.size" ] },
                          "then": "$$offer",
                          "else": false
                        }
                      }
                    }},
                    [false]
                  ]
                }
              }
            }
          },
          "as": "store",
          "in": {
            "$cond": {
              "if": { "$ne": [ "$$store.offers", [] ] },
              "then": "$$store",
              "else": false
            }
          }
        }},
        [false]
      ]
    }
  }}
])

最后,在任何版本MongoDB 2.2.x以上,聚合框架被引入。

db.getCollection('retailers').aggregate([
  { "$match": { "stores.offers.size": "L" } },
  { "$unwind": "$stores" },
  { "$unwind": "$stores.offers" },
  { "$match": { "stores.offers.size": "L" } },
  { "$group": {
    "_id": {
      "_id": "$_id",
      "storeId": "$stores._id",
    },
    "offers": { "$push": "$stores.offers" }
  }},
  { "$group": {
    "_id": "$_id._id",
    "stores": {
      "$push": {
        "_id": "$_id.storeId",
        "offers": "$offers"
      }
    }
  }}
])

让我们分解一下这些解释。

MongoDB 3.2.x及更高版本

一般来说,$filter是适合这种情况的方式,因为它是专门为此目的设计的。由于数组有多个级别,所以需要在每个级别上应用此过程。因此,首先要进入每个"stores"中的每个"offers"进行检查并$filter该内容。

这里的简单比较是:""size"数组是否包含我正在寻找的元素"。在这个逻辑上下文中,简单的方法是使用$setIsSubset操作将一个["L"]的数组("set")与目标数组进行比较。当条件为true(它包含"L")时,"offers"的数组元素会被保留并返回到结果中。

在更高层次的$filter中,您需要查看前一个$filter是否为"offers"返回了一个空数组[]。如果不为空,则返回该元素;否则将其删除。

MongoDB 2.6.x

这与现代过程非常相似,只是这个版本中没有$filter,因此可以使用$map检查每个元素,然后使用$setDifference过滤掉返回为false的任何元素。

$map将返回整个数组,但是$cond操作只决定是否返回元素或返回false值。在将$setDifference与单个元素"set" [false]进行比较时,返回数组中所有false元素都将被删除。

在其他方面,逻辑与上述相同。

MongoDB 2.2.x及更高版本

因此,在MongoDB 2.6以下,唯一处理数组的工具是$unwind,仅出于这个目的,您不应该“仅”使用聚合框架来进行此操作。

这个过程看起来很简单,只需将每个数组“拆开”,过滤掉不需要的内容,然后重新组合即可。主要关注点在“两个”$group阶段,第一个阶段重新构建内部数组,第二个阶段重新构建外部数组。在所有级别上都有不同的_id值,因此这些值只需要在每个分组级别中包含即可。

但问题在于,$unwind的成本非常高昂。虽然它确实有目的,但它的主要用途并不是像这样对每个文档进行过滤。事实上,在现代版本中,它的唯一用途应该是当数组的某个元素需要成为“分组键”本身的一部分时。


结论

因此,要在多个数组级别上获取匹配项并不是一个简单的过程,实际上,如果实现不当,它可能非常昂贵。

仅应使用两个现代列表来达到此目的,因为它们在“查询”$match之外还使用了“单个”管道阶段来执行“过滤”操作。由此产生的效果比.find()的标准形式增加了一点开销。

总的来说,这些列表仍然具有一定的复杂性,实际上,除非您通过这种方式显着减少了通过服务器和客户端之间使用的带宽来返回的内容,否则最好还是过滤初始查询和基本投影的结果。

db.getCollection('retailers').find(
    { 'stores.offers.size': 'L'},
    { 'stores.$': 1 }
).forEach(function(doc) {
    // Technically this is only "one" store. So omit the projection
    // if you wanted more than "one" match
    doc.stores = doc.stores.filter(function(store) {
        store.offers = store.offers.filter(function(offer) {
            return offer.size.indexOf("L") != -1;
        });
        return store.offers.length != 0;
    });
    printjson(doc);
})

使用返回的"post"对象进行查询处理远比使用聚合管道来完成更加简单明了。如前所述,唯一的“真正”差异在于当接收到数据时,您放弃了“服务器”上的其他元素,而不是“按文档”删除它们,这可能会节省一些带宽。

但是,除非您在仅具有$match$project的现代版本中执行此操作,否则在服务器上处理的“成本”将大大超过通过首先剥离未匹配元素来减少网络开销所获得的“利润”。

在所有情况下,您都会得到相同的结果:

{
        "_id" : ObjectId("56f277b1279871c20b8b4567"),
        "stores" : [
                {
                        "_id" : ObjectId("56f277b5279871c20b8b4783"),
                        "offers" : [
                                {
                                        "_id" : ObjectId("56f277b1279871c20b8b4567"),
                                        "size" : [
                                                "S",
                                                "L",
                                                "XL"
                                        ]
                                }
                        ]
                }
        ]
}

1
我已经实现了与此非常相似的东西(不同之处在于我需要找到这个示例中“size”的精确数组匹配),实际上对于只有几个文档(而不是1000个或100万个)来说效率并不高,计算需要超过5秒。看看后处理是否更有效。 - dter
1
或者,如果将大小作为单独的集合而不是嵌套数组来维护,那么这样做不仅可以提高性能,还可以提供惊人的查询功能。 - PirateApp
谢谢,对我来说关键是“unwind”运算符。现在我明白了。 - Stefano Scarpanti
谢谢你的回答!它也帮助了我 :) - Andrew T
现在它在Mongo 4.4中无法工作。db.inventory.find({}, {size: 1, "size.uom": 1}) // 从4.4开始无效参考 - https://docs.mongodb.com/manual/release-notes/4.4-compatibility/ - krishna

17

由于您的数组是嵌套的,所以我们无法使用 $elemMatch,您可以使用聚合框架来获得您的结果:

db.retailers.aggregate([
{$match:{"stores.offers.size": 'L'}}, //just precondition can be skipped
{$unwind:"$stores"},
{$unwind:"$stores.offers"},
{$match:{"stores.offers.size": 'L'}},
{$group:{
    _id:{id:"$_id", "storesId":"$stores._id"},
    "offers":{$push:"$stores.offers"}
}},
{$group:{
    _id:"$_id.id",
    stores:{$push:{_id:"$_id.storesId","offers":"$offers"}}
}}
]).pretty()

这个查询的作用是展开数组(两次),然后匹配大小,最后将文档重塑为以前的形式。您可以删除 $group 步骤并查看它如何打印。


2

它也可以不使用聚合操作。以下是解决方案链接:https://mongoplayground.net/p/Q5lxPvGK03A

db.collection.find({
  "stores.offers.size": "L"
},
{
  "stores": {
    "$filter": {
      "input": {
        "$map": {
          "input": "$stores",
          "as": "store",
          "in": {
            "_id": "$$store._id",
            "offers": {
              "$filter": {
                "input": "$$store.offers",
                "as": "offer",
                "cond": {
                  "$setIsSubset": [
                    [
                      "L"
                    ],
                    "$$offer.size"
                  ]
                }
              }
            }
          }
        }
      },
      "as": "store",
      "cond": {
        "$ne": [
          "$$store.offers",
          []
        ]
      }
    }
  }
})

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接