【问题标题】:Lookup and group from two fields in one aggregation从一个聚合中的两个字段查找和分组
【发布时间】:2023-03-12 13:44:01
【问题描述】:

我有一个如下所示的聚合:

userSchema.statics.getCounts = function (req, type) {
  return this.aggregate([
    { $match: { organization: req.user.organization._id } },
    {
      $lookup: {
        from: 'tickets', localField: `${type}Tickets`, foreignField: '_id', as: `${type}_tickets`,
      },
    },
    { $unwind: `$${type}_tickets` },
    { $match: { [`${type}_tickets.createdAt`]: { $gte: new Date(moment().subtract(4, 'd').startOf('day').utc()), $lt: new Date(moment().endOf('day').utc()) } } },
    {
      $group: {
        _id: {
          groupDate: {
            $dateFromParts: {
              year: { $year: `$${type}_tickets.createdAt` },
              month: { $month: `$${type}_tickets.createdAt` },
              day: { $dayOfMonth: `$${type}_tickets.createdAt` },
            },
          },
          userId: `$${type}_tickets.assignee_id`,
        },
        ticketCount: {
          $sum: 1,
        },
      },
    },
    {
      $sort: { '_id.groupDate': -1 },
    },
    { $group: { _id: '$_id.userId', data: { $push: { groupDate: '$_id.groupDate', ticketCount: '$ticketCount' } } } },
  ]);
};

输出数据如下:

