L'algoritmo per questo consiste sostanzialmente nell'"iterare" i valori tra l'intervallo dei due valori. MongoDB ha un paio di modi per affrontare questo problema, essendo ciò che è sempre stato presente con mapReduce()
e con le nuove funzionalità disponibili per aggregate()
metodo.
Espanderò la tua selezione per mostrare deliberatamente un mese sovrapposto poiché i tuoi esempi non ne avevano uno. Ciò comporterà la visualizzazione dei valori "HGV" in "tre" mesi di produzione.
{
"_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"
}
Aggregato - Richiede 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" }
}
}}
])
La chiave per farlo funzionare è il $range
operatore che accetta i valori di "inizio" e "fine" nonché un "intervallo" da applicare. Il risultato è un array di valori presi dall'"inizio" e incrementati fino al raggiungimento della "fine".
Lo usiamo con startDate
e endDate
per generare le possibili date tra quei valori. Noterai che dobbiamo fare un po' di matematica qui poiché $range
richiede solo un numero intero a 32 bit, ma possiamo togliere i millisecondi dai valori del timestamp, quindi va bene.
Poiché vogliamo "mesi", le operazioni applicate estraggono i valori del mese e dell'anno dall'intervallo generato. In realtà generiamo l'intervallo poiché i "giorni" intermedi poiché i "mesi" sono difficili da gestire in matematica. Il successivo $reduce
l'operazione richiede solo i "mesi distinti" dall'intervallo di date.
Il risultato quindi della prima fase della pipeline di aggregazione è un nuovo campo nel documento che è un "array" di tutti i mesi distinti coperti tra startDate
e endDate
. Questo fornisce un "iteratore" per il resto dell'operazione.
Per "iteratore" intendo di quando applichiamo $unwind
otteniamo una copia del documento originale per ogni mese distinto coperto dall'intervallo. Ciò consente quindi i seguenti due $group
fasi per applicare prima un raggruppamento alla chiave comune di "mese" e "tipo" per "totale" i conteggi tramite $sum
e poi $group
rende la chiave solo il "tipo" e inserisce i risultati in un array tramite $push
.
Questo dà il risultato sui dati di cui sopra:
{
"_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
}
]
}
Si noti che la copertura dei "mesi" è presente solo dove sono presenti dati effettivi. Sebbene sia possibile produrre valori zero su un intervallo, richiede un bel po' di discussioni per farlo e non è molto pratico. Se vuoi valori zero, allora è meglio aggiungerlo in post-elaborazione nel client una volta che i risultati sono stati recuperati.
Se hai davvero il cuore puntato sui valori zero, dovresti eseguire una query separatamente per $min
e $max
valori e passarli alla "forza bruta" della pipeline per generare le copie per ogni possibile valore di intervallo fornito.
Quindi questa volta l'"intervallo" viene creato esternamente a tutti i documenti, quindi si utilizza un $cond
istruzione nell'accumulatore per vedere se i dati correnti rientrano nell'intervallo raggruppato prodotto. Inoltre, poiché la generazione è "esterna", non abbiamo davvero bisogno dell'operatore MongoDB 3.4 di $range
, quindi questo può essere applicato anche alle versioni precedenti:
// 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" }
}
}}
])
Che produce riempimenti zero coerenti per tutti i mesi possibili su tutti i raggruppamenti:
{
"_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
}
]
}
Riduci mappa
Tutte le versioni di MongoDB supportano mapReduce e il semplice caso dell'"iteratore" come menzionato sopra è gestito da un for
loop nel mappatore. Possiamo ottenere l'output generato fino al primo $group
dall'alto semplicemente facendo:
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 } }
)
Che produce:
{
"_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
}
Quindi non ha il secondo raggruppamento da comporre in array, ma abbiamo prodotto lo stesso output aggregato di base.