Chez Deepki, au sein de notre application Deepki Ready, nous travaillons avec différents types de données et notamment des données temporelles. Par exemple, nous collectons automatiquement l’ensemble des factures de nos clients qui nous donnent une information au pas de temps mensuel mais nous récupérons également des données beaucoup plus précises comme les données des compteurs télérelevés qui nous donnent un index de consommation toutes les 10 minutes. Chacune des ces données temporelles peut comprendre plusieurs dimensions : l’année, le mois, le jour, l’heure, la minute et la seconde. Dans un contexte d’internationalisation et de croissance de notre activité, les questions autour du format, du stockage et de la gestion du fuseau horaire de ces données temporelles constituent un véritable enjeu pour notre application.

Comment manipuler facilement l’objet datetime dans la base de données au sein d’une application qui doit permettre à nos clients de suivre la performance ESG de leurs bâtiments à travers le monde ?

Au sein de cet article, je vous propose d’essayer de répondre à cette question. Dans un premier temps nous verrons quels sont les différents formats de dates existants et les problèmes qu’ils soulèvent. Puis nous nous attarderons sur le problème du stockage d’un objet datetime avec le bon fuseau horaire dans notre base de données MongoDB. Enfin, dans une dernière partie, nous essaierons de voir quelle est la solution la plus adaptée.

1. Les différents formats de l’objet datetime

Python peut stocker un objet datetime sous plusieurs formats dans MongoDB grâce à la librairie PyMongo:

  1. String: “2021-10-25”, “2021-10-25T10:00:00”, “2021-10-25T10:00:00.000000Z”, “2021-10-25T10:00:00.000000”, “2021-10-25T10:00:00+00:00”
  2. Timestamp: Timestamp(1618910203, 1)
  3. ISODate: ISODate(“2021-10-25T10:00:00.000Z”)

Chacun de ces formats permet de rechercher efficacement notre donnée à l’aide d’une plage de dates. Si l’on souhaite connaître l’heure locale il faut cependant bien prendre en compte le fuseau horaire. Le Z présent dans le datetime fait référence à l’heure Zoulou, également appelée le temps universel coordonné (UTC). Il spécifie la date et l’heure en UTC.

UTC est une échelle de temps adoptée comme base du temps civil international par la majorité des pays du globe. Les fuseaux horaires du monde entier sont exprimés en utilisant des décalages positifs ou négatifs par rapport à l’UTC, comme dans la liste des fuseaux horaires par décalage UTC. Si l’on veut connaître l’heure locale, il suffit donc d’ajouter à l’UTC le fuseau horaire dans lequel on se trouve.

1.1 String

Le code ci-dessous permet de tester la recherche Python dans MongoDB avec la date au format string :

>>> db.test.insert_many([
        {'date': "2021-10-25"},
        {'date': "2021-10-25T10:00:00"},
        {'date': "2021-10-25T10:00:00.000000Z"},
        {'date': "2021-10-25T10:00:00.000000"},
        {'date': "2021-10-25T10:00:00+00:00"}
    ])

>>> list(db.test.find({'date': {'$gt': '2021-10-25'}}, {'_id':0, 'date': 1}))
[
    {'date': '2021-10-25T10:00:00'},
    {'date': '2021-10-25T10:00:00.000000Z'},
    {'date': '2021-10-25T10:00:00.000000'},
    {'date': '2021-10-25T10:00:00+00:00'}
]

>>> list(db.test.find({'date': {'$gt': '2021-10-25T10:00:00'}}, {'_id':0, 'date': 1}))
[
    {'date': '2021-10-25T10:00:00.000000Z'},
    {'date': '2021-10-25T10:00:00.000000'},
    {'date': '2021-10-25T10:00:00+00:00'}
]

>>> list(db.test.find({'date': {'$gt': '2021-10-25T10:00:00.00'}}, {'_id':0, 'date': 1}))
[
    {'date': '2021-10-25T10:00:00.000000Z'},
    {'date': '2021-10-25T10:00:00.000000'}
]

De notre point de vue, 2021-10-25T10:00:00 et 2021-10-25T10:00:00.000000 sont les mêmes heures, pourtant lorsque l’on fait une recherche en python, 2021-10-25T10:00:00.000000 est plus récent que 2021-10-25T10:00:00. Le format string ne permet donc pas de filtrer efficacement nos recherches.

1.2 Timestamp

Timestamp est un type d’horodatage spécial pour l’utilisation interne de MongoDB et n’est pas associé au type de datetime normale. Sa taille est de 64 bits lorsque :

  • la partie la plus significative est une valeur time_t (nombre de secondes depuis l’époque Unix).
  • l’autre partie la moins significative est un ordinal incrémental pour les opérations qui ont lieu dans la même seconde.
