Écrire des sous-classes de propriétés

La classe Property est conçue pour être sous-classée. Cependant, il est normalement plus facile de sous-classer une sous-classe Property existante.

Le nom de tous les attributs spéciaux associés à Property, même ceux considérés comme "publics", commence par un trait de soulignement. En effet, StructuredProperty utilise l'espace de noms d'attribut non souligné pour faire référence aux noms Property imbriqués. Cette caractéristique est essentielle pour spécifier des requêtes sur des sous-propriétés.

La classe Property et ses sous-classes prédéfinies permettent un sous-classement à l'aide des API de validation et de conversion composables (ou empilables). Celles-ci nécessitent les définitions terminologiques suivantes :

  • Une valeur utilisateur correspond à une valeur définie et accessible par le code d'application à l'aide d'attributs standards sur l'entité.
  • Une valeur de base correspond à une valeur sérialisée vers et désérialisée depuis le datastore.

Une sous-classe Property qui met en œuvre une transformation spécifique entre des valeurs utilisateur et des valeurs sérialisables doit utiliser deux méthodes, _to_base_type() et _from_base_type(). Celles-ci ne doivent pas appeler leur méthode super(). C'est ce que l'on entend par des API composables (ou empilables).

L'API accepte les classes d'empilage et utilise une complexité accrue dans le cadre des conversions des valeurs utilisateur/de base : la conversion des valeurs utilisateur vers des valeurs de base devient de moins en moins complexe, tandis que la conversion des valeurs de base vers des valeurs utilisateur devient de plus en plus complexe. Par exemple, vous pouvez vous référer à la relation entre BlobProperty, TextProperty et StringProperty. En effet, TextProperty hérite de BlobProperty ; son code est assez simple, car il hérite de la plupart des comportements dont il a besoin.

Outre _to_base_type() et _from_base_type(), la méthode _validate() est également une API composable.

L'API de validation fait la distinction entre les valeurs utilisateur laxistes et strictes. L'ensemble de valeurs laxistes correspond à un sur-ensemble de l'ensemble de valeurs strictes. La méthode _validate() utilise une valeur laxiste et la convertit en valeur stricte si nécessaire. Cela signifie que lors de la définition de la valeur de propriété, les valeurs laxistes sont acceptées, tandis que lors de l'obtention de la valeur de propriété, seules les valeurs strictes sont renvoyées. Si aucune conversion n'est nécessaire, la méthode _validate() peut renvoyer la valeur "None". Si l'argument se situe en dehors de l'ensemble des valeurs laxistes acceptées, _validate() doit générer une exception, de préférence TypeError ou datastore_errors.BadValueError.

