对开始和结束范围进行分组和计数

mat*_*410 2 mongodb aggregation-framework

如果我有以下格式的数据:

[
  {
    _id: 1,
    startDate: ISODate("2017-01-1T00:00:00.000Z"),
    endDate: ISODate("2017-02-25T00:00:00.000Z"),
    type: 'CAR'
  },
  {
    _id: 2,
    startDate: ISODate("2017-02-17T00:00:00.000Z"),
    endDate: ISODate("2017-03-22T00:00:00.000Z"),
    type: 'HGV'
  }
]
Run Code Online (Sandbox Code Playgroud)

是否可以检索按“类型”分组的数据,但也可以检索给定日期范围内每个月的类型计数,例如在 2017/1/1 到 2017/4/1 之间将返回:

[
  {
   _id: 'CAR', 
   monthCounts: [
     /*January*/
     {
       from: ISODate("2017-01-1T00:00:00.000Z"), 
       to: ISODate("2017-01-31T23:59:59.999Z"), 
       count: 1
     },
     /*February*/
     {
       from: ISODate("2017-02-1T00:00:00.000Z"), 
       to: ISODate("2017-02-28T23:59:59.999Z"), 
       count: 1
     },
     /*March*/
     {
       from: ISODate("2017-03-1T00:00:00.000Z"), 
       to: ISODate("2017-03-31T23:59:59.999Z"), 
       count: 0
     },
   ]
  },
  {
   _id: 'HGV', 
   monthCounts: [
     {
       from: ISODate("2017-01-1T00:00:00.000Z"), 
       to: ISODate("2017-01-31T23:59:59.999Z"), 
       count: 0
     },
     {
       from: ISODate("2017-02-1T00:00:00.000Z"), 
       to: ISODate("2017-02-28T23:59:59.999Z"), 
       count: 1
     },
     {
       from: ISODate("2017-03-1T00:00:00.000Z"), 
       to: ISODate("2017-03-31T23:59:59.999Z"), 
       count: 1
     },
   ]
  }
]
Run Code Online (Sandbox Code Playgroud)

返回的格式并不是很重要,但我想要实现的是在单个查询中检索同一分组(每月一个)的多个计数。输入可能只是要报告的开始和结束日期,或者更有可能是要分组的日期范围数组。

Nei*_*unn 5

这个算法基本上是在两个值的间隔之间“迭代”值。MongoDB 有几种方法可以解决这个问题,即一直存在mapReduce()aggregate()方法以及该方法可用的新功能。

我将扩大您的选择范围,故意显示一个重叠的月份,因为您的示例没有一个。这将导致“三个月”的输出中出现“HGV”值。

{
        "_id" : 1,
        "startDate" : ISODate("2017-01-01T00:00:00Z"),
        "endDate" : ISODate("2017-02-25T00:00:00Z"),
        "type" : "CAR"
}
{
        "_id" : 2,
        "startDate" : ISODate("2017-02-17T00:00:00Z"),
        "endDate" : ISODate("2017-03-22T00:00:00Z"),
        "type" : "HGV"
}
{
        "_id" : 3,
        "startDate" : ISODate("2017-02-17T00:00:00Z"),
        "endDate" : ISODate("2017-04-22T00:00:00Z"),
        "type" : "HGV"
}
Run Code Online (Sandbox Code Playgroud)

聚合 - 需要 MongoDB 3.4

db.cars.aggregate([
  { "$addFields": {
    "range": {
      "$reduce": {
        "input": { "$map": {
          "input": { "$range": [ 
            { "$trunc": { 
              "$divide": [ 
                { "$subtract": [ "$startDate", new Date(0) ] },
                1000
              ]
            }},
            { "$trunc": {
              "$divide": [
                { "$subtract": [ "$endDate", new Date(0) ] },
                1000
              ]
            }},
            60 * 60 * 24
          ]},
          "as": "el",
          "in": {
            "$let": {
              "vars": {
                "date": {
                  "$add": [ 
                    { "$multiply": [ "$$el", 1000 ] },
                    new Date(0)
                  ]
                },
                "month": {
                }
              },
              "in": {
                "$add": [
                  { "$multiply": [ { "$year": "$$date" }, 100 ] },
                  { "$month": "$$date" }
                ]
              }
            }
          }
        }},
        "initialValue": [],
        "in": {
          "$cond": {
            "if": { "$in": [ "$$this", "$$value" ] },
            "then": "$$value",
            "else": { "$concatArrays": [ "$$value", ["$$this"] ] }
          }
        }
      }
    }
  }},
  { "$unwind": "$range" },
  { "$group": {
    "_id": {
      "type": "$type",
      "month": "$range"
    },
    "count": { "$sum": 1 }
  }},
  { "$sort": { "_id": 1 } },
  { "$group": {
    "_id": "$_id.type",
    "monthCounts": { 
      "$push": { "month": "$_id.month", "count": "$count" }
    }
  }}
])
Run Code Online (Sandbox Code Playgroud)

完成这项工作的关键是$range运算符,它采用“开始”和“结束”以及要应用的“间隔”的值。结果是从“开始”获取并递增直到到达“结束”的值数组。

我们使用它和startDateendDate来生成这些值之间的可能日期。您会注意到我们需要在这里做一些数学运算,因为$range它只需要一个 32 位整数,但我们可以从时间戳值中减去毫秒,这样就可以了。

因为我们想要“月”,所以应用的操作从生成的范围中提取月和年值。我们实际上将范围生成为介于两者之间的“天”,因为“月”在数学中很难处理。后续$reduce操作只需要日期范围内的“不同月份”。

因此,第一个聚合管道阶段的结果是文档中的一个新字段,它是startDate和之间涵盖的所有不同月份的“数组” endDate。这为操作的其余部分提供了一个“迭代器”。