>>> from bson.objectid import ObjectId
>>> from datetime import datetime
>>> from bson.timestamp import Timestamp

>>> db.test.insert_one({'name': 'ts', 'date': Timestamp(datetime.utcnow(), 1)})
>>> db.test.find_one({'name': 'ts'})
{'_id': ObjectId('61766465f3ad38f02058230b'), 'name': 'ts', 'date': Timestamp(1635148901, 1)}

>>> Timestamp(1635148901, 1).as_datetime()
datetime.datetime(2021, 10, 25, 8, 1, 41, tzinfo=<bson.tz_util.FixedOffset object at 0x11e0356a0>)

>>> Timestamp(1635148901, 1).as_datetime() == ObjectId('61766465f3ad38f02058230b').generation_time
True

ObjectId est un nombre hexadécimal de 12 octets. Les 4 premiers octets sont une valeur d’horodatage représentant la création de l’ObjectId, mesurée en secondes depuis l’époque Unix (1970-01-01 00:00:00). On peut directement lire la date de création d’un document en lisant son ObjectId. Cependant, l’affichage du Timestamp dans la base de données sous forme de chaîne de chiffres n’est pas intuitif. De plus, le Timestamp est compris entre “1970-01-01 00:00:01” et “2038-01-19 03:14:07 UTC” et l’année 2038 n’est plus très loin.

1.3 ISODate

ISODate est un type de date avec la datetime spécifiée en UTC. Ce format de Date est un entier de 64 bits qui représente le nombre de millisecondes depuis l’époque Unix (1er janvier 1970). Il en résulte une plage de dates possible d’environ 290 millions d’années dans le passé et le futur. On peut voir la valeur comme ISODate("2021-10-25T10:00:00.000Z") au sein de MongoDB ce qui signifie que le temps est en UTC.

2. Le problème du fuseau horaire

Imaginons le scénario suivant :

On est en France et on insère dans MongoDB un document avec une date au format datetime.

# En France
>>> now = datetime.now()
>>> db.test.insert_one({'name': 'abc', 'date': now})
>>> db.test.find_one({'date': {'$lte': now}}, {'_id': 0})
{'name': 'abc',
 'date': datetime.datetime(2021, 10, 25, 10, 4, 21, 357000)}

10 minutes plus tard, un client au Royaume-Uni veut consulter ce document, il réalise :

  • une recherche Python comme suit :
# Au Royaume-Uni
>>> db.test.find_one({'date': {'$lte': datetime.now()}}, {'_id': 0})
# rien trouvé
  • une recherche MongoDB comme suit :
> db.test.findOne({'date': {'$lte': new Date()}})
null

Le client au Royaume-Uni ne trouve rien du tout ! Mais pourquoi ?

Parce que datetime.now() en France est ISODate("2021-10-25T10:04:21.357Z") tandis que datetime.now() au Royaume-Uni et new Date() dans le shell mongo sont ISODate("2021-10-25T08:14:21.357Z"). On voit qu’il y a une différence de 1 heure et 50 minutes entre les deux dates, pas seulement 10 minutes. Le shell mongo enveloppe l’objet Date avec l’assistant ISODate, l’ISODate est en UTC et il n’est en fait qu’une fonction d’aide du shell. Lorsqu’elle est stockée en interne dans MongoDB, c’est sous forme d’horodatage 64 bits. L’horodatage (Timestamp en anglais) est indépendant du fuseau horaire.

En conclusion, l’exemple ci-dessus utilise datetime.now() pour générer une date locale. Cette date n’a pas d’informations sur le fuseau horaire (on dit qu’elle est naive). Ensuite PyMongo convertit cette date en UTC (voir def _datetime_to_millis au Chapitre 3). Une fois stockée dans MongoDB, cette date apparaît en UTC avec 2 heures de moins que l’heure locale.

Donc si on insère un document comme suit :

>>> db.test.insert_one({'name': 'abcd', 'date': datetime.utcnow()})

On peut le retrouver immédiatement, peu importe l’heure locale :

> db.test.findOne({'date': {'$lte': new Date()}}, {'_id': 0})
{
  "name" : "abcd",
  "date" : ISODate("2021-10-25T08:06:44.789Z")
}

3. Comment résoudre ce problème du fuseau horaire ?

Le code source de PyMongo, le driver python pour MongoDB

Ici on voit le code de PyMongo 3.11.3 pour trouver la relation entre un objet de type Timestamp et un de type datetime

EPOCH_AWARE = datetime.datetime.fromtimestamp(0, utc)
EPOCH_NAIVE = datetime.datetime.utcfromtimestamp(0)


