MongoDB
 sql >> Database >  >> NoSQL >> MongoDB

Raggruppa per data con il fuso orario locale in MongoDB

Problema generale di gestione delle "date locali"

Quindi c'è una risposta breve a questo e anche una risposta lunga. Il caso di base è che invece di utilizzare uno qualsiasi degli "operatori di aggregazione di date" si desidera invece e "devi" effettivamente "fare i calcoli" sugli oggetti data. La cosa principale qui è regolare i valori in base all'offset da UTC per il fuso orario locale specificato e quindi "arrotondare" all'intervallo richiesto.

La "risposta molto più lunga" e anche il problema principale da considerare riguarda il fatto che le date sono spesso soggette a modifiche dell'"ora legale" nell'offset dall'UTC in diversi periodi dell'anno. Quindi questo significa che quando si esegue la conversione in "ora locale" per tali scopi di aggregazione, è necessario considerare dove esistono i limiti di tali modifiche.

C'è anche un'altra considerazione, essendo che, indipendentemente da ciò che si fa per "aggregare" a un determinato intervallo, i valori di output "dovrebbero" almeno inizialmente risultare UTC. Questa è una buona pratica poiché la visualizzazione in "locale" è in realtà una "funzione client" e, come descritto in seguito, le interfacce client avranno comunemente un modo di visualizzare nella lingua attuale che sarà basata sulla premessa che è stata effettivamente alimentata dati come UTC.

Determinazione dell'offset locale e dell'ora legale

Questo è generalmente il problema principale che deve essere risolto. La matematica generale per "arrotondare" una data a un intervallo è la parte semplice, ma non esiste una vera matematica che puoi applicare per sapere quando si applicano tali limiti e le regole cambiano in ogni locale e spesso ogni anno.

Quindi è qui che entra in gioco una "libreria" e l'opzione migliore qui nell'opinione degli autori per una piattaforma JavaScript è moment-timezone, che è fondamentalmente un "superset" di moment.js che include tutte le importanti funzionalità di "timezeone" che vogliamo da usare.

Moment Timezone fondamentalmente definisce una struttura per ogni fuso orario locale come:

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

Dove ovviamente gli oggetti sono molto maggiore rispetto a untils e offsets proprietà effettivamente registrate. Ma questi sono i dati a cui devi accedere per vedere se c'è effettivamente una modifica nell'offset per una zona dati i cambiamenti dell'ora legale.

Questo blocco dell'elenco di codice successivo è ciò che fondamentalmente utilizziamo per determinare dato un start e end valore per un intervallo in cui vengono superati i limiti dell'ora legale, se presenti:

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

Guardando l'intero 2017 per l'Australia/Sydney locale l'output di questo sarebbe:

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

Il che fondamentalmente rivela che tra la prima sequenza di date l'offset sarebbe +11 ore, quindi passa a +10 ore tra le date nella seconda sequenza e quindi torna a +11 ore per l'intervallo che copre la fine dell'anno e il intervallo specificato.

Questa logica deve quindi essere tradotta in una struttura che sarà compresa da MongoDB come parte di una pipeline di aggregazione.

Applicazione della matematica

Il principio matematico qui per l'aggregazione a qualsiasi "intervallo di date arrotondato" si basa essenzialmente sull'utilizzo del valore in millisecondi della data rappresentata che viene "arrotondato" per difetto al numero più vicino che rappresenta l'"intervallo" richiesto.

Lo fai essenzialmente trovando il "modulo" o il "resto" del valore corrente applicato all'intervallo richiesto. Quindi "sottrai" quel resto dal valore corrente che restituisce un valore all'intervallo più vicino.

Ad esempio, data la data corrente:

  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

Questa è la matematica generale che dobbiamo applicare anche nella pipeline di aggregazione utilizzando $subtract e $mod operations, che sono le espressioni di aggregazione utilizzate per le stesse operazioni matematiche mostrate sopra.

La struttura generale della pipeline di aggregazione è quindi:

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

Le parti principali qui che devi capire sono la conversione da una Date oggetto come memorizzato in MongoDB in Numeric che rappresenta il valore del timestamp interno. Abbiamo bisogno della forma "numerica", e per fare questo è un trucco matematico in cui sottraiamo una data BSON da un'altra che produce la differenza numerica tra di loro. Questo è esattamente ciò che fa questa affermazione:

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

Ora abbiamo un valore numerico da gestire, possiamo applicare il modulo e sottrarlo dalla rappresentazione numerica della data per "arrotondarla". Quindi la rappresentazione "diretta" di questo è come:

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

Che rispecchia lo stesso approccio matematico JavaScript mostrato in precedenza ma applicato ai valori del documento effettivi nella pipeline di aggregazione. Noterai anche l'altro "trucco" in cui applichiamo un $add operazione con un'altra rappresentazione di una data BSON a partire da epoch ( o 0 millisecondi ) in cui l'"addizione" di una data BSON a un valore "numerico", restituisce una "data BSON" che rappresenta i millisecondi dati in input.

Ovviamente l'altra considerazione nel codice elencato è l'effettiva "offset" dall'UTC che sta regolando i valori numerici per garantire che l'"arrotondamento" avvenga per il fuso orario attuale. Questo è implementato in una funzione basata sulla precedente descrizione di trovare dove si verificano i diversi offset e restituisce un formato utilizzabile in un'espressione di pipeline di aggregazione confrontando le date di input e restituendo l'offset corretto.

