sql >> Base de Datos >  >> NoSQL >> MongoDB

Agrupar por fecha con zona horaria local en MongoDB

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 fechas", prefiere y "necesita" realmente "hacer los cálculos" en los objetos de fecha. Lo principal aquí es ajustar los valores por el desplazamiento de UTC para la zona horaria local dada 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 los cambios del "horario de verano" en el desplazamiento de UTC en diferentes momentos del año. Entonces, esto significa que al convertir a la "hora local" para tales fines de agregación, realmente debe considerar dónde existen 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 salir como UTC. Esta es una buena práctica, ya que la visualización en "localidad" es realmente una "función de cliente" y, como se describe más adelante, las interfaces de cliente normalmente tendrán una forma de mostrarse en la configuración regional actual que se basará en la premisa de que, de hecho, fue alimentada datos como UTC.

Determinación de la compensación local 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 lugar y, a menudo, cada año.

Así que aquí es donde entra en juego una "biblioteca", y la mejor opción aquí, en opinión de los autores, para una plataforma de JavaScript es moment-timezone, 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 local 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 mayor con respecto al untils y offsets propiedades efectivamente registradas. Pero esos son los datos a los que necesita acceder para ver si realmente hay un cambio en la compensación de una zona debido a los cambios de horario de verano.

Este bloque de la lista de código posterior es lo que básicamente usamos para determinar dado un start y end valor para un rango, qué límites de horario de verano se cruzan, si los hay:

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

Mirando todo el 2017 para Australia/Sydney locale 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 vuelve a cambiar a +11 horas para el intervalo que cubre hasta el final del año y el rango especificado.

Luego, esta lógica debe traducirse a una estructura que MongoDB entenderá como parte de una canalización de agregación.

Aplicando las matemáticas

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

Básicamente, hace esto encontrando el "módulo" o "resto" del valor actual aplicado al intervalo requerido. Luego, "restas" ese resto 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 debemos aplicar en la canalización de agregación usando $subtract y $mod operaciones, 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 aquí que debe comprender es la conversión de una Date objeto almacenado en MongoDB a Numeric que representa el valor de la marca de tiempo interna. Necesitamos la forma "numérica", y hacer esto es un truco matemático en el que restamos una fecha BSON de otra, lo que produce la diferencia numérica entre ellas. Esto es exactamente lo que hace esta declaración:

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

Ahora que tenemos un valor numérico con el que lidiar, podemos aplicar el módulo y restarlo de la representación numérica de la fecha para "redondearlo". Así que la representación "directa" 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 aplica a los valores reales del documento en la canalización de agregación. También notará el otro "truco" allí donde aplicamos un $add operación con otra representación de una fecha BSON a partir de la época (o 0 milisegundos) donde la "suma" de una fecha BSON a un valor "numérico" devuelve una "fecha BSON" que representa los milisegundos que se le dio como entrada.

Por supuesto, la otra consideración en el código enumerado es el "desplazamiento" real de UTC que ajusta los valores numéricos para garantizar que el "redondeo" se realice para la zona horaria actual. Esto se implementa en una función basada en la descripción anterior de encontrar dónde ocurren las diferentes compensaciones y devuelve un formato utilizable en una expresión de canalización de agregación al comparar las fechas de entrada y devolver la compensación correcta.

Con la expansión completa de todos los detalles, incluida la generación de manejo de esos diferentes desfases de horario 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á usando el $switch declaración para aplicar los rangos de fechas como condiciones para devolver los valores de compensación dados. Esta es la forma más conveniente desde las "branches" el argumento corresponde directamente a una "matriz", que es la salida más conveniente de los "rangos" determinados por el examen de untils que representa los "puntos de corte" de compensación 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 en cambio, pero es un poco más complicado de implementar, por lo que solo estamos usando el método de implementación más conveniente aquí.

Una vez que se aplican todas esas condiciones, las fechas "agregadas" son en realidad las que representan la hora "local" definida por el locale proporcionado. . Esto realmente nos lleva a lo que es la etapa final de agregación y la razón por la que está allí, así como el manejo posterior como se demuestra en la lista.

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 al "redondear", pero luego los números finales "después de la agrupación" se reajustan con el mismo desplazamiento que se aplica a los valores de fecha "redondeados".

La lista aquí da "tres" posibilidades de salida diferentes 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 a 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" por usted. Pero depende de dónde se suministren los datos. Las bibliotecas "buenas" estarán al tanto del uso de una fecha UTC en la configuración regional actual. Cuando ese no sea el caso, es posible que deba "enredarse" usted mismo.

Pero es algo simple, y obtienes la mayor compatibilidad con esto mediante el uso de una biblioteca que esencialmente basa su manipulación de la salida a partir de un "valor UTC dado".

Lo principal aquí es "comprender lo que está haciendo" cuando pregunta algo como agregar a una zona horaria local. Tal proceso debe considerar:

  1. Los datos se pueden ver y, a menudo, se ven desde la perspectiva de 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 eso que almacenamos en UTC.

  3. Las zonas horarias a menudo están sujetas a un "desplazamiento" cambiante del "horario de verano" en muchas de las zonas horarias del mundo, y debe tenerlo en cuenta al analizar y procesar los datos.

  4. Independientemente de los intervalos de agregación, la salida "debería" permanecer en UTC, aunque ajustada para agregarse en el intervalo según la configuración regional proporcionada. Esto deja la presentación para ser delegada a una función de "cliente", como debería ser.

Siempre que tenga en cuenta estas cosas y las aplique tal como se muestra en la lista aquí, entonces estará haciendo todo lo correcto para manejar la agregación de fechas e incluso el almacenamiento general con respecto a una configuración regional determinada.

Entonces, "debería" estar haciendo esto, y lo que "no debería" estar haciendo es darse por vencido y simplemente almacenar la "fecha de configuración regional" como una cadena. Tal como se describe, sería un enfoque muy incorrecto y solo causaría más problemas para su aplicación.

NOTA :El único tema que no toco aquí en absoluto es agregar a un "mes" (o de hecho "año") intervalo. Los "meses" son la anomalía matemática en todo el proceso, ya que la cantidad de días siempre varía y, por lo tanto, requiere un conjunto de lógica completamente diferente para poder aplicar. Describir eso solo es al menos tan largo como esta publicación y, por lo tanto, sería otro tema. Para minutos, horas y días generales, que es el caso común, las matemáticas aquí son "suficientemente buenas" para esos casos.

Lista completa

Esto sirve como una "demostración" para jugar. Emplea la función necesaria para extraer las fechas y los valores compensados ​​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 el locale y interval parámetros, y luego tal vez agregar diferentes datos y diferentes start y end fechas para la consulta. Pero no es necesario cambiar el resto del código para simplemente realizar cambios en cualquiera de esos valores y, por lo tanto, puede demostrarse usando diferentes intervalos (como 1 hour como se pregunta en la pregunta) y diferentes lugares.

Por ejemplo, una vez que proporcione datos válidos que en realidad requerirían agregación en un "intervalo de 1 hora", la línea en la lista se cambiaría como:

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

Para definir un valor de milisegundos para el intervalo de agregación según lo requieran 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();
  }
})()