Algorytm do tego polega w zasadzie na „iterowaniu” wartości między przedziałem dwóch wartości. MongoDB ma kilka sposobów radzenia sobie z tym, będąc tym, co zawsze było obecne w mapReduce()
oraz z nowymi funkcjami dostępnymi dla aggregate()
metoda.
Rozszerzę twój wybór, aby celowo pokazać nakładający się miesiąc, ponieważ w twoich przykładach go nie było. Spowoduje to pojawienie się wartości „ciężarowych” w ciągu „trzech” miesięcy produkcji.
{
"_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"
}
Agregacja — wymaga 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" }
}
}}
])
Kluczem do tego, aby to zadziałało, jest $range
operator, który przyjmuje wartości dla "początku" i "końca" oraz "przedziału" do zastosowania. Wynikiem jest tablica wartości pobranych od „początku” i inkrementowanych aż do osiągnięcia „końca”.
Używamy tego z startDate
i endDate
aby wygenerować możliwe daty pomiędzy tymi wartościami. Zauważysz, że musimy tutaj trochę policzyć, ponieważ $zakres
zajmuje tylko 32-bitową liczbę całkowitą, ale możemy odjąć milisekundy od wartości znacznika czasu, więc jest to w porządku.
Ponieważ chcemy "miesięcy", zastosowane operacje wyodrębniają wartości miesiąca i roku z wygenerowanego zakresu. W rzeczywistości generujemy zakres jako „dni” pomiędzy nimi, ponieważ „miesiące” są trudne w matematyce. Kolejne $reduce
operacja zajmuje tylko "różne miesiące" z zakresu dat.
Dlatego wynikiem pierwszego etapu potoku agregacji jest nowe pole w dokumencie, które jest "tablicą" wszystkich odrębnych miesięcy zawartych między startDate
i endDate
. Daje to "iterator" do końca operacji.
Przez „iterator” mam na myśli niż wtedy, gdy stosujemy $unwind
otrzymujemy kopię oryginalnego dokumentu za każdy odrębny miesiąc objęty przedziałem. To umożliwia następnie następujące dwa $group
etapy, aby najpierw zastosować grupowanie do wspólnego klucza „miesiąc” i „typ” w celu „sumowania” zliczeń za pomocą $sum
, a następnie $group
sprawia, że klucz jest po prostu „typem” i umieszcza wyniki w tablicy poprzez $push
.
Daje to wynik z powyższych danych:
{
"_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
}
]
}
Należy zauważyć, że zakres „miesięcy” występuje tylko tam, gdzie istnieją rzeczywiste dane. Chociaż możliwe jest uzyskanie wartości zerowych w całym zakresie, wymaga to sporo wysiłku i nie jest zbyt praktyczne. Jeśli chcesz uzyskać wartości zerowe, lepiej dodać to w przetwarzaniu końcowym w kliencie po pobraniu wyników.
Jeśli naprawdę masz serce ustawione na wartości zerowe, powinieneś osobno zapytać o $min
i $max
wartości i przekaż je, aby potok „brute force” wygenerował kopie dla każdej podanej możliwej wartości zakresu.
Więc tym razem „zakres” jest tworzony zewnętrznie dla wszystkich dokumentów, a następnie używasz $war
do akumulatora, aby sprawdzić, czy bieżące dane mieszczą się w zgrupowanym zakresie. Również ponieważ generacja jest "zewnętrzna", tak naprawdę nie potrzebujemy operatora MongoDB 3.4 $range
, więc można to zastosować również we wcześniejszych wersjach:
// 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" }
}
}}
])
Daje to spójne zerowe wypełnienia we wszystkich możliwych miesiącach we wszystkich grupach:
{
"_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
Wszystkie wersje MongoDB obsługują mapReduce, a prosty przypadek „iteratora”, jak wspomniano powyżej, jest obsługiwany przez for
pętla w programie mapującym. Możemy uzyskać dane wyjściowe jako wygenerowane aż do pierwszej grupy $
z góry, po prostu wykonując:
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 } }
)
Co daje:
{
"_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
}
Nie ma więc drugiego grupowania do łączenia w tablice, ale stworzyliśmy te same podstawowe zagregowane dane wyjściowe.