MongoDB
 sql >> Base de Dados >  >> NoSQL >> MongoDB

Agrupar por data com fuso horário local no MongoDB

Problema geral de lidar com "datas locais"


Portanto, há uma resposta curta para isso e uma resposta longa também. O caso básico é que, em vez de usar qualquer um dos "operadores de agregação de data", você prefere e "precisa" realmente "fazer as contas" nos objetos de data. O principal aqui é ajustar os valores pelo deslocamento do UTC para o fuso horário local fornecido e depois "arredondar" para o intervalo necessário.

A "resposta muito mais longa" e também o principal problema a considerar envolve que as datas geralmente estão sujeitas a alterações no "horário de verão" no deslocamento do UTC em diferentes épocas do ano. Portanto, isso significa que, ao converter para "hora local" para fins de agregação, você realmente deve considerar onde existem os limites para essas alterações.

Há também outra consideração, sendo que não importa o que você faça para "agregar" em um determinado intervalo, os valores de saída "devem" pelo menos inicialmente sair como UTC. Esta é uma boa prática, pois exibir para "locale" é realmente uma "função de cliente" e, conforme descrito posteriormente, as interfaces de cliente geralmente terão uma maneira de exibir na localidade atual que será baseada na premissa de que foi de fato alimentada dados como UTC.

Determinando o deslocamento de localidade e o horário de verão


Este é geralmente o principal problema que precisa ser resolvido. A matemática geral para "arredondar" uma data para um intervalo é a parte simples, mas não há matemática real que você possa aplicar para saber quando esses limites se aplicam, e as regras mudam em todas as localidades e geralmente todos os anos.

Então é aí que entra uma "biblioteca", e a melhor opção aqui na opinião dos autores para uma plataforma JavaScript é moment-timezone, que é basicamente um "superconjunto" de moment.js incluindo todos os recursos importantes de "timezeone" que queremos usar.

Moment Timezone basicamente define uma estrutura para cada fuso horário 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
}

Onde, é claro, os objetos são muito maior em relação ao untils e offsets propriedades realmente registradas. Mas esses são os dados que você precisa acessar para ver se há realmente uma alteração no deslocamento para uma zona com as alterações de horário de verão.

Este bloco da listagem de código posterior é o que usamos basicamente para determinar um start e end valor para um intervalo, cujos limites de horário de verão são cruzados, se houver:
  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 }] : []
          )
        ,[]);
  }

Olhando para todo o ano de 2017 para a Australia/Sydney locale a saída disso seria:
[
  {
    "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"
  }
]

O que basicamente revela que entre a primeira sequência de datas o deslocamento seria de +11 horas, depois muda para +10 horas entre as datas na segunda sequência e depois volta para +11 horas para o intervalo que abrange o final do ano e o intervalo especificado.

Essa lógica precisa ser traduzida em uma estrutura que será entendida pelo MongoDB como parte de um pipeline de agregação.

Aplicando a matemática


O princípio matemático aqui para agregar a qualquer "intervalo de data arredondado" depende essencialmente do uso do valor de milissegundos da data representada que é "arredondada" para o número mais próximo que representa o "intervalo" necessário.

Você essencialmente faz isso encontrando o "módulo" ou "restante" do valor atual aplicado ao intervalo necessário. Então você "subtrai" esse resto do valor atual que retorna um valor no intervalo mais próximo.

Por exemplo, dada a data atual:
  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 é a matemática geral que também precisamos aplicar no pipeline de agregação usando o $subtract e $mod operações, que são as expressões de agregação usadas para as mesmas operações matemáticas mostradas acima.

A estrutura geral do pipeline de agregação é então:
    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 } }
    ];

As partes principais aqui que você precisa entender são a conversão de uma Date objeto como armazenado no MongoDB para Numeric representando o valor do carimbo de data/hora interno. Precisamos da forma "numérica", e para fazer isso é um truque de matemática em que subtraímos uma data BSON de outra, o que produz a diferença numérica entre elas. Isso é exatamente o que esta declaração faz:
{ "$subtract": [ "$createdAt", new Date(0) ] }

Agora temos um valor numérico para lidar, podemos aplicar o módulo e subtraí-lo da representação numérica da data para "arredondar". Portanto, a representação "reta" disso é como:
{ "$subtract": [
  { "$subtract": [ "$createdAt", new Date(0) ] },
  { "$mod": [
    { "$subtract": [ "$createdAt", new Date(0) ] },
    ( 1000 * 60 * 60 * 24 ) // 24 hours
  ]}
]}

Que espelha a mesma abordagem matemática JavaScript mostrada anteriormente, mas aplicada aos valores reais do documento no pipeline de agregação. Você também notará o outro "truque" onde aplicamos um $add operação com outra representação de uma data BSON a partir da época ( ou 0 milissegundos ) onde a "adição" de uma Data BSON a um valor "numérico", retorna uma "Data BSON" representando os milissegundos que foram dados como entrada.

É claro que a outra consideração no código listado é o "deslocamento" real do UTC, que está ajustando os valores numéricos para garantir que o "arredondamento" ocorra para o fuso horário atual. Isso é implementado em uma função com base na descrição anterior de localizar onde ocorrem os diferentes deslocamentos e retorna um formato utilizável em uma expressão de pipeline de agregação comparando as datas de entrada e retornando o deslocamento correto.