def _millis_to_datetime(millis, opts):
    """Convert milliseconds since epoch UTC to datetime."""
    diff = ((millis % 1000) + 1000) % 1000
    seconds = (millis - diff) // 1000
    micros = diff * 1000
    if opts.tz_aware:
        # Si on tient compte du fuseau horaire, ici plus EPOCH_AWARE
        # pour prendre en compte le décalage du fuseau horaire
        dt = EPOCH_AWARE + datetime.timedelta(seconds=seconds,
                                              microseconds=micros)
        if opts.tzinfo:
            dt = dt.astimezone(opts.tzinfo)
        return dt
    else:
        # Si on ne tient pas compte du fuseau horaire, ici
        # plus EPOCH_NAIVE (1970-01-01 00:00:00) pour prendre l'heure locale
        return EPOCH_NAIVE + datetime.timedelta(seconds=seconds,
                                                microseconds=micros)


def _datetime_to_millis(dtm):
    """Convert datetime to milliseconds since epoch UTC."""
    if dtm.utcoffset() is not None:
        # Il a le fuseau horaire, donc ici on convertit le datetime au temps UTC
        dtm = dtm - dtm.utcoffset()
    # Il n'a pas le fuseau horaire, donc ici on retourne la différence entre '1970-01-01 00:00:00' et dtm
    return int(calendar.timegm(dtm.timetuple()) * 1000 +
               dtm.microsecond // 1000)

Comme on le voit, quand on insère une date dans MongoDB sans préciser le fuseau horaire, ce dernier calcule la différence entre le temps local et 1970-01-01 00:00:00, mais pas 1970-01-01 02:00:00 (ce qui ajoute 2 heures pour le fuseau horaire Europe/Paris). On obtient un décalage de 2 heures entre ce qu’on croyait faire et ce qu’on a inséré dans la base de données.

La solution

Étape 1: Insérez les bonnes données

Avant d’insérer notre objet datetime en base on a deux possibilités :

  • Soit c’est le temps UTC, pas le temps local.
  • Soit on spécifie un fuseau horaire.
>>> datetime.utcnow()
datetime.datetime(2021, 10, 25, 8, 28, 34, 969452)
>>> db.test.insert_one({'name': 'abc', 'date': datetime.utcnow()})

# OU

>>> import pytz
>>> france = pytz.timezone('Europe/Paris')
>>> fr_datetime = france.localize(datetime.now())
# datetime.datetime(2021, 10, 25, 10, 28, 34, 969452, tzinfo=<DstTzInfo 'Europe/Paris' CEST+2:00:00 DST>)
>>> db.test.insert_one({'name': 'abc', 'date': fr_datetime})

Étape 2 Spécifiez le fuseau horaire dans la connexion

Si le fuseau horaire est spécifié lors de la connexion, il sera utilisé pour tous les échanges avec MongoDB.

>>> import pytz
>>> from datetime import datetime
>>> from bson.codec_options import CodecOptions

>>> db_aware_times = db.test.with_options(codec_options=CodecOptions(
    tz_aware=True,
    tzinfo=pytz.timezone('Europe/Paris'))
)

>>> db_aware_times.find_one({'name': 'abc'}, {'_id': 0})
{
    'name': 'abc', 
    'date': datetime.datetime(
            2021, 10, 25, 10, 28, 34, 969000, 
            tzinfo=<DstTzInfo 'Europe/Paris' CEST+2:00:00 DST>
    )
}

4. Conclusion

Nous l’avons vu à travers ces quelques lignes, MongoDB peut stocker des dates sous différents formats: String ou des entiers de 64-bit (Timestamp et ISODate).

Si vous n’êtes pas concerné par le soucis des fuseaux horaires et recherchez un moyen simple d’afficher une date à l’utilisateur, alors le format String peut être un choix judicieux. De plus, l’objet de datetime n’est pas sérialisable en JSON : il faut au préalable le convertir au format string. Stocker vos dates au format String vous permettra d’utiliser directement la sortie d’une requête MongoDB sans avoir besoin de convertir les données. Par contre on ne peut pas filtrer efficacement les dates au format String, ni faire d’agrégations.

Si vous souhaitez indexer vos données par date, effectuer des requêtes MongoDB ou des fonctions d’agrégation, stocker vos dates sous le format Timestamp ou ISODate est un bon choix.

En conclusion, parmi les 3 types de date (String, Timestamp et ISODate), ISODate est la meilleure façon de stocker une date dans MongoDB. Il est plus intuitif que Timestamp et à l’inverse de ce dernier il n’est pas limité jusqu’en 2038. Quand on utilise la library PyMongo, il est important de préciser le fuseau horaire à l’insertion et à la connexion.