[ 
  {
    _id: 5aeb6b71709f43359e0888bb,
    data: [ 
      { "groupDate": 2018-05-07T00:00:000Z", ticketCount: 4 }
  }
]

不过,理想情况下,我会有这样的数据:

[ 
  {
    _id: 5aeb6b71709f43359e0888bb,
    data: [ 
      { "groupDate": 2018-05-07T00:00:000Z", assignedCount: 4, resolvedCount: 8 }
  }
]

不同之处在于用户的对象将输出每个日期分配的工单总数和已解决工单的总数。

我的 userSchema 是这样的:

const userSchema = new Schema({
  firstName: String,
  lastName: String,
  assignedTickets: [
    {
      type: mongoose.Schema.ObjectId,
      ref: 'Ticket',
      index: true,
    },
  ],
  resolvedTickets: [
    {
      type: mongoose.Schema.ObjectId,
      ref: 'Ticket',
      index: true,
    },
  ],
}, {
  timestamps: true,
});

一个示例用户文档是这样的:

{
    "_id": "5aeb6b71709f43359e0888bb", 
    "assignedTickets": ["5aeb6ba7709f43359e0888bd", "5aeb6bf3709f43359e0888c2", "5aec7e0adcdd76b57af9e889"], 
    "resolvedTickets": ["5aeb6bc2709f43359e0888be", "5aeb6bc2709f43359e0888bf"], 
    "firstName": "Name", 
    "lastName": "Surname", 
}

票证文件示例如下:

{
    "_id": "5aeb6ba7709f43359e0888bd", 
    "ticket_id": 120292, 
    "type": "assigned", 
    "status": "Pending", 
    "assignee_email": "email@gmail.com", 
    "assignee_id": "5aeb6b71709f43359e0888bb", 
    "createdAt": "2018-05-02T20:05:59.147Z", 
    "updatedAt": "2018-05-03T20:05:59.147Z", 
}

我尝试添加多个查找和分组阶段,但我不断得到一个空数组。如果我只进行一次查找和一组,我会得到正确的搜索字段计数,但我希望在一个查询中同时包含这两个字段。是否可以在两次查找中使用查询组?

【问题讨论】:

  • 如果您可以生成一小部分可以从中获得预期结果的文档样本,这将对您的问题有所帮助。然后人们可以以与您演示如何返回所需结果相同的方式实际使用它。请参阅How to create a Minimal, Complete, and Verifiable example,这是我们希望您阅读的帮助中心的内容,它将帮助您更好、更及时地回答您的问题。
  • 使用示例用户文档和示例工单文档更新问题。
  • 这更好,但实际上您应该展示“再现预期结果”的内容。例如,如果您希望看到assignedCount: 4, resolvedCount: 8,那么您在问题中包含的文档应该包含足够的信息,以便能够“重现”该结果。它还可以帮助您显示从mongo shell 查看的文档,因为这样人们可以简单地复制和粘贴内容并直接使用它。

标签: mongodb mongoose mongodb-query aggregation-framework


【解决方案1】:

这样的事情怎么样?

db.users.aggregate([  
   {  
      $lookup:{ // lookup assigned tickets
         from:'tickets',
         localField:'assignedTickets',
         foreignField:'_id',
         as:'assigned',

      }
   },
   {  
      $lookup:{ // lookup resolved tickets 
         from:'tickets',
         localField:'resolvedTickets',
         foreignField:'_id',
         as:'resolved',

      }
   },
   {  
      $project:{  
         "tickets":{  // merge all tickets into one single array
            $concatArrays:[  
               "$assigned",
               "$resolved"
            ]
         }
      }
   },
   {  
      $unwind:'$tickets' // flatten the 'tickets' array into separate documents
   },
   {  
      $group:{ // group by 'createdAt' and 'assignee_id'
         _id:{  
            groupDate:{  
               $dateFromParts:{  
                  year:{ $year:'$tickets.createdAt' },
                  month:{ $month:'$tickets.createdAt' },
                  day:{ $dayOfMonth:'$tickets.createdAt' },

               },
            },
            userId:'$tickets.assignee_id',
         },
         assignedCount:{ // get the count of assigned tickets
            $sum:{  
               $cond:[  
                  {  // by checking the 'type' field for a value of 'assigned'
                     $eq:[  
                        '$tickets.type',
                        'assigned'
                     ]
                  },
                  1, // if matching count 1
                  0 // else 0
               ]
            }
         },
         resolvedCount:{  
            $sum:{  
               $cond:[  
                  {  // by checking the 'type' field for a value of 'resolved'
                     $eq:[  
                        '$tickets.type',
                        'resolved'
                     ]
                  },
                  1, // if matching count 1
                  0 // else 0
               ]
            }
         },
      },
   },
   {  
      $sort:{  // sort by 'groupDate' descending
         '_id.groupDate':-1
      },
   },
   {  
      $group:{  
         _id:'$_id.userId', // group again but only by userId
         data:{  
            $push:{  // create an array
               groupDate:'$_id.groupDate',
               assignedCount:{ 
                  $sum:'$assignedCount'
               },
               resolvedCount:{  
                  $sum:'$resolvedCount'
               }
            }
         }
      }
   }
])

【讨论】:

  • +10 为您的努力
  • 如果我这样使用,第二次查找总是空的。
【解决方案2】:

简而言之,您似乎已经同意在 mongoose 中设置您的模型,并且已经过分引用参考。实际上,您真的不应该将数组保留在 "User" 文档中。这实际上是一种“反模式”,它只是最初用作保留人口“参考”的约定的猫鼬,它不知道如何将参考从保留在“孩子”中转换为“父母” .

您实际上在每个"Ticket" 中都有该数据,$lookup 的自然形式是使用该"foreignField" 来引用本地集合中的详细信息。在这种情况下,票证上的"assignee_id" 将足以查看匹配回"User""_id"。虽然您没有说明,但您的 "status" 应该是数据是否实际上是“已分配”(如处于“待定”状态时)或“已解决”时的指示符。

为了简单起见,如果状态值不是“待定”值,我们将考虑“已解决”状态,但根据实际需要扩展示例中的逻辑并不是这里的问题。

基本上,我们通过实际使用自然“外键”而不是保留单独的数组来解决单个 $lookup 操作。

MongoDB 3.6 及更高版本

理想情况下,您可以在此处使用 MongoDB 3.6 的功能和子管道处理:

// Better date calculations
const oneDay = (1000 * 60 * 60 * 24);
var now = Date.now(),
    end = new Date((now - (now % oneDay)) + oneDay),
    start = new Date(end.valueOf() - (4 * oneDay));

User.aggregate([
  { "$match": { "organization": req.user.organization._id } },
  { "$lookup": {
    "from": Ticket.collection.name,
    "let": { "id": "$_id" },
    "pipeline": [
      { "$match": {
        "createdAt": { "$gte": start, "$lt": end },
        "$expr": {
          "$eq": [ "$$id", "$assignee_id" ]
        }
      }},
      { "$group": {
        "_id": {
          "status": "$status",
          "date": {
            "$dateFromParts": {
              "year": { "$year": "$createdAt" },
              "month": { "$month": "$createdAt" },
              "day": { "$dayOfMonth": "$createdAt" }
            }
          }
        },
        "count": { "$sum": 1 }
      }},
      { "$group": {
        "_id": "$_id.date",
        "data": {
          "$push": {
            "k": {
              "$cond": [
                { "$eq": ["$_id.status", "Pending"] },
                "assignedCount",
                "resolvedCount"
              ]
            },
            "v": "$count"
          }
        }
      }},
      { "$sort": { "_id": -1 } },
      { "$replaceRoot": {
        "newRoot": {
          "$mergeObjects": [
            { "groupDate": "$_id", "assignedCount": 0, "resolvedCount": 0 },
            { "$arrayToObject": "$data" }
          ]
        }
      }}
    ],
    "as": "data"
  }},
  { "$project": { "data": 1 } }
])

从 MongoDB 3.0 及更高版本

或者在您缺少这些功能的地方,我们使用不同的管道流程并在从服务器返回结果后进行少量数据转换:

User.aggregate([
  { "$match": { "organization": req.user.organization._id } },
  { "$lookup": {
    "from": Ticket.collection.name,
    "localField": "_id",
    "foreignField": "assignee_id",
    "as": "data"
  }},
  { "$unwind": "$data" },
  { "$match": {
    "data.createdAt": { "$gte": start, "$lt": end }
  }},
  { "$group": {
    "_id": { 
      "userId": "$_id",
      "date": {
        "$add": [
          { "$subtract": [
            { "$subtract": [ "$data.createdAt", new Date(0) ] },
            { "$mod": [
              { "$subtract": [ "$data.createdAt", new Date(0) ] },
              oneDay
            ]}
          ]},
          new Date(0)
        ]
      },
      "status": "$data.status"
    },
    "count": { "$sum": 1 }
  }},
  { "$group": {
    "_id": {
      "userId": "$_id.userId",
      "date": "$_id.date"
    },
    "data": {
      "$push": {
        "k": {
          "$cond": [
            { "$eq": [ "$_id.status", "Pending" ] },
            "assignedCount",
            "resolvedCount"
          ]
        },
        "v": "$count"
      }
    }
  }},
  { "$sort": { "_id.userId": 1, "_id.date": -1 } },
  { "$group": {
    "_id": "$_id.userId",
    "data": {
      "$push": {
        "groupDate": "$_id.date",
        "data": "$data"
      }
    }
  }}
])
.then( results => 
  results.map( ({ data, ...d }) => 
    ({
      ...d,
      data: data.map(di =>
        ({
          groupDate: di.groupDate,
          assignedCount: 0,
          resolvedCount: 0,
          ...di.data.reduce((acc,curr) => ({ ...acc, [curr.k]: curr.v }),{})
        })
      )
    })
  )
)

这确实表明,即使在现代版本中具有花哨的功能,您也确实不需要它们,因为几乎总是有解决此问题的方法。在当前的“对象扩展”语法可用之前,即使是 JavaScript 部分也只有稍微长一点的版本。

所以这确实是您需要进入的方向。您当然不希望使用“多个”$lookup 阶段,甚至在可能是大型数组的情况下应用$filter 条件。此外,这里的两种表格都尽最大努力“过滤”从外部集合中“加入”的项目数量,以免违反 BSON 限制。

特别是“pre 3.6”版本实际上有一个技巧,您可以在解释输出中看到$lookup + $unwind + $match 连续出现。实际上,所有阶段都合并为“一个”阶段,该阶段仅从外部集合中仅返回与$match 中的条件匹配的项目。在我们进一步减少之前保持“展开”可以避免 BSON 限制问题,MongoDB 3.6 的新表单也是如此,其中“子管道”在返回任何结果之前执行所有文档减少和分组。

您的一个文档示例将返回如下:

    {
        "_id" : ObjectId("5aeb6b71709f43359e0888bb"),
        "data" : [
            {
                "groupDate" : ISODate("2018-05-02T00:00:00Z"),
                "assignedCount" : 1,
                "resolvedCount" : 0

            }
        ]
    }

一旦我扩展日期选择以包含该日期,当然日期选择也可以从您的原始表单中改进和更正。

因此,您的关系实际上是这样定义的,但这似乎是有道理的,但只是您“两次”记录了它们。您不需要,即使这不是定义,那么您实际上应该记录在“子”而不是父数组中。我们可以兼顾和合并父数组,但这对于实际正确建立数据关系并正确使用它们会适得其反。

【讨论】:

  • 尼尔,我很欣赏深思熟虑的答案!我最初将数据结构不同,并移动到具有票证模式的模型,并且认为当票证模式已经引用用户时,没有必要保留对用户模式的引用。我同意这是一种更好的方法,并减少了许多不必要的复杂性。非常感谢您的详细回答,因为我相信这将有助于减少头痛!
  • @Josh 没问题。您可能还想通过其余的猫鼬代码来设置"virtuals" for convenience of reference。我通常建议坚持使用$lookup 进行数据检索,但偶尔来自虚拟的额外查询不会造成太大伤害。
猜你喜欢
  • 2021-02-06
  • 1970-01-01
  • 1970-01-01
  • 2023-03-14
  • 1970-01-01
  • 2013-03-19
  • 1970-01-01
  • 2016-11-23
  • 2018-09-01
相关资源
最近更新 更多