Con l'espansione completa di tutti i dettagli, inclusa la generazione della gestione di quelle diverse differenze orarie "ora legale", sarebbe quindi come:

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

Quell'espansione sta usando il $switch per applicare gli intervalli di date come condizioni a quando restituire i valori di offset indicati. Questa è la forma più conveniente dopo i "branches" argomento corrisponde direttamente a un "array", che è l'output più conveniente degli "intervalli" determinati dall'esame di untils che rappresentano i "punti di taglio" di offset per il fuso orario specificato nell'intervallo di date fornito della query.

È possibile applicare la stessa logica nelle versioni precedenti di MongoDB utilizzando un'implementazione "nidificata" di $cond invece, ma è un po' più complicato da implementare, quindi stiamo solo usando il metodo più conveniente per l'implementazione qui.

Una volta applicate tutte queste condizioni, le date "aggregate" sono in realtà quelle che rappresentano l'ora "locale" come definita dalla locale fornita . Questo in realtà ci porta a qual è la fase finale di aggregazione e il motivo per cui è presente così come la successiva gestione come dimostrato nell'elenco.

Risultati finali

Ho menzionato in precedenza che la raccomandazione generale è che "output" dovrebbe comunque restituire i valori della data in formato UTC di almeno una descrizione, e quindi è esattamente ciò che sta facendo la pipeline qui convertendo prima "da" UTC in locale da applicando l'offset durante l'"arrotondamento", ma poi i numeri finali "dopo il raggruppamento" vengono riadattati dello stesso offset che si applica ai valori di data "arrotondati".

L'elenco qui fornisce "tre" diverse possibilità di output qui come:

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

L'unica cosa da notare qui è che per un "client" come Angular, ognuno di questi formati sarebbe accettato dal proprio DatePipe che può effettivamente eseguire il "formato locale" per te. Ma dipende da dove vengono forniti i dati. Le biblioteche "buone" saranno a conoscenza dell'utilizzo di una data UTC nella locale attuale. In caso contrario, potresti dover "stringere" te stesso.

Ma è una cosa semplice e ottieni il massimo supporto per questo utilizzando una libreria che essenzialmente basa la sua manipolazione dell'output da un "valore UTC dato".

La cosa principale qui è "capire cosa stai facendo" quando chiedi una cosa come l'aggregazione a un fuso orario locale. Tale processo dovrebbe considerare:

  1. I dati possono essere e spesso vengono visualizzati dal punto di vista di persone all'interno di fusi orari diversi.

  2. I dati sono generalmente forniti da persone con fusi orari diversi. In combinazione con il punto 1, questo è il motivo per cui memorizziamo in UTC.

  3. I fusi orari sono spesso soggetti a un "offset" variabile rispetto all'"ora legale" in molti fusi orari mondiali e dovresti tenerne conto durante l'analisi e l'elaborazione dei dati.

  4. Indipendentemente dagli intervalli di aggregazione, l'output "dovrebbe" rimanere in realtà in formato UTC, anche se regolato per aggregare in base all'intervallo in base alla locale fornita. Questo lascia la presentazione da delegare a una funzione "client", proprio come dovrebbe.

Finché tieni a mente queste cose e ti applichi proprio come dimostra l'elenco qui, stai facendo tutte le cose giuste per gestire l'aggregazione di date e persino l'archiviazione generale rispetto a una determinata località.

Quindi "dovresti" farlo, e ciò che "non dovresti" fare è rinunciare e semplicemente memorizzare la "data locale" come stringa. Come descritto, sarebbe un approccio molto scorretto e non causerebbe altro che ulteriori problemi alla tua applicazione.

NOTA :L'unico argomento che non toccherò affatto qui è l'aggregazione a un "mese" ( o addirittura "anno" ) intervallo. I "mesi" sono l'anomalia matematica nell'intero processo poiché il numero di giorni varia sempre e quindi richiede tutta un'altra serie di logica per essere applicato. Descriverlo da solo è lungo almeno quanto questo post, e quindi sarebbe un altro argomento. Per minuti, ore e giorni generali, che è il caso comune, la matematica qui è "abbastanza buona" per quei casi.

Elenco completo

Questo serve come una "dimostrazione" con cui armeggiare. Utilizza la funzione richiesta per estrarre le date e i valori di offset da includere ed esegue una pipeline di aggregazione sui dati forniti.

Puoi cambiare qualsiasi cosa qui, ma probabilmente inizierai con il locale e interval parametri, e poi magari aggiungere dati diversi e start diversi e end date per la query. Ma il resto del codice non deve essere modificato per apportare semplicemente modifiche a nessuno di questi valori e può quindi dimostrare utilizzando intervalli diversi (come 1 hour come richiesto nella domanda ) e diverse impostazioni locali.

Ad esempio, una volta forniti dati validi che richiederebbero effettivamente l'aggregazione a un "intervallo di un'ora", la riga nell'elenco verrebbe modificata come:

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

Per definire un valore in millisecondi per l'intervallo di aggregazione come richiesto dalle operazioni di aggregazione eseguite sulle date.

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