【问题标题】:$lookup multiple levels without $unwind?$lookup 没有 $unwind 的多个级别?
【发布时间】:2021-06-11 22:48:44
【问题描述】:

我有以下收藏:

  • 场地集合
{    "_id" : ObjectId("5acdb8f65ea63a27c1facf86"),
     "name" : "ASA College - Manhattan Campus",
     "addedBy" : ObjectId("5ac8ba3582c2345af70d4658"),
     "reviews" : [ 
         ObjectId("5acdb8f65ea63a27c1facf8b"), 
         ObjectId("5ad8288ccdd9241781dce698")
     ] 
}
  • 评论集
{     "_id" : ObjectId("5acdb8f65ea63a27c1facf8b"),
      "createdAt" : ISODate("2018-04-07T12:31:49.503Z"),
      "venue" : ObjectId("5acdb8f65ea63a27c1facf86"),
      "author" : ObjectId("5ac8ba3582c2345af70d4658"),
      "content" : "nice place",
      "comments" : [ 
          ObjectId("5ad87113882d445c5cbc92c8")
      ]
 }
  • 评论收集
{     "_id" : ObjectId("5ad87113882d445c5cbc92c8"),
      "author" : ObjectId("5ac8ba3582c2345af70d4658"),
      "comment" : "dcfdsfdcfdsfdcfdsfdcfdsfdcfdsfdcfdsfdcfdsfdcfdsf",
      "review" : ObjectId("5acdb8f65ea63a27c1facf8b"),
      "__v" : 0
}
  • 作者合集
{    "_id" : ObjectId("5ac8ba3582c2345af70d4658"),
     "firstName" : "Bruce",
     "lastName" : "Wayne",
     "email" : "bruce@linkites.com",
     "followers" : [ObjectId("5ac8b91482c2345af70d4650")]
}

现在以下填充查询工作正常

    const venues = await Venue.findOne({ _id: id.id })
    .populate({
      path: 'reviews',
      options: { sort: { createdAt: -1 } },
      populate: [
        {  path: 'author'  },
        {  path: 'comments', populate: [{ path: 'author' }] }
      ]
    })

但是,我想通过$lookup 查询来实现它,但是当我对评论执行“$unwind”时,它会拆分场地......我想要相同数组(如填充)和相同顺序的评论。 ..

我想使用$lookup 实现以下查询,因为作者有关注者字段,所以我需要通过$project 发送字段isFollow,而使用populate 无法完成...

$project: {
    isFollow: { $in: [mongoose.Types.ObjectId(req.user.id), '$followers'] }
}

【问题讨论】:

  • $lookup 不会正确替换超过一级深度的对象。您可以在 MongoDB 3.6 中“荒谬”地做到这一点,尽管我个人认为这并不直观。通常该过程将是$unwind,然后使用$group重建回数组。
  • @NeilLunn 我主要关心的是isFollow 密钥...我可以使用填充发送该密钥...还是有其他方法可以做到这一点?

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


【解决方案1】:

根据您可用的 MongoDB 版本,当然有几种方法。这些变化从$lookup 的不同用法到通过.lean().populate() 结果启用对象操作。

我确实要求您仔细阅读这些部分,并注意在考虑您的实施解决方案时,所有内容可能并不像看起来那样。

MongoDB 3.6,“嵌套”$lookup

在 MongoDB 3.6 中,$lookup 运算符获得了包含 pipeline 表达式的额外功能,而不是简单地将“本地”键值连接到“外部”键值,这意味着您基本上可以执行每个 $lookup在这些管道表达式中“嵌套”