Les éléments _validate(), _to_base_type() et _from_base_type() n'ont pas besoin de gérer les éléments suivants :

  • None : Ils ne seront pas appelés avec None (et s'ils renvoient "None", cela signifie que la valeur n'a pas besoin d'être convertie).
  • Valeurs répétées : L'infrastructure se charge d'appeler _from_base_type() ou _to_base_type() pour chaque élément de liste dans une valeur répétée.
  • Distinction entre les valeurs utilisateur et les valeurs de base : l'infrastructure s'en charge en appelant les API composables.
  • Comparaisons : Les opérations de comparaison appellent _to_base_type() leur opérande.
  • Distinction entre les valeurs utilisateur et les valeurs de base : l’infrastructure garantit que _from_base_type() est appelé avec une valeur de base (désencapsulée) et que _to_base_type() est appelé avec une valeur utilisateur.

Par exemple, supposons que vous ayez besoin de stocker des entiers très longs. L'élément standard IntegerProperty n'accepte que les entiers de 64 bits signés. La propriété peut stocker un entier plus long sous forme de chaîne ; il serait donc préférable que la classe de propriété gère la conversion. Une application utilisant la classe de propriété peut ressembler à ceci :

from datetime import date

import my_models
...
class MyModel(ndb.Model):
    name = ndb.StringProperty()
    abc = LongIntegerProperty(default=0)
    xyz = LongIntegerProperty(repeated=True)
...
# Create an entity and write it to the Datastore.
entity = my_models.MyModel(name='booh', xyz=[10**100, 6**666])
assert entity.abc == 0
key = entity.put()
...
# Read an entity back from the Datastore and update it.
entity = key.get()
entity.abc += 1
entity.xyz.append(entity.abc//3)
entity.put()
...
# Query for a MyModel entity whose xyz contains 6**666.
# (NOTE: using ordering operations don't work, but == does.)
results = my_models.MyModel.query(
    my_models.MyModel.xyz == 6**666).fetch(10)

Cette méthode semble simple et directe. Elle illustre également l'utilisation de certaines options de propriétés standards (par défaut, répétées) ; en tant qu'auteur de LongIntegerProperty, vous n'avez pas besoin de rédiger de "code récurrent" pour les faire fonctionner. Il est plus facile de définir une sous-classe associée à une autre propriété, par exemple :

class LongIntegerProperty(ndb.StringProperty):
    def _validate(self, value):
        if not isinstance(value, (int, long)):
            raise TypeError('expected an integer, got %s' % repr(value))

    def _to_base_type(self, value):
        return str(value)  # Doesn't matter if it's an int or a long

    def _from_base_type(self, value):
        return long(value)  # Always return a long

Lorsque vous définissez une valeur de propriété sur une entité, par exemple ent.abc = 42, votre méthode _validate() est appelée et, si elle ne génère pas d'exception, la valeur est stockée sur l'entité. Lorsque vous écrivez l'entité dans le datastore, la méthode _to_base_type() est appelée et convertit la valeur en chaîne. Cette valeur est ensuite sérialisée par la classe de base, StringProperty. La chaîne d'événements inverse se produit lorsque l'entité est lue depuis le datastore. Les classes StringProperty et Property se chargent des autres détails, tels que la sérialisation et la désérialisation de la chaîne, la définition de la valeur par défaut et la gestion des valeurs de propriété répétées.

Dans cet exemple, la gestion des inégalités (requêtes utilisant <, <=, > et >=) nécessite davantage de travail. L'exemple de mise en œuvre suivant impose une taille maximale du nombre entier et stocke les valeurs sous forme de chaînes à longueur fixe :

class BoundedLongIntegerProperty(ndb.StringProperty):
    def __init__(self, bits, **kwds):
        assert isinstance(bits, int)
        assert bits > 0 and bits % 4 == 0  # Make it simple to use hex
        super(BoundedLongIntegerProperty, self).__init__(**kwds)
        self._bits = bits

    def _validate(self, value):
        assert -(2 ** (self._bits - 1)) <= value < 2 ** (self._bits - 1)

    def _to_base_type(self, value):
        # convert from signed -> unsigned
        if value < 0:
            value += 2 ** self._bits
        assert 0 <= value < 2 ** self._bits
        # Return number as a zero-padded hex string with correct number of
        # digits:
        return '%0*x' % (self._bits // 4, value)

    def _from_base_type(self, value):
        value = int(value, 16)
        if value >= 2 ** (self._bits - 1):
            value -= 2 ** self._bits
        return value

Ceci peut être utilisé de la même manière que LongIntegerProperty, sauf que vous devez transmettre le nombre de bits au constructeur de la propriété, par exemple BoundedLongIntegerProperty(1024).

Vous pouvez sous-classer d'autres types de propriétés de manière similaire.

Cette approche fonctionne également pour stocker des données structurées. Supposons que vous ayez une classe Python FuzzyDate qui représente une plage de dates. celle-ci utilise les champs first et last pour stocker le début et la fin de la plage de dates :

from datetime import date

...
class FuzzyDate(object):
    def __init__(self, first, last=None):
        assert isinstance(first, date)
        assert last is None or isinstance(last, date)
        self.first = first
        self.last = last or first

Vous pouvez créer une classe FuzzyDateProperty dérivée de StructuredProperty. Malheureusement, cette classe ne fonctionne pas avec les anciennes classes Python de base ; une sous-classe Model doit lui être associée. Définissez donc une sous-classe Model comme une représentation intermédiaire.

class FuzzyDateModel(ndb.Model):
    first = ndb.DateProperty()
    last = ndb.DateProperty()

Créez ensuite une sous-classe de StructuredProperty qui code en dur l'argument "modelclass" en FuzzyDateModel et définit les méthodes _to_base_type() et _from_base_type() à convertir entre FuzzyDate et FuzzyDateModel :

class FuzzyDateProperty(ndb.StructuredProperty):
    def __init__(self, **kwds):
        super(FuzzyDateProperty, self).__init__(FuzzyDateModel, **kwds)

    def _validate(self, value):
        assert isinstance(value, FuzzyDate)

    def _to_base_type(self, value):
        return FuzzyDateModel(first=value.first, last=value.last)

    def _from_base_type(self, value):
        return FuzzyDate(value.first, value.last)

Une application peut utiliser cette classe comme suit :

class HistoricPerson(ndb.Model):
    name = ndb.StringProperty()
    birth = FuzzyDateProperty()
    death = FuzzyDateProperty()
    # Parallel lists:
    event_dates = FuzzyDateProperty(repeated=True)
    event_names = ndb.StringProperty(repeated=True)
...
columbus = my_models.HistoricPerson(
    name='Christopher Columbus',
    birth=my_models.FuzzyDate(date(1451, 8, 22), date(1451, 10, 31)),
    death=my_models.FuzzyDate(date(1506, 5, 20)),
    event_dates=[my_models.FuzzyDate(
        date(1492, 1, 1), date(1492, 12, 31))],
    event_names=['Discovery of America'])
columbus.put()

# Query for historic people born no later than 1451.
results = my_models.HistoricPerson.query(
    my_models.HistoricPerson.birth.last <= date(1451, 12, 31)).fetch()

Supposons que vous souhaitiez accepter des objets date de base en plus des objets FuzzyDate en tant que valeurs pour FuzzyDateProperty. Pour ce faire, modifiez la méthode _validate()_validate() comme suit :

def _validate(self, value):
    if isinstance(value, date):
        return FuzzyDate(value)  # Must return the converted value!
    # Otherwise, return None and leave validation to the base class

Vous pouvez plutôt sous-classer FuzzyDateProperty comme suit (en supposant que FuzzyDateProperty._validate() se présente comme indiqué ci-dessus).

class MaybeFuzzyDateProperty(FuzzyDateProperty):
    def _validate(self, value):
        if isinstance(value, date):
            return FuzzyDate(value)  # Must return the converted value!
        # Otherwise, return None and leave validation to the base class

Lorsque vous affectez une valeur à un champ MaybeFuzzyDateProperty, MaybeFuzzyDateProperty._validate() et FuzzyDateProperty._validate() sont tous deux appelés dans cet ordre. Il en va de même pour _to_base_type() et _from_base_type() : les méthodes incluses dans la superclasse et la sous-classe sont combinées de manière implicite. (N'utilisez pas super pour contrôler le comportement hérité à cette fin. Dans le cas de ces trois  méthodes, l'interaction est subtile et super ne fait pas ce que vous voulez).