Allmänt problem med att hantera "lokala datum"
Så det finns ett kort svar på detta och ett långt svar också. Grundfallet är att istället för att använda någon av "datumaggregationsoperatorerna" så vill du istället och "måste" faktiskt "göra matten" på datumobjekten istället. Det primära här är att justera värdena med offset från UTC för den givna lokala tidszonen och sedan "runda" till det önskade intervallet.
Det "mycket längre svaret" och även det största problemet att överväga innebär att datum ofta är föremål för "sommartid" förändringar i offset från UTC vid olika tider på året. Så detta betyder att när du konverterar till "lokal tid" för sådana aggregeringsändamål, bör du verkligen överväga var gränserna för sådana förändringar finns.
Det finns också en annan övervägande, att oavsett vad du gör för att "aggregera" vid ett givet intervall, "bör" utdatavärdena åtminstone initialt komma ut som UTC. Detta är god praxis eftersom display to "locale" verkligen är en "klientfunktion", och som senare beskrivs kommer klientgränssnitten vanligtvis att ha ett sätt att visa i den nuvarande lokalen som kommer att baseras på antagandet att det faktiskt matades data som UTC.
Bestämma platsförskjutning och sommartid
Detta är i allmänhet det huvudsakliga problemet som måste lösas. Den allmänna matematiken för att "avrunda" ett datum till ett intervall är den enkla delen, men det finns ingen riktig matematik du kan använda för att veta när sådana gränser gäller, och reglerna ändras på varje plats och ofta varje år.
Så det är här ett "bibliotek" kommer in, och det bästa alternativet här enligt författarens åsikt för en JavaScript-plattform är moment-timezone, som i grunden är en "superuppsättning" av moment.js inklusive alla viktiga "timezeone"-funktioner vi vill ha att använda.
Moment Timezone definierar i princip en sådan struktur för varje lokal tidszon som:
{
name : 'America/Los_Angeles', // the unique identifier
abbrs : ['PDT', 'PST'], // the abbreviations
untils : [1414918800000, 1425808800000], // the timestamps in milliseconds
offsets : [420, 480] // the offsets in minutes
}
Där objekten förstås är mycket större med avseende på untils
och offsets
fastigheter som faktiskt registrerats. Men det är den data du behöver komma åt för att se om det faktiskt finns en förändring i offset för en zon givet sommartid.
Det här blocket i den senare kodlistan är vad vi i princip använder för att bestämma en start
och end
värde för ett intervall, vilka sommar- och vintergränser som överskrids, om några:
const zone = moment.tz.zone(locale);
if ( zone.hasOwnProperty('untils') ) {
let between = zone.untils.filter( u =>
u >= start.valueOf() && u < end.valueOf()
);
if ( between.length > 0 )
branches = between
.map( d => moment.tz(d, locale) )
.reduce((acc,curr,i,arr) =>
acc.concat(
( i === 0 )
? [{ start, end: curr }] : [{ start: acc[i-1].end, end: curr }],
( i === arr.length-1 ) ? [{ start: curr, end }] : []
)
,[]);
}
Tittar på hela 2017 för Australia/Sydney
locale utdata för detta skulle vara:
[
{
"start": "2016-12-31T13:00:00.000Z", // Interval is +11 hours here
"end": "2017-04-01T16:00:00.000Z"
},
{
"start": "2017-04-01T16:00:00.000Z", // Changes to +10 hours here
"end": "2017-09-30T16:00:00.000Z"
},
{
"start": "2017-09-30T16:00:00.000Z", // Changes back to +11 hours here
"end": "2017-12-31T13:00:00.000Z"
}
]
Vilket i princip avslöjar att mellan den första sekvensen av datum skulle förskjutningen vara +11 timmar och sedan ändras till +10 timmar mellan datumen i den andra sekvensen och sedan växlas tillbaka till +11 timmar för intervallet som omfattar till slutet av året och specificerat intervall.
Denna logik måste sedan översättas till en struktur som kommer att förstås av MongoDB som en del av en aggregeringspipeline.
Tillämpa matematiken
Den matematiska principen här för att aggregera till valfritt "avrundat datumintervall" bygger i huvudsak på att använda millisekundersvärdet för det representerade datumet som "avrundas" nedåt till närmaste tal som representerar det "intervall" som krävs.
Du gör detta i huvudsak genom att hitta "modulo" eller "resten" av det aktuella värdet som tillämpas på det önskade intervallet. Sedan "subtraherar" du den återstoden från det aktuella värdet som returnerar ett värde vid närmaste intervall.
Till exempel, givet det aktuella datumet:
var d = new Date("2017-07-14T01:28:34.931Z"); // toValue() is 1499995714931 millis
// 1000 millseconds * 60 seconds * 60 minutes = 1 hour or 3600000 millis
var v = d.valueOf() - ( d.valueOf() % ( 1000 * 60 * 60 ) );
// v equals 1499994000000 millis or as a date
new Date(1499994000000);
ISODate("2017-07-14T01:00:00Z")
// which removed the 28 minutes and change to nearest 1 hour interval
Detta är den allmänna matematiken som vi också måste använda i aggregeringspipelinen med $subtract
och $mod
operationer, som är de aggregeringsuttryck som används för samma matematiska operationer som visas ovan.
Den allmänna strukturen för aggregeringspipelinen är då:
let pipeline = [
{ "$match": {
"createdAt": { "$gte": start.toDate(), "$lt": end.toDate() }
}},
{ "$group": {
"_id": {
"$add": [
{ "$subtract": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
{ "$mod": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
interval
]}
]},
new Date(0)
]
},
"amount": { "$sum": "$amount" }
}},
{ "$addFields": {
"_id": {
"$add": [
"$_id", switchOffset(start,end,"$_id",true)
]
}
}},
{ "$sort": { "_id": 1 } }
];
De viktigaste delarna här du behöver förstå är konverteringen från ett Date
objekt som lagras i MongoDB till Numeric
representerar det interna tidsstämpelvärdet. Vi behöver den "numeriska" formen, och för att göra detta är ett matematiktrick där vi subtraherar ett BSON-datum från ett annat vilket ger den numeriska skillnaden mellan dem. Det här är exakt vad detta uttalande gör:
{ "$subtract": [ "$createdAt", new Date(0) ] }
Nu har vi ett numeriskt värde att ta itu med, vi kan tillämpa modulo och subtrahera det från den numeriska representationen av datumet för att "runda" det. Så den "raka" representationen av detta är som:
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
{ "$mod": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
( 1000 * 60 * 60 * 24 ) // 24 hours
]}
]}
Vilket speglar samma JavaScript-matematik som visats tidigare men tillämpas på de faktiska dokumentvärdena i aggregeringspipelinen. Du kommer också att notera det andra "tricket" där vi tillämpar en $add
operation med en annan representation av ett BSON-datum från epok (eller 0 millisekunder) där "tillägget" av ett BSON-datum till ett "numeriskt" värde, returnerar ett "BSON-datum" som representerar millisekunderna som det gavs som indata.
Naturligtvis är den andra hänsynen i den listade koden den faktiska "offset" från UTC som justerar de numeriska värdena för att säkerställa att "avrundningen" sker för den aktuella tidszonen. Detta implementeras i en funktion baserad på den tidigare beskrivningen av att hitta var de olika offseten förekommer, och returnerar ett format som är användbart i ett aggregeringspipelineuttryck genom att jämföra indata och returnera korrekt offset.
Med den fullständiga expansionen av alla detaljer, inklusive genereringen av hantering av dessa olika "sommartid" skulle tidsförskjutningar då bli som:
[
{
"$match": {
"createdAt": {
"$gte": "2016-12-31T13:00:00.000Z",
"$lt": "2017-12-31T13:00:00.000Z"
}
}
},
{
"$group": {
"_id": {
"$add": [
{
"$subtract": [
{
"$subtract": [
{
"$subtract": [
"$createdAt",
"1970-01-01T00:00:00.000Z"
]
},
{
"$switch": {
"branches": [
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2016-12-31T13:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
}
]
},
"then": -39600000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
}
]
},
"then": -36000000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-12-31T13:00:00.000Z"
]
}
]
},
"then": -39600000
}
]
}
}
]
},
{
"$mod": [
{
"$subtract": [
{
"$subtract": [
"$createdAt",
"1970-01-01T00:00:00.000Z"
]
},
{
"$switch": {
"branches": [
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2016-12-31T13:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
}
]
},
"then": -39600000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
}
]
},
"then": -36000000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-12-31T13:00:00.000Z"
]
}
]
},
"then": -39600000
}
]
}
}
]
},
86400000
]
}
]
},
"1970-01-01T00:00:00.000Z"
]
},
"amount": {
"$sum": "$amount"
}
}
},
{
"$addFields": {
"_id": {
"$add": [
"$_id",
{
"$switch": {
"branches": [
{
"case": {
"$and": [
{
"$gte": [
"$_id",
"2017-01-01T00:00:00.000Z"
]
},
{
"$lt": [
"$_id",
"2017-04-02T03:00:00.000Z"
]
}
]
},
"then": -39600000
},
{
"case": {
"$and": [
{
"$gte": [
"$_id",
"2017-04-02T02:00:00.000Z"
]
},
{
"$lt": [
"$_id",
"2017-10-01T02:00:00.000Z"
]
}
]
},
"then": -36000000
},
{
"case": {
"$and": [
{
"$gte": [
"$_id",
"2017-10-01T03:00:00.000Z"
]
},
{
"$lt": [
"$_id",
"2018-01-01T00:00:00.000Z"
]
}
]
},
"then": -39600000
}
]
}
}
]
}
}
},
{
"$sort": {
"_id": 1
}
}
]
Den expansionen använder $switch
för att tillämpa datumintervallen som villkor för när de givna offsetvärdena ska returneras. Detta är den mest bekväma formen sedan "branches"
argumentet motsvarar direkt en "array", vilket är den mest bekväma utgången av "intervallen" som bestäms genom granskning av untils
representerar offset "cut-points" för den givna tidszonen på det angivna datumintervallet för frågan.
Det är möjligt att tillämpa samma logik i tidigare versioner av MongoDB med en "kapslad" implementering av $cond
istället, men det är lite rörigare att implementera, så vi använder bara den mest bekväma metoden för implementering här.
När alla dessa villkor har tillämpats är datumen "sammanlagda" faktiskt de som representerar den "lokala" tiden som definieras av den angivna locale
. Detta för oss faktiskt till vad det sista aggregeringsstadiet är, och anledningen till att det finns där samt den senare hanteringen som visas i listan.
Slutresultat
Jag nämnde tidigare att den allmänna rekommendationen är att "utgången" fortfarande ska returnera datumvärdena i UTC-format med åtminstone någon beskrivning, och därför är det precis vad pipelinen här gör genom att först konvertera "från" UTC till lokal av tillämpa offset när "avrundning", men sedan de slutliga siffrorna "efter grupperingen" återjusteras tillbaka med samma offset som gäller för de "avrundade" datumvärdena.
Listan här ger "tre" olika utgångsmöjligheter här som:
// ISO Format string from JSON stringify default
[
{
"_id": "2016-12-31T13:00:00.000Z",
"amount": 2
},
{
"_id": "2017-01-01T13:00:00.000Z",
"amount": 1
},
{
"_id": "2017-01-02T13:00:00.000Z",
"amount": 2
}
]
// Timestamp value - milliseconds from epoch UTC - least space!
[
{
"_id": 1483189200000,
"amount": 2
},
{
"_id": 1483275600000,
"amount": 1
},
{
"_id": 1483362000000,
"amount": 2
}
]
// Force locale format to string via moment .format()
[
{
"_id": "2017-01-01T00:00:00+11:00",
"amount": 2
},
{
"_id": "2017-01-02T00:00:00+11:00",
"amount": 1
},
{
"_id": "2017-01-03T00:00:00+11:00",
"amount": 2
}
]
En sak att notera här är att för en "klient" som Angular, skulle vart och ett av dessa format accepteras av sin egen DatePipe som faktiskt kan göra "lokalformatet" åt dig. Men det beror på vart uppgifterna levereras. "Bra" bibliotek kommer att vara medvetna om att använda ett UTC-datum i den aktuella lokalen. Om så inte är fallet, kan du behöva "stringify" dig själv.
Men det är en enkel sak, och du får mest stöd för detta genom att använda ett bibliotek som i huvudsak baserar sin manipulering av utdata från ett "givet UTC-värde".
Huvudsaken här är att "förstå vad du gör" när du frågar något som att aggregera till en lokal tidszon. En sådan process bör överväga:
-
Data kan ses och ses ofta ur människors perspektiv inom olika tidszoner.
-
Uppgifterna tillhandahålls vanligtvis av personer i olika tidszoner. I kombination med punkt 1 är det därför vi lagrar i UTC.
-
Tidszoner är ofta föremål för en föränderlig "offset" från "sommartid" i många av världens tidszoner, och du bör ta hänsyn till det när du analyserar och bearbetar data.
-
Oavsett aggregeringsintervall "bör" utdata i själva verket förbli i UTC, om än justerat för att aggregeras på intervall enligt den angivna lokalen. Detta gör att presentationen kan delegeras till en "klient"-funktion, precis som den ska.
Så länge du har dessa saker i åtanke och tillämpar precis som listan här visar, så gör du alla rätt saker för att hantera aggregering av datum och till och med allmän lagring med avseende på en given plats.
Så du "borde" göra detta, och vad du "inte borde" göra är att ge upp och helt enkelt lagra "lokal datum" som en sträng. Som beskrivits skulle det vara ett mycket felaktigt tillvägagångssätt och orsakar inget annat än ytterligare problem för din ansökan.
OBS :Det enda ämne jag inte berör här alls är att sammanställas till en "månad" (eller faktiskt "år") intervall. "Månader" är den matematiska anomalien i hela processen eftersom antalet dagar alltid varierar och därför kräver en helt annan uppsättning logik för att kunna tillämpas. Att bara beskriva det är minst lika långt som det här inlägget och skulle därför vara ett annat ämne. För allmänna minuter, timmar och dagar, vilket är det vanliga fallet, är matematiken här "tillräckligt bra" för dessa fall.
Fullständig lista
Detta fungerar som en "demonstration" att mixtra med. Den använder den nödvändiga funktionen för att extrahera offsetdatumen och -värdena som ska inkluderas och kör en aggregeringspipeline över de tillhandahållna data.
Du kan ändra vad som helst här, men kommer förmodligen att börja med locale
och interval
parametrar, och sedan kanske lägga till annan data och annan start
och end
datum för frågan. Men resten av koden behöver inte ändras för att bara göra ändringar i något av dessa värden, och kan därför demonstrera med olika intervall (som 1 hour
som ställts i frågan ) och olika lokaler.
Till exempel, när giltig data som faktiskt skulle kräva aggregering med ett "1 timmes intervall" så kommer raden i listan att ändras som:
const interval = moment.duration(1,'hour').asMilliseconds();
För att definiera ett millisekundersvärde för aggregeringsintervallet som krävs av de aggregeringsoperationer som utförs på datumen.
const moment = require('moment-timezone'),
mongoose = require('mongoose'),
Schema = mongoose.Schema;
mongoose.Promise = global.Promise;
mongoose.set('debug',true);
const uri = 'mongodb://localhost/test',
options = { useMongoClient: true };
const locale = 'Australia/Sydney';
const interval = moment.duration(1,'day').asMilliseconds();
const reportSchema = new Schema({
createdAt: Date,
amount: Number
});
const Report = mongoose.model('Report', reportSchema);
function log(data) {
console.log(JSON.stringify(data,undefined,2))
}
function switchOffset(start,end,field,reverseOffset) {
let branches = [{ start, end }]
const zone = moment.tz.zone(locale);
if ( zone.hasOwnProperty('untils') ) {
let between = zone.untils.filter( u =>
u >= start.valueOf() && u < end.valueOf()
);
if ( between.length > 0 )
branches = between
.map( d => moment.tz(d, locale) )
.reduce((acc,curr,i,arr) =>
acc.concat(
( i === 0 )
? [{ start, end: curr }] : [{ start: acc[i-1].end, end: curr }],
( i === arr.length-1 ) ? [{ start: curr, end }] : []
)
,[]);
}
log(branches);
branches = branches.map( d => ({
case: {
$and: [
{ $gte: [
field,
new Date(
d.start.valueOf()
+ ((reverseOffset)
? moment.duration(d.start.utcOffset(),'minutes').asMilliseconds()
: 0)
)
]},
{ $lt: [
field,
new Date(
d.end.valueOf()
+ ((reverseOffset)
? moment.duration(d.start.utcOffset(),'minutes').asMilliseconds()
: 0)
)
]}
]
},
then: -1 * moment.duration(d.start.utcOffset(),'minutes').asMilliseconds()
}));
return ({ $switch: { branches } });
}
(async function() {
try {
const conn = await mongoose.connect(uri,options);
// Data cleanup
await Promise.all(
Object.keys(conn.models).map( m => conn.models[m].remove({}))
);
let inserted = await Report.insertMany([
{ createdAt: moment.tz("2017-01-01",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-01",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-02",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-03",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-03",locale), amount: 1 },
]);
log(inserted);
const start = moment.tz("2017-01-01", locale)
end = moment.tz("2018-01-01", locale)
let pipeline = [
{ "$match": {
"createdAt": { "$gte": start.toDate(), "$lt": end.toDate() }
}},
{ "$group": {
"_id": {
"$add": [
{ "$subtract": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
{ "$mod": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
interval
]}
]},
new Date(0)
]
},
"amount": { "$sum": "$amount" }
}},
{ "$addFields": {
"_id": {
"$add": [
"$_id", switchOffset(start,end,"$_id",true)
]
}
}},
{ "$sort": { "_id": 1 } }
];
log(pipeline);
let results = await Report.aggregate(pipeline);
// log raw Date objects, will stringify as UTC in JSON
log(results);
// I like to output timestamp values and let the client format
results = results.map( d =>
Object.assign(d, { _id: d._id.valueOf() })
);
log(results);
// Or use moment to format the output for locale as a string
results = results.map( d =>
Object.assign(d, { _id: moment.tz(d._id, locale).format() } )
);
log(results);
} catch(e) {
console.error(e);
} finally {
mongoose.disconnect();
}
})()