Venue.aggregate([
  { "$match": { "_id": mongoose.Types.ObjectId(id.id) } },
  { "$lookup": {
    "from": Review.collection.name,
    "let": { "reviews": "$reviews" },
    "pipeline": [
       { "$match": { "$expr": { "$in": [ "$_id", "$$reviews" ] } } },
       { "$lookup": {
         "from": Comment.collection.name,
         "let": { "comments": "$comments" },
         "pipeline": [
           { "$match": { "$expr": { "$in": [ "$_id", "$$comments" ] } } },
           { "$lookup": {
             "from": Author.collection.name,
             "let": { "author": "$author" },
             "pipeline": [
               { "$match": { "$expr": { "$eq": [ "$_id", "$$author" ] } } },
               { "$addFields": {
                 "isFollower": { 
                   "$in": [ 
                     mongoose.Types.ObjectId(req.user.id),
                     "$followers"
                   ]
                 }
               }}
             ],
             "as": "author"
           }},
           { "$addFields": { 
             "author": { "$arrayElemAt": [ "$author", 0 ] }
           }}
         ],
         "as": "comments"
       }},
       { "$sort": { "createdAt": -1 } }
     ],
     "as": "reviews"
  }},
 ])

这真的很强大,从原始管道的角度来看,它真的只知道将内容添加到 "reviews" 数组,然后每个后续的“嵌套”管道表达式也只看到它是“内部的” " 来自连接的元素。

它很强大,在某些方面它可能更清晰一些,因为所有字段路径都与嵌套级别相关,但它确实会在 BSON 结构中开始缩进,你需要知道你是否是遍历结构时匹配数组或奇异值。

请注意,我们还可以在此处执行诸如“扁平化作者属性”之类的操作,如在 "comments" 数组条目中所见。所有$lookup 目标输出可能是一个“数组”,但在“子管道”中,我们可以将该单个元素数组重新整形为单个值。

标准 MongoDB $lookup

仍然保留“在服务器上加入”,您实际上可以使用$lookup 来完成它,但它只需要中间处理。这是使用$unwind 解构数组并使用$group 阶段来重建数组的长期方法:

Venue.aggregate([
  { "$match": { "_id": mongoose.Types.ObjectId(id.id) } },
  { "$lookup": {
    "from": Review.collection.name,
    "localField": "reviews",
    "foreignField": "_id",
    "as": "reviews"
  }},
  { "$unwind": "$reviews" },
  { "$lookup": {
    "from": Comment.collection.name,
    "localField": "reviews.comments",
    "foreignField": "_id",
    "as": "reviews.comments",
  }},
  { "$unwind": "$reviews.comments" },
  { "$lookup": {
    "from": Author.collection.name,
    "localField": "reviews.comments.author",
    "foreignField": "_id",
    "as": "reviews.comments.author"
  }},
  { "$unwind": "$reviews.comments.author" },
  { "$addFields": {
    "reviews.comments.author.isFollower": {
      "$in": [ 
        mongoose.Types.ObjectId(req.user.id), 
        "$reviews.comments.author.followers"
      ]
    }
  }},
  { "$group": {
    "_id": { 
      "_id": "$_id",
      "reviewId": "$review._id"
    },
    "name": { "$first": "$name" },
    "addedBy": { "$first": "$addedBy" },
    "review": {
      "$first": {
        "_id": "$review._id",
        "createdAt": "$review.createdAt",
        "venue": "$review.venue",
        "author": "$review.author",
        "content": "$review.content"
      }
    },
    "comments": { "$push": "$reviews.comments" }
  }},
  { "$sort": { "_id._id": 1, "review.createdAt": -1 } },
  { "$group": {
    "_id": "$_id._id",
    "name": { "$first": "$name" },
    "addedBy": { "$first": "$addedBy" },
    "reviews": {
      "$push": {
        "_id": "$review._id",
        "venue": "$review.venue",
        "author": "$review.author",
        "content": "$review.content",
        "comments": "$comments"
      }
    }
  }}
])

这确实不像您最初想象的那样令人生畏,并且随着您在每个阵列中前进,遵循$lookup$unwind 的简单模式。

"author" 细节当然是单数的,所以一旦它“展开”,您只想让它保持这种状态,添加字段并开始“回滚”到数组中的过程。

只有两个级别可以重建回原始Venue 文档,因此第一个详细级别由Review 重建"comments" 数组。您只需要$push"$reviews.comments" 的路径即可收集这些,只要"$reviews._id" 字段位于“分组_id”中,您需要保留的唯一其他内容就是所有其他字段.您也可以将所有这些放入_id,也可以使用$first

