Como gravar subclasses de propriedades

A classe Property foi projetada para ser subclassificada. No entanto, normalmente é mais fácil subclassificar uma subclasse Property existente.

Todos os atributos de Property especiais, mesmo aqueles considerados "públicos", têm nomes que começam com um sublinhado. Isso ocorre porque StructuredProperty usa o namespace de atributo não sublinhado para se referir a nomes de Property aninhados. Isso é essencial para especificar consultas em subpropriedades.

A classe Property e suas subclasses predefinidas permitem subclassificar usando APIs de validação e conversão para composição ou empilhamento. Elas exigem algumas definições de terminologia:

  • Um user value é um valor que é definido e acessado pelo código do aplicativo usando atributos padrão na entidade.
  • Um base value é um valor que é serializado e desserializado do Datastore.

Uma subclasse Property que implementa uma transformação específica entre valores de usuário e valores serializáveis precisa implementar dois métodos, _to_base_type() e _from_base_type(). Eles não podem chamar o método super() deles. É isso que APIs para composição (ou empilhamento) quer dizer.

A API é compatível com classes stacking com conversões usuário-base mais sofisticadas: a conversão usuário-para-base vai da mais sofisticada à menos sofisticada, enquanto a conversão base-para-usuário vai da menos sofisticada à mais sofisticada. Por exemplo, veja a relação entre BlobProperty, TextProperty e StringProperty. Por exemplo, TextProperty herda de BlobProperty. O código é bem simples porque herda a maior parte do comportamento de que precisa.

Além de _to_base_type() e _from_base_type(), o método _validate() também é uma API composta.

A API de validação distingue entre valores de usuário lax e rígidos. O conjunto de valores lax é um superconjunto do conjunto de valores rígidos. O método _validate() recebe um valor lax e, se necessário, o converte em um valor rígido. Isso significa que ao definir o valor da propriedade, os valores lax são aceitos, enquanto ao receber o valor da propriedade, apenas valores rígidos serão retornados. Caso nenhuma conversão seja necessária, _validate() pode retornar None. Se o argumento estiver fora do conjunto de valores lax aceitos, _validate() gerará uma exceção, de preferência TypeError ou datastore_errors.BadValueError.

O _validate(), _to_base_type() e _from_base_type() não precisam lidar com:

  • None: eles não serão chamados com None (e se retornarem None, isso significa que o valor não precisa de conversão).
  • Valores repetidos: a infraestrutura cuida de chamar _from_base_type() ou _to_base_type() para cada item da lista em um valor repetido.
  • Distinguindo valores de usuário dos valores base: a infraestrutura gerencia isso chamando as APIs de composição.
  • Comparações: as operações de comparação chamam _to_base_type() no operando.
  • Distinção entre os valores do usuário e da base: a infraestrutura garante que _from_base_type() será chamado com um valor base (sem pacote) e que _to_base_type() será chamado com um valor de usuário.

Por exemplo, imagine que você precisa armazenar números inteiros muito longos. O padrão IntegerProperty suporta apenas números inteiros de 64 bits (assinados). Sua propriedade pode armazenar um inteiro mais longo como uma string. Seria bom que a classe de propriedades manipulasse a conversão. Um aplicativo usando sua classe de propriedade pode parecer algo como

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)

Isso parece simples e direto. Ele também demonstra o uso de algumas opções de propriedade padrão (padrão, repetidas). Como autor de LongIntegerProperty, você ficará feliz em saber que não precisa escrever nenhum "clichê" para que eles funcionem. É mais fácil definir uma subclasse de outra propriedade, por exemplo:

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

Quando você define um valor de propriedade em uma entidade, por exemplo, ent.abc = 42, seu método _validate() é chamado e, se não gerar uma exceção, o valor é armazenado na entidade. Quando você escreve a entidade no Datastore, seu método _to_base_type() é chamado, convertendo o valor na string. Então esse valor é serializado pela classe base, StringProperty. A cadeia inversa de eventos ocorre quando a entidade é lida de volta do Datastore. As classes StringProperty e Property cuidam dos outros detalhes, como serializar e desserializar a string, definir o padrão e gerenciar os valores de propriedade repetidos.

Neste exemplo, compatibilizar desigualdades, ou seja, consultas usando <, <=, >, >=, requer mais trabalho. A implementação do exemplo a seguir impõe um tamanho máximo de inteiro e armazena valores como strings de comprimento fixo:

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

Isso pode ser usado da mesma forma que LongIntegerProperty, exceto que você precisa passar o número de bits para o construtor da propriedade, por exemplo, BoundedLongIntegerProperty(1024).

Você pode criar subclasses de outros tipos de propriedade de maneira semelhante.

Essa abordagem também funciona para armazenar dados estruturados. Suponha que você tenha uma classe Python FuzzyDate que represente um período. Ela usa os campos first e last para armazenar o início e o fim do período:

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

É possível criar um FuzzyDateProperty que seja derivado de StructuredProperty. Infelizmente, o último não funciona com classes Python antigas simples; ele precisa de uma subclasse Model. Portanto, defina uma subclasse Model como uma representação intermediária.

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

Em seguida, construa uma subclasse de StructuredProperty que codifica o argumento modelclass como FuzzyDateModel e define os métodos _to_base_type() e _from_base_type() para converter entre FuzzyDate e 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)

Um aplicativo pode usar essa classe da seguinte maneira:

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

Suponha que você queira aceitar objetos date simples, além de objetos FuzzyDate como valores para FuzzyDateProperty. Para fazer isso, modifique o método _validate() da seguinte maneira:

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

Em vez disso, você pode criar uma subclasse FuzzyDateProperty da seguinte maneira (supondo que FuzzyDateProperty._validate() seja como mostrado acima).

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

Quando você atribui um valor a um campo MaybeFuzzyDateProperty, MaybeFuzzyDateProperty._validate() e FuzzyDateProperty._validate() são invocados, nessa ordem. O mesmo se aplica a _to_base_type() e _from_base_type(): os métodos na superclass e na subclasse são combinados implicitamente. Não use super para controlar o comportamento herdado para isso. Para esses três métodos, a interação é sutil e super não faz o que você quer.