通过“迭代器”,我的意思是当我们申请时,$unwind我们会为间隔中涵盖的每个不同的月份获得原始文档的副本。这然后允许以下两个$group阶段首先将分组应用于“month”和“type”的公共键,以便通过“总计”计数$sum,然后$group将键设为“type”并将结果放入数组通过$push.

这给出了上述数据的结果:

{
        "_id" : "HGV",
        "monthCounts" : [
                {
                        "month" : 201702,
                        "count" : 2
                },
                {
                        "month" : 201703,
                        "count" : 2
                },
                {
                        "month" : 201704,
                        "count" : 1
                }
        ]
}
{
        "_id" : "CAR",
        "monthCounts" : [
                {
                        "month" : 201701,
                        "count" : 1
                },
                {
                        "month" : 201702,
                        "count" : 1
                }
        ]
}
Run Code Online (Sandbox Code Playgroud)

请注意,“月”的覆盖范围仅在有实际数据的情况下才存在。虽然可以在一个范围内产生零值,但这样做需要相当多的争论并且不太实用。如果您想要零值,那么最好在检索到结果后在客户端的后期处理中添加该值。

如果您真的很重视零值,那么您应该单独查询$min$max值,并将它们传递给“蛮力”管道以生成每个提供的可能范围值的副本。

所以这次“范围”是在所有文档的外部制作的,然后您$cond在累加器中使用一个语句来查看当前数据是否在生成的分组范围内。此外,由于生成是“外部”,我们真的不需要 MongoDB 3.4 运算符$range,因此这也可以应用于早期版本:

// Get min and max separately 
var ranges = db.cars.aggregate(
 { "$group": {
   "_id": null,
   "startRange": { "$min": "$startDate" },
   "endRange": { "$max": "$endDate" }
 }}
).toArray()[0]

// Make the range array externally from all possible values
var range = [];
for ( var d = new Date(ranges.startRange.valueOf()); d <= ranges.endRange; d.setUTCMonth(d.getUTCMonth()+1)) {
  var v = ( d.getUTCFullYear() * 100 ) + d.getUTCMonth()+1;
  range.push(v);
}

// Run conditional aggregation
db.cars.aggregate([
  { "$addFields": { "range": range } },
  { "$unwind": "$range" },
  { "$group": {
    "_id": {
      "type": "$type",
      "month": "$range"
    },
    "count": { 
      "$sum": {
        "$cond": {
          "if": {
            "$and": [
              { "$gte": [
                "$range",
                { "$add": [
                  { "$multiply": [ { "$year": "$startDate" }, 100 ] },
                  { "$month": "$startDate" }
                ]}
              ]},
              { "$lte": [
                "$range",
                { "$add": [
                  { "$multiply": [ { "$year": "$endDate" }, 100 ] },
                  { "$month": "$endDate" }
                ]}
              ]}
            ]
          },
          "then": 1,
          "else": 0
        }
      }
    }
  }},
  { "$sort": { "_id": 1 } },
  { "$group": {
    "_id": "$_id.type",
    "monthCounts": { 
      "$push": { "month": "$_id.month", "count": "$count" }
    }
  }}
])
Run Code Online (Sandbox Code Playgroud)

它为所有分组的所有可能月份产生一致的零填充:

{
        "_id" : "HGV",
        "monthCounts" : [
                {
                        "month" : 201701,
                        "count" : 0
                },
                {
                        "month" : 201702,
                        "count" : 2
                },
                {
                        "month" : 201703,
                        "count" : 2
                },
                {
                        "month" : 201704,
                        "count" : 1
                }
        ]
}
{
        "_id" : "CAR",
        "monthCounts" : [
                {
                        "month" : 201701,
                        "count" : 1
                },
                {
                        "month" : 201702,
                        "count" : 1
                },
                {
                        "month" : 201703,
                        "count" : 0
                },
                {
                        "month" : 201704,
                        "count" : 0
                }
        ]
}
Run Code Online (Sandbox Code Playgroud)

地图简化

所有版本的 MongoDB 都支持 mapReduce,并且上面提到的“迭代器”的简单情况由for映射器中的循环处理。我们可以$group通过简单地执行以下操作来获得从上面生成的输出:

db.cars.mapReduce(
  function () {
    for ( var d = this.startDate; d <= this.endDate;
      d.setUTCMonth(d.getUTCMonth()+1) )
    { 
      var m = new Date(0);
      m.setUTCFullYear(d.getUTCFullYear());
      m.setUTCMonth(d.getUTCMonth());
      emit({ id: this.type, date: m},1);
    }
  },
  function(key,values) {
    return Array.sum(values);
  },
  { "out": { "inline": 1 } }
)
Run Code Online (Sandbox Code Playgroud)

其中产生:

{
        "_id" : {
                "id" : "CAR",
                "date" : ISODate("2017-01-01T00:00:00Z")
        },
        "value" : 1
},
{
        "_id" : {
                "id" : "CAR",
                "date" : ISODate("2017-02-01T00:00:00Z")
        },
        "value" : 1
},
{
        "_id" : {
                "id" : "HGV",
                "date" : ISODate("2017-02-01T00:00:00Z")
        },
        "value" : 2
},
{
        "_id" : {
                "id" : "HGV",
                "date" : ISODate("2017-03-01T00:00:00Z")
        },
        "value" : 2
},
{
        "_id" : {
                "id" : "HGV",
                "date" : ISODate("2017-04-01T00:00:00Z")
        },
        "value" : 1
}
Run Code Online (Sandbox Code Playgroud)

所以它没有第二个分组来复合到数组,但我们确实产生了相同的基本聚合输出。