不使用$unwind进行多级$lookup?

14

我有以下的集合:

  • 场馆集合(venue collection)
{    "_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'到评论时它会分割场馆... 我希望评论在同一个数组中(像populate一样)并且按照相同的顺序...

由于作者有关注者字段,因此我需要通过$project发送isFollow字段,这不能使用populate来完成,所以我希望使用以下查询来实现$lookup...

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

1
$lookup不能正确替换超过一层深度的对象。虽然MongoDB 3.6可以以某种“荒谬”的方式做到这一点,但我个人不认为它很直观。通常的处理方法是使用$unwind,然后使用$group重新构建成数组。 - Neil Lunn
@NeilLunn 我的主要关注点在于 isFollow 键... 我能否使用 populate 发送该键值... 或者还有其他方法吗? - Ashh
1个回答

46

当然,具体取决于您可用的MongoDB版本,有几种方法。这些方法从不同用途的$lookup使用到通过.lean().populate()结果上启用对象操作。

我要求您仔细阅读各节,并注意在考虑实现解决方案时,一切可能并非看起来那么简单。

MongoDB 3.6,“嵌套”$lookup

使用MongoDB 3.6,$lookup运算符获得了包含pipeline表达式的附加功能,而不仅仅是将“local”与“foreign”键值连接起来,这意味着您可以在这些管道表达式中本质上执行每个$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将场馆本身的所有属性提取出来,并使用$push将剩余的"$review"细节放回一个数组中。当然,上一个$group的输出"$comments"成为了"review.comments"路径。
在单个文档及其关联作用下,这并不是太糟糕的情况。$unwind管道操作符通常会成为性能问题,但在这种用法的上下文中,它不应该对性能造成太大影响。
由于数据仍在服务器上“连接”,因此与其他剩余替代方案相比,流量仍然要少得多。
JavaScript操作
当然,另一种情况是,你不是在服务器上更改数据,而是操作结果。在大多数情况下,我倾向于这种方法,因为任何对数据的“添加”都最好在客户端处理。
当然,使用populate()的问题在于,虽然它可能看起来更简化,但实际上它根本不是联接。populate()实际上只是“隐藏”了向数据库提交多个查询的基本过程,然后通过异步处理等待结果。
因此,“联接”的“外观”实际上是由于向服务器发出多个请求,然后对数据进行“客户端操作”以嵌入详细信息在数组中。
因此,除了明确警告性能特征远远不及服务器$lookup之外,另一个警告当然是结果中的“mongoose文档”实际上不是普通的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数组的级别。然后,可以使用.map()返回用于与该数组中存储的ObjectId值进行比较的“字符串”值,首先将其与req.user.id进行比较,后者也是一个字符串(如果不是,则还需添加.toString()),因为通常使用JavaScript代码以这种方式比较这些值更容易。
但再次强调,这“看起来简单”,但实际上这是您真正想要避免系统性能问题的事情,因为那些额外的查询和服务器和客户端之间的传输会花费大量处理时间,甚至由于请求开销,这增加了在托管提供商之间的传输的实际成本。

总结

除了自己编写代码实际执行多个查询而不使用.populate()帮助程序之外,这些基本上是您可以采取的方法。

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

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

可以完全争论管道本身是否可以基于已存储在模式上的属性进行构建。因此,根据所附的模式编写自己的方法来执行此“构建”可能不太困难。

长期来看,$lookup是更好的解决方案,但如果您不仅仅是从这里列出的内容中简单地复制,那么您可能需要在初始编码方面投入更多的工作;)


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