MongoDB
 sql >> Base de données >  >> NoSQL >> MongoDB

Grouper par date avec le fuseau horaire local dans MongoDB

Problème général de gestion des "dates locales"

Il y a donc une réponse courte à cela et une réponse longue également. Le cas de base est qu'au lieu d'utiliser l'un des "opérateurs d'agrégation de date", vous voulez plutôt et "avez besoin" de "faire le calcul" sur les objets de date à la place. La chose principale ici est d'ajuster les valeurs par le décalage par rapport à UTC pour le fuseau horaire local donné, puis "d'arrondir" à l'intervalle requis.

La "réponse beaucoup plus longue" et aussi le principal problème à considérer implique que les dates sont souvent sujettes à des changements "d'heure d'été" dans le décalage par rapport à UTC à différents moments de l'année. Cela signifie donc que lors de la conversion à "l'heure locale" à de telles fins d'agrégation, vous devez vraiment prendre en compte les limites de ces changements.

Il y a aussi une autre considération, étant que peu importe ce que vous faites pour "agréger" à un intervalle donné, les valeurs de sortie "devraient" au moins initialement sortir en UTC. C'est une bonne pratique car l'affichage dans les "locales" est vraiment une "fonction client", et comme décrit plus tard, les interfaces client auront généralement un moyen d'afficher dans les paramètres régionaux actuels qui seront basés sur la prémisse qu'il a en fait été alimenté données au format UTC.

Déterminer le décalage local et l'heure d'été

C'est généralement le principal problème à résoudre. Le calcul général pour "arrondir" une date à un intervalle est la partie la plus simple, mais il n'y a pas de véritable calcul que vous pouvez appliquer pour savoir quand de telles limites s'appliquent, et les règles changent dans chaque région et souvent chaque année.

C'est donc là qu'une "bibliothèque" entre en jeu, et la meilleure option ici de l'avis des auteurs pour une plate-forme JavaScript est moment-timezone, qui est essentiellement un "surensemble" de moment.js comprenant toutes les fonctionnalités importantes de "timezeone" que nous voulons à utiliser.

Moment Timezone définit essentiellement une telle structure pour chaque fuseau horaire local comme :

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

Où bien sûr les objets sont beaucoup plus grand par rapport aux untils et offsets biens effectivement enregistrés. Mais ce sont les données auxquelles vous devez accéder pour voir s'il y a réellement un changement dans le décalage d'une zone en fonction des changements d'heure d'été.

Ce bloc de la liste de code ultérieure est ce que nous utilisons essentiellement pour déterminer un start donné et end valeur pour une plage, quelles limites d'heure d'été sont franchies, le cas échéant :

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

Regard sur l'ensemble de 2017 pour Australia/Sydney locale la sortie de ceci serait :

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

Ce qui révèle essentiellement qu'entre la première séquence de dates, le décalage serait de +11 heures puis passe à +10 heures entre les dates de la deuxième séquence, puis repasse à +11 heures pour l'intervalle couvrant la fin de l'année et le plage spécifiée.

Cette logique doit ensuite être traduite en une structure qui sera comprise par MongoDB dans le cadre d'un pipeline d'agrégation.

Appliquer les mathématiques

Le principe mathématique ici pour l'agrégation à tout "intervalle de date arrondi" repose essentiellement sur l'utilisation de la valeur en millisecondes de la date représentée qui est "arrondie" au nombre le plus proche représentant "l'intervalle" requis.

Vous faites essentiellement cela en trouvant le "modulo" ou le "reste" de la valeur actuelle appliquée à l'intervalle requis. Ensuite, vous "soustrayez" ce reste de la valeur actuelle qui renvoie une valeur à l'intervalle le plus proche.

Par exemple, étant donné la date actuelle :

  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

C'est le calcul général que nous devons également appliquer dans le pipeline d'agrégation en utilisant le $subtract et $mod opérations, qui sont les expressions d'agrégation utilisées pour les mêmes opérations mathématiques indiquées ci-dessus.

La structure générale du pipeline d'agrégation est alors :

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

Les parties principales ici que vous devez comprendre sont la conversion à partir d'une Date objet tel qu'il est stocké dans MongoDB en Numeric représentant la valeur d'horodatage interne. Nous avons besoin de la forme "numérique", et pour ce faire, c'est une astuce mathématique où nous soustrayons une date BSON d'une autre, ce qui donne la différence numérique entre elles. C'est exactement ce que fait cette déclaration :

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

Maintenant que nous avons une valeur numérique à gérer, nous pouvons appliquer le modulo et le soustraire de la représentation numérique de la date afin de "l'arrondir". Ainsi, la représentation "simple" de ceci est :

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

Ce qui reflète la même approche mathématique JavaScript que celle présentée précédemment, mais appliquée aux valeurs réelles du document dans le pipeline d'agrégation. Vous y noterez également l'autre "astuce" où l'on applique un $add opération avec une autre représentation d'une date BSON à partir de l'époque (ou 0 millisecondes) où "l'ajout" d'une date BSON à une valeur "numérique", renvoie une "date BSON" représentant les millisecondes qui lui ont été données en entrée.

Bien sûr, l'autre considération dans le code répertorié est le "décalage" réel par rapport à UTC qui ajuste les valeurs numériques afin de garantir que "l'arrondi" a lieu pour le fuseau horaire actuel. Ceci est implémenté dans une fonction basée sur la description précédente de la recherche de l'emplacement des différents décalages, et renvoie un format utilisable dans une expression de pipeline d'agrégation en comparant les dates d'entrée et en renvoyant le décalage correct.