Com a expansão completa de todos os detalhes, incluindo a geração de manipulação desses diferentes deslocamentos de horário de "horário de verão", seria assim:
[
  {
    "$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
    }
  }
]

Essa expansão está usando o $switch instrução para aplicar os intervalos de datas como condições para quando retornar os valores de deslocamento fornecidos. Esta é a forma mais conveniente, pois as "branches" O argumento corresponde diretamente a um "array", que é a saída mais conveniente dos "ranges" determinados pelo exame do untils representando os "pontos de corte" de deslocamento para o fuso horário especificado no intervalo de datas fornecido da consulta.

É possível aplicar a mesma lógica em versões anteriores do MongoDB usando uma implementação "aninhada" de $cond em vez disso, mas é um pouco mais complicado de implementar, então estamos apenas usando o método mais conveniente de implementação aqui.

Uma vez que todas essas condições são aplicadas, as datas "agregadas" são, na verdade, aquelas que representam a hora "local", conforme definido pelo locale fornecido . Isso realmente nos leva ao que é o estágio final de agregação e o motivo pelo qual ele está lá, bem como o manuseio posterior, conforme demonstrado na listagem.

Resultados finais


Eu mencionei anteriormente que a recomendação geral é que a "saída" ainda deve retornar os valores de data no formato UTC de pelo menos alguma descrição e, portanto, é exatamente isso que o pipeline aqui está fazendo convertendo primeiro "de" UTC para local por aplicando o deslocamento ao "arredondar", mas os números finais "após o agrupamento" são reajustados pelo mesmo deslocamento que se aplica aos valores de data "arredondados".

A listagem aqui fornece "três" diferentes possibilidades de saída aqui 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
  }
]

A única coisa a ser observada aqui é que, para um "cliente" como o Angular, cada um desses formatos seria aceito por seu próprio DatePipe, que pode realmente fazer o "formato de localidade" para você. Mas isso depende de onde os dados são fornecidos. Bibliotecas "boas" estarão cientes de usar uma data UTC na localidade atual. Onde esse não for o caso, talvez você precise "stringificar" a si mesmo.

Mas é uma coisa simples, e você obtém o maior suporte para isso usando uma biblioteca que basicamente baseia sua manipulação de saída de um "valor UTC fornecido".

O principal aqui é "entender o que você está fazendo" quando pergunta algo como agregar a um fuso horário local. Tal processo deve considerar:

  1. Os dados podem ser e muitas vezes são vistos da perspectiva de pessoas em diferentes fusos horários.

  2. Os dados geralmente são fornecidos por pessoas em diferentes fusos horários. Combinado com o ponto 1, é por isso que armazenamos em UTC.

  3. Os fusos horários geralmente estão sujeitos a uma mudança de "deslocamento" do "Horário de verão" em muitos dos fusos horários do mundo, e você deve considerar isso ao analisar e processar os dados.

  4. Independentemente dos intervalos de agregação, a saída "deve" de fato permanecer em UTC, embora ajustada para agregar no intervalo de acordo com a localidade fornecida. Isso deixa a apresentação a ser delegada a uma função "cliente", exatamente como deveria.

Contanto que você mantenha essas coisas em mente e aplique exatamente como a listagem aqui demonstra, você está fazendo todas as coisas certas para lidar com agregação de datas e até mesmo armazenamento geral em relação a uma determinada localidade.

Então você "deveria" estar fazendo isso, e o que você "não deveria" estar fazendo é desistir e simplesmente armazenar a "data local" como uma string. Conforme descrito, essa seria uma abordagem muito incorreta e não causa nada além de problemas adicionais para seu aplicativo.

OBSERVAÇÃO :O único tópico que não toco aqui é agregar a um "mês" (ou mesmo "ano" ) intervalo. "Meses" são a anomalia matemática em todo o processo, pois o número de dias sempre varia e, portanto, requer todo um outro conjunto de lógica para ser aplicado. Descrever isso por si só é pelo menos tão longo quanto este post e, portanto, seria outro assunto. Para minutos, horas e dias gerais, que é o caso comum, a matemática aqui é "boa o suficiente" para esses casos.

Lista completa


Isso serve como uma "demonstração" para mexer. Ele emprega a função necessária para extrair as datas e valores de deslocamento a serem incluídos e executa um pipeline de agregação sobre os dados fornecidos.

Você pode alterar qualquer coisa aqui, mas provavelmente começará com o locale e interval parâmetros, e então talvez adicionar dados diferentes e diferentes start e end datas para a consulta. Mas o resto do código não precisa ser alterado para simplesmente fazer alterações em qualquer um desses valores e, portanto, pode ser demonstrado usando intervalos diferentes (como 1 hour conforme solicitado na pergunta ) e diferentes localidades.

Por exemplo, depois de fornecer dados válidos que realmente exigiriam agregação em um "intervalo de 1 hora", a linha na listagem seria alterada como:
const interval = moment.duration(1,'hour').asMilliseconds();

Para definir um valor de milissegundos para o intervalo de agregação conforme exigido pelas operações de agregação que estão sendo executadas nas datas.
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();
  }
})()