O algoritmo para isso é basicamente "iterar" valores entre o intervalo dos dois valores. O MongoDB tem algumas maneiras de lidar com isso, sendo o que sempre esteve presente com
mapReduce()
e com novos recursos disponíveis para o aggregate()
método. Vou expandir sua seleção para mostrar deliberadamente um mês sobreposto, já que seus exemplos não tinham um. Isso fará com que os valores "HGV" apareçam em "três" meses de produção.
{
"_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"
}
Agregado - Requer 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" }
}
}}
])
A chave para fazer isso funcionar é o
$range
operador que recebe valores para um "início" e um "fim" bem como um "intervalo" para aplicar. O resultado é uma matriz de valores retirados do "início" e incrementados até que o "fim" seja alcançado. Usamos isso com
startDate
e endDate
para gerar as datas possíveis entre esses valores. Você notará que precisamos fazer algumas contas aqui, pois o $range
leva apenas um inteiro de 32 bits, mas podemos tirar os milissegundos dos valores de carimbo de data/hora para que esteja tudo bem. Como queremos "meses", as operações aplicadas extraem os valores de mês e ano do intervalo gerado. Na verdade, geramos o intervalo como os "dias" intermediários, já que os "meses" são difíceis de lidar em matemática. O subsequente
$reduce
operação leva apenas os "meses distintos" do intervalo de datas. O resultado, portanto, do primeiro estágio do pipeline de agregação é um novo campo no documento que é uma "matriz" de todos os meses distintos cobertos entre
startDate
e endDate
. Isso fornece um "iterador" para o restante da operação. Por "iterador" quero dizer que quando aplicamos
$unwind
obtemos uma cópia do documento original para cada mês distinto coberto no intervalo. Isso permite os seguintes dois $group
etapas para primeiro aplicar um agrupamento à chave comum de "mês" e "tipo" para "totalizar" as contagens por meio de $sum
e em seguida $group
torna a chave apenas o "tipo" e coloca os resultados em uma matriz via $push
. Isso dá o resultado nos dados acima:
{
"_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
}
]
}
Observe que a cobertura de "meses" só está presente onde há dados reais. Embora seja possível produzir valores zero em um intervalo, isso requer um pouco de discussão e não é muito prático. Se você deseja valores zero, é melhor adicionar isso no pós-processamento no cliente assim que os resultados forem recuperados.
Se você realmente deseja os valores zero, deve consultar separadamente
$min
e $max
valores e passá-los para "força bruta" o pipeline para gerar as cópias para cada valor de intervalo possível fornecido. Dessa vez, o "intervalo" é feito externamente a todos os documentos e você usa um
$cond
declaração no acumulador para ver se os dados atuais estão dentro do intervalo agrupado produzido. Além disso, como a geração é "externa", realmente não precisamos do operador MongoDB 3.4 de $range
, portanto, isso também pode ser aplicado a versões anteriores:// 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" }
}
}}
])
O que produz preenchimentos zero consistentes para todos os meses possíveis em todos os agrupamentos:
{
"_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
}
]
}
MapReduce
Todas as versões do MongoDB suportam mapReduce, e o caso simples do "iterador" como mencionado acima é tratado por um
for
loop no mapeador. Podemos obter a saída gerada até o primeiro $group
de cima simplesmente fazendo: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 } }
)
Que produz:
{
"_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
}
Portanto, ele não tem o segundo agrupamento para compor as matrizes, mas produzimos a mesma saída agregada básica.