Avec l'expansion complète de tous les détails, y compris la génération de la gestion de ces différents décalages horaires "Daylight Savings", ce serait alors :

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

Cette extension utilise le $switch afin d'appliquer les plages de dates comme conditions de retour des valeurs de décalage données. C'est la forme la plus pratique depuis les "branches" l'argument correspond directement à un "tableau", qui est la sortie la plus pratique des "plages" déterminées par l'examen des untils représentant le décalage "points de coupure" pour le fuseau horaire donné sur la plage de dates fournie de la requête.

Il est possible d'appliquer la même logique dans les versions antérieures de MongoDB en utilisant une implémentation "imbriquée" de $cond à la place, mais c'est un peu plus compliqué à implémenter, nous utilisons donc ici la méthode d'implémentation la plus pratique.

Une fois toutes ces conditions appliquées, les dates "agrégées" sont en fait celles représentant l'heure "locale" telle que définie par le locale fourni . Cela nous amène en fait à ce qu'est l'étape d'agrégation finale, et la raison pour laquelle elle est là ainsi que la manipulation ultérieure comme démontré dans la liste.

Résultats finaux

J'ai mentionné plus tôt que la recommandation générale est que la "sortie" doit toujours renvoyer les valeurs de date au format UTC d'au moins une description, et c'est donc exactement ce que fait le pipeline ici en convertissant d'abord "de" UTC en local par en appliquant le décalage lors de "l'arrondi", mais ensuite les nombres finaux "après le regroupement" sont réajustés par le même décalage qui s'applique aux valeurs de date "arrondies".

La liste ici donne ici "trois" possibilités de sortie différentes comme :

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

La seule chose à noter ici est que pour un "client" tel que Angular, chacun de ces formats serait accepté par son propre DatePipe qui peut en fait faire le "format de paramètres régionaux" pour vous. Mais cela dépend de l'endroit où les données sont fournies. Les "bonnes" bibliothèques seront conscientes de l'utilisation d'une date UTC dans la locale actuelle. Si ce n'est pas le cas, vous devrez peut-être vous "chaîner".

Mais c'est une chose simple, et vous obtenez le meilleur support pour cela en utilisant une bibliothèque qui base essentiellement sa manipulation de la sortie à partir d'une "valeur UTC donnée".

L'essentiel ici est de "comprendre ce que vous faites" lorsque vous demandez une chose telle que l'agrégation à un fuseau horaire local. Un tel processus doit prendre en compte :

  1. Les données peuvent être et sont souvent vues du point de vue de personnes dans différents fuseaux horaires.

  2. Les données sont généralement fournies par des personnes dans des fuseaux horaires différents. Combiné avec le point 1, c'est pourquoi nous stockons en UTC.

  3. Les fuseaux horaires sont souvent soumis à un "décalage" changeant par rapport à "l'heure d'été" dans de nombreux fuseaux horaires mondiaux, et vous devez en tenir compte lors de l'analyse et du traitement des données.

  4. Indépendamment des intervalles d'agrégation, la sortie "devrait" en fait rester en UTC, bien qu'ajustée pour s'agréger sur l'intervalle en fonction des paramètres régionaux fournis. Cela laisse la présentation être déléguée à une fonction "client", comme il se doit.

Tant que vous gardez ces choses à l'esprit et que vous les appliquez comme le montre la liste ici, vous faites tout ce qu'il faut pour gérer l'agrégation des dates et même le stockage général par rapport à un paramètre régional donné.

Donc, vous "devriez" faire cela, et ce que vous "ne devriez pas" faire, c'est abandonner et simplement stocker la "date locale" sous forme de chaîne. Comme décrit, ce serait une approche très incorrecte et ne causerait que des problèmes supplémentaires pour votre application.

REMARQUE :Le seul sujet que je n'aborde pas du tout ici est l'agrégation en un "mois" ( ou bien une "année" ) intervalle. Les "mois" sont l'anomalie mathématique dans l'ensemble du processus puisque le nombre de jours varie toujours et nécessite donc un tout autre ensemble de logique pour s'appliquer. Décrire cela seul est au moins aussi long que ce post, et serait donc un autre sujet. Pour les minutes, les heures et les jours généraux, ce qui est le cas courant, les calculs ici sont "assez bons" pour ces cas.

Liste complète

Cela sert de "démonstration" à bricoler. Il utilise la fonction requise pour extraire les dates de décalage et les valeurs à inclure et exécute un pipeline d'agrégation sur les données fournies.

Vous pouvez changer n'importe quoi ici, mais commencerez probablement par le locale et interval paramètres, puis peut-être ajouter des données différentes et différents start et end dates de la requête. Mais le reste du code n'a pas besoin d'être modifié pour simplement apporter des modifications à l'une de ces valeurs, et peut donc démontrer en utilisant différents intervalles (tels que 1 hour comme demandé dans la question ) et différents paramètres régionaux.

Par exemple, une fois que vous avez fourni des données valides qui nécessiteraient en fait une agrégation à un "intervalle d'une heure", la ligne de la liste serait modifiée comme suit :

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

Afin de définir une valeur en millisecondes pour l'intervalle d'agrégation tel que requis par les opérations d'agrégation effectuées aux dates.

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