完成后,只有一个$group 阶段才能返回Venue 本身。这次分组键当然是"$_id",场地本身的所有属性都使用$first,其余的"$review"细节返回到$push的数组中。当然,前一个$group"$comments" 输出变成了"review.comments" 路径。

处理单个文档及其关系,这并不是那么糟糕。 $unwind 管道运算符一般可能是一个性能问题,但在这种用法的上下文中,它不应该真正造成那么大的影响。

由于数据仍在“加入服务器”,因此仍然的流量远低于其他剩余替代方案。

JavaScript 操作

当然,这里的另一种情况是,您实际上操作的是结果,而不是更改服务器本身的数据。在大多数情况下,我会支持这种方法,因为对数据的任何“添加”可能最好在客户端处理。

当然,使用populate() 的问题在于,虽然它可能“看起来”是一个更加简化的过程,但实际上它不是 JOIN大大地。 populate() 实际上所做的只是“隐藏”向数据库提交多个查询的底层过程,然后通过异步处理等待结果。

所以join的“外观”其实是多次向服务器请求,然后对数据进行“客户端操作”以嵌入细节的结果在数组中。

因此,除了 明确警告 性能特征远不能与服务器 $lookup 相提并论之外,另一个警告当然是结果中的“猫鼬文档”是实际上不是经过进一步操作的纯 JavaScript 对象。

因此,为了采用这种方法,您需要在执行前将 .lean() 方法添加到查询中,以指示 mongoose 返回“普通 JavaScript 对象”而不是使用模式方法强制转换的 Document 类型附在模型上。当然,请注意生成的数据不再可以访问任何与相关模型本身相关联的“实例方法”:

let venue = await Venue.findOne({ _id: id.id })
  .populate({ 
    path: 'reviews', 
    options: { sort: { createdAt: -1 } },
    populate: [
     { path: 'comments', populate: [{ path: 'author' }] }
    ]
  })
  .lean();

现在venue是一个普通的对象,我们可以根据需要进行简单的处理和调整:

venue.reviews = venue.reviews.map( r => 
  ({
    ...r,
    comments: r.comments.map( c =>
      ({
        ...c,
        author: {
          ...c.author,
          isAuthor: c.author.followers.map( f => f.toString() ).indexOf(req.user.id) != -1
        }
      })
    )
  })
);

因此,实际上只是循环遍历每个内部数组,直到您可以在author 详细信息中看到followers 数组的级别。然后可以对存储在该数组中的ObjectId 值进行比较,然后首先使用.map() 返回“字符串”值以与同样是字符串的req.user.id 进行比较(如果不是,则还添加.toString() ),因为通常通过 JavaScript 代码以这种方式比较这些值更容易。

虽然我需要再次强调它“看起来很简单”,但实际上这是您真正想要避免的系统性能的事情,因为这些额外的查询以及服务器和客户端之间的传输成本很高处理时间,甚至由于请求开销,这增加了托管服务提供商之间传输的实际成本。


总结

这些基本上是你可以采取的方法,除了“滚动你自己的”,你自己实际对数据库执行“多个查询”,而不是使用.populate() 是的帮助器。

使用填充输出,您可以像任何其他数据结构一样简单地操作结果中的数据,只要您将.lean() 应用于查询以转换或以其他方式从返回的 mongoose 文档中提取纯对象数据。

虽然聚合方法看起来更复杂,但在服务器上进行这项工作还有“很多”更多优势。可以对较大的结果集进行排序,可以进行计算以进行进一步过滤,当然您会得到 “单个响应” 对服务器发出的 “单个请求”,所有这些都没有额外的开销。

完全有争议的是,管道本身可以简单地基于已经存储在模式中的属性来构建。因此,根据附加的架构编写自己的方法来执行这种“构造”应该不会太困难。

从长远来看,$lookup 当然是更好的解决方案,但您可能需要在初始编码中投入更多的工作,当然,如果您不只是简单地复制此处列出的内容; )

【讨论】:

    猜你喜欢
    • 2019-01-09
    • 2023-03-12
    • 1970-01-01
    • 2019-09-03
    • 1970-01-01
    相关资源
    最近更新 更多