Agrupar por fecha con zona horaria local en MongoDB

Soy nuevo en mongodb. A continuación se muestra mi consulta.

Model.aggregate() .match({ 'activationId': activationId, "t": { "$gte": new Date(fromTime), "$lt": new Date(toTime) } }) .group({ '_id': { 'date': { $dateToString: { format: "%Y-%m-%d %H", date: "$datefield" } } }, uniqueCount: { $addToSet: "$mac" } }) .project({ "date": 1, "month": 1, "hour": 1, uniqueMacCount: { $size: "$uniqueCount" } }) .exec() .then(function (docs) { return docs; }); 

El problema es la fecha de las tiendas mongodb en la zona horaria iso. Necesito estos datos para mostrar el gráfico de área.

Quiero agrupar por fecha con zona horaria local. ¿Hay alguna manera de agregar un timeoffset en la fecha cuando se agrupan?

Problema general de tratar con “fechas locales”

Así que hay una respuesta corta a esto y una respuesta larga también. El caso básico es que, en lugar de usar cualquiera de los “operadores de agregación de fecha” , en lugar de eso, desea “necesita” hacer “la matemática” en los objetos de fecha. Lo principal aquí es ajustar los valores mediante el desplazamiento de UTC para la zona horaria local determinada y luego “redondear” al intervalo requerido.

La “respuesta mucho más larga” y también el principal problema a considerar implica que las fechas a menudo están sujetas a cambios en el “horario de verano” en la compensación de UTC en diferentes épocas del año. Por lo tanto, esto significa que cuando se convierte a “hora local” para dichos propósitos de agregación, realmente debe considerar dónde están los límites para tales cambios.

También hay otra consideración, ya que no importa lo que haga para “agregar” en un intervalo dado, los valores de salida “deberían” al menos inicialmente aparecer como UTC. Esta es una buena práctica ya que la visualización a “configuración regional” es realmente una “función de cliente”, y como se describe más adelante, las interfaces de cliente generalmente tendrán una forma de visualización en la configuración regional actual que se basará en la premisa de que, de hecho, se alimentó Datos como UTC.

Determinación de la compensación de la configuración regional y el horario de verano

Este es generalmente el principal problema que necesita ser resuelto. La matemática general para “redondear” una fecha a un intervalo es la parte simple, pero no hay una matemática real que pueda aplicar para saber cuándo se aplican dichos límites, y las reglas cambian en cada localidad y, a menudo, cada año.

Así que aquí es donde entra una “biblioteca”, y la mejor opción aquí en la opinión de los autores para una plataforma de JavaScript es la zona horaria , que es básicamente un “superconjunto” de moment.js que incluye todas las características importantes de “timezeone” que queremos usar.

Moment Timezone básicamente define una estructura de este tipo para cada zona horaria de configuración regional como:

 { 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 } 

Donde, por supuesto, los objetos son mucho más grandes con respecto a las untils y de offsets realmente registradas. Pero esos son los datos a los que necesita acceder para ver si realmente hay un cambio en el desplazamiento de una zona dado el cambio de horario de verano.

Este bloque de la lista de códigos posterior es lo que básicamente usamos para determinar un valor de start y end para un rango, con qué límites del horario de verano se cruzan, en su caso:

  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 }] : [] ) ,[]); } 

En cuanto a la totalidad de 2017 para el local Australia/Sydney la salida de esto sería:

 [ { "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" } ] 

Lo que básicamente revela que, entre la primera secuencia de fechas, el desplazamiento sería de +11 horas, luego cambia a +10 horas entre las fechas de la segunda secuencia y luego regresa a +11 horas para el intervalo que abarca hasta el final del año y rango especificado.

Luego, esta lógica debe traducirse a una estructura que será entendida por MongoDB como parte de un canal de agregación.

Aplicando las matematicas

El principio matemático aquí para agregar a cualquier “intervalo de fecha redondeado” se basa esencialmente en el uso del valor de milisegundos de la fecha representada que se “redondea” hasta el número más cercano que representa el “intervalo” requerido.

Básicamente, puede hacer esto encontrando el “módulo” o el “rest” del valor actual aplicado al intervalo requerido. Luego, “resta” ese rest del valor actual que devuelve un valor en el intervalo más cercano.

Por ejemplo, dada la fecha actual:

  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 

Esta es la matemática general que también necesitamos aplicar en el canal de agregación usando las operaciones $subtract y $mod , que son las expresiones de agregación utilizadas para las mismas operaciones matemáticas que se muestran arriba.

La estructura general de la tubería de agregación es entonces:

  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 } } ]; 

Las partes principales que debe comprender aquí es la conversión de un objeto Date almacenado en MongoDB a Numeric representa el valor interno de la marca de tiempo. Necesitamos la forma “numérica”, y para hacer esto es un truco matemático donde restamos una fecha BSON de otra que produce la diferencia numérica entre ellas. Esto es exactamente lo que hace esta statement:

 { "$subtract": [ "$createdAt", new Date(0) ] } 

Ahora que tenemos que tratar un valor numérico, podemos aplicar el módulo y restarlo de la representación numérica de la fecha para “redondearlo”. Así que la representación “recta” de esto es como:

 { "$subtract": [ { "$subtract": [ "$createdAt", new Date(0) ] }, { "$mod": [ { "$subtract": [ "$createdAt", new Date(0) ] }, ( 1000 * 60 * 60 * 24 ) // 24 hours ]} ]} 

Lo que refleja el mismo enfoque matemático de JavaScript que se mostró anteriormente pero se aplicó a los valores reales del documento en el canal de agregación. También notará el otro “truco” allí donde aplicamos una operación $add con otra representación de una fecha BSON en la época (o 0 milisegundos) donde la “adición” de una Fecha BSON a un valor “numérico”, devuelve un “Fecha BSON” que representa los milisegundos que se proporcionó como entrada.

Por supuesto, la otra consideración en el código listado es el “desplazamiento” real de UTC que está ajustando los valores numéricos para asegurar que el “redondeo” tenga lugar en la zona horaria actual. Esto se implementa en una función basada en la descripción anterior de encontrar dónde se producen las distintas compensaciones, y devuelve un formato como utilizable en una expresión de agregación comparando las fechas de entrada y devolviendo la compensación correcta.

Con la expansión completa de todos los detalles, incluida la generación de la gestión de esas diferentes compensaciones de “Horario de verano”, sería como:

 [ { "$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 } } ] 

Esa expansión está utilizando la instrucción $switch para aplicar los rangos de fechas como condiciones para cuándo devolver los valores de desplazamiento dados. Esta es la forma más conveniente, ya que el argumento de "branches" corresponde directamente con una “matriz”, que es el resultado más conveniente de los “rangos” determinados al examinar las untils representan los “puntos de corte” de desplazamiento para la zona horaria dada en el intervalo de fechas proporcionado de la consulta.

Es posible aplicar la misma lógica en versiones anteriores de MongoDB usando una implementación “anidada” de $cond , pero es un poco más complicado de implementar, por lo que solo estamos usando el método más conveniente para implementar aquí.

Una vez que se aplican todas esas condiciones, las fechas “agregadas” son en realidad aquellas que representan la hora “local” tal como se define en la locale proporcionada. En realidad, esto nos lleva a lo que es la etapa de agregación final, y la razón por la que está allí, así como el manejo posterior como se muestra en el listado.

Resultados finales

Mencioné anteriormente que la recomendación general es que la “salida” aún debe devolver los valores de fecha en formato UTC de al menos alguna descripción, y por lo tanto, eso es exactamente lo que está haciendo aquí la tubería al convertir primero “de” UTC a local por aplicando el desplazamiento cuando se “redondea”, pero luego los números finales “después de la agrupación” se reajustan nuevamente por el mismo desplazamiento que se aplica a los valores de fecha “redondeados”.

El listado aquí da “tres” diferentes posibilidades de salida aquí como:

 // 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 } ] 

Lo único que se debe tener en cuenta aquí es que para un “cliente” como Angular, cada uno de esos formatos sería aceptado por su propio DatePipe, que en realidad puede hacer el “formato local”. Pero depende de donde se suministran los datos. Las bibliotecas “buenas” sabrán que se está usando una fecha UTC en la ubicación actual. Si ese no es el caso, entonces es posible que necesite “encordarse” usted mismo.

Pero es una cosa simple, y obtienes el mayor apoyo para esto al usar una biblioteca que esencialmente basa su manipulación de la salida de un “valor UTC dado”.

Lo principal aquí es “entender lo que estás haciendo” cuando preguntas algo como agregar a una zona horaria local. Tal proceso debe considerar:

  1. Los datos pueden ser y, a menudo, se ven desde la perspectiva de las personas dentro de diferentes zonas horarias.

  2. Los datos generalmente son proporcionados por personas en diferentes zonas horarias. Combinado con el punto 1, es por esto que almacenamos en UTC.

  3. Las zonas horarias a menudo están sujetas a un “desplazamiento” cambiante de “Horario de verano” en muchas de las zonas horarias mundiales, y debe tenerlo en cuenta al analizar y procesar los datos.

  4. Independientemente de los intervalos de agregación, la salida “debería”, de hecho, debe permanecer en UTC, aunque se haya ajustado para agregarse en el intervalo de acuerdo con la configuración regional proporcionada. Esto deja la presentación para ser delegada a una función de “cliente”, tal como debería ser.

Mientras tenga esas cosas en mente y aplique tal como lo demuestra el listado aquí, entonces está haciendo todas las cosas correctas para tratar con la agregación de fechas e incluso el almacenamiento general con respecto a un lugar determinado.

Así que “debería” estar haciendo esto, y lo que “no debería” estar haciendo es renunciar y simplemente almacenar la “fecha del lugar” como una cadena. Como se describe, ese sería un enfoque muy incorrecto y no causaría más que problemas adicionales para su aplicación.

NOTA : El único tema que no menciono aquí se agrega a un intervalo de “mes” (o incluso “año”) . Los “meses” son la anomalía matemática en todo el proceso, ya que el número de días siempre varía y, por lo tanto, requiere un conjunto de lógica completamente diferente para poder aplicar. Describir que solo es al menos tan largo como este post, y por lo tanto sería otro tema. Para los minutos generales, las horas y los días, que es el caso común, los cálculos aquí son “lo suficientemente buenos” para esos casos.


Listado completo

Esto sirve como una “demostración” para jugar con. Emplea la función requerida para extraer las fechas y los valores de desplazamiento que se incluirán y ejecuta una canalización de agregación sobre los datos proporcionados.

Puede cambiar cualquier cosa aquí, pero probablemente comenzará con los parámetros de locale e interval , y luego tal vez agregue datos diferentes y diferentes fechas de start y end para la consulta. Pero el rest del código no necesita ser cambiado para simplemente realizar cambios en cualquiera de esos valores, y por lo tanto, puede demostrar el uso de diferentes intervalos (como 1 hour como se pregunta en la pregunta) y diferentes configuraciones regionales.

Por ejemplo, una vez que se suministran datos válidos que en realidad requerirían agregación en un “intervalo de 1 hora”, la línea en el listado se cambiaría como:

 const interval = moment.duration(1,'hour').asMilliseconds(); 

Con el fin de definir un valor de milisegundos para el intervalo de agregación como lo requieren las operaciones de agregación que se realizan en las fechas.


 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(); } })()