O mixin de modelos convertíveis

Se usou herança multítabela no Django, talvez tenha escrito consultas para o modelo pai e, ao processar instâncias, desejado convertê-las ao modelo filho adequado. Talvez tenha escrito código para isso. Passamos pelo mesmo e compartilhamos nossa abordagem.
Esta solução foi pensada para Python 3.
O problema
Consulte a documentação do Django sobre herança multítabela. Vamos ampliar seus modelos com outra subclasse de Place, chamada CoffeeShop.
class CoffeeShop(Place):
serves_decaffeinated = models.BooleanField(default=False)
Temos duas subclasses de Place: Restaurant e CoffeeShop. Podemos precisar listar todos os lugares na página inicial sem duas consultas diferentes, uma por subclasse, ou três se houver lugares simples, para manter uma ordem específica entre todos. Também precisamos de campos ou lógica próprios das subclasses.
A solução
class CastableModelMixin:
"""
Add support to cast an object to its final class
"""
def cast(self):
cls = self.__class__
subclasses = cls.all_subclasses()
if len(subclasses) == 0:
return self
for subclass in subclasses:
try:
obj = getattr(self, subclass._meta.model_name, None)
if obj is not None:
# select_related doesn't fill child with parent relateds
descriptors = [getattr(cls, field.name)
for field in cls._meta.get_fields()
if field.is_relation and field.many_to_one]
for descriptor in descriptors:
if descriptor.is_cached(self):
setattr(obj,
descriptor.cache_name,
getattr(self, descriptor.cache_name))
if hasattr(self, '_prefetched_objects_cache'):
obj._prefetched_objects_cache = \
self._prefetched_objects_cache
return obj
except ObjectDoesNotExist:
pass
return self
@classmethod
def all_subclasses_model_names(cls):
model_names = []
for subclass in cls.all_subclasses():
if not (subclass._meta.proxy or subclass._meta.abstract):
model_names.append(subclass._meta.model_name)
return model_names
@classmethod
def all_subclasses(cls):
return [g for s in cls.__subclasses__() for g in s.all_subclasses()] + cls.__subclasses__()
@property
def model(self):
return self.cast()._meta.model_name
@property
def verbose_name(self):
return self.cast()._meta.verbose_name.capitalize()
Se Place estende esse mixin, podemos chamar cast() e obter uma instância da subclasse adequada, ou o mesmo objeto se não tiver subclasse.
Vamos revisar o código:
def cast(self):
cls = self.__class__
subclasses = cls.all_subclasses()
if len(subclasses) == 0:
return self
cast salva a classe atual em uma variável e todas as subclasses em outra. Se não houver subclasses, retorna a instância. Vamos ver como obtém todas:
@classmethod
def all_subclasses(cls):
return [g for s in cls.__subclasses__() for g in s.all_subclasses()] + cls.__subclasses__()
Usa class.__subclasses__(), que retorna referências às subclasses imediatas. Se a hierarquia tem mais de dois níveis, precisamos continuar procurando. O método percorre recursivamente cada subclasse e adiciona cada classe folha ao início da lista até terminar.
Continuando com cast:
for subclass in subclasses:
try:
obj = getattr(self, subclass._meta.model_name, None)
if obj is not None:
# select_related doesn't fill child with parent relateds
descriptors = [getattr(cls, field.name)
for field in cls._meta.get_fields()
if field.is_relation and field.many_to_one]
for descriptor in descriptors:
if descriptor.is_cached(self):
setattr(obj,
descriptor.cache_name,
getattr(self, descriptor.cache_name))
if hasattr(self, '_prefetched_objects_cache'):
obj._prefetched_objects_cache = \
self._prefetched_objects_cache
return obj
except ObjectDoesNotExist:
pass
return self
Percorremos as subclasses e procuramos um campo de mesmo nome na instância; Django adiciona esse OneToOneField automaticamente ao estender o modelo. Ele pode não existir, indicando que a subclasse não é um modelo, ou lançar ObjectDoesNotExist: é um modelo, mas não há uma linha da subclasse para essa instância, como um Place que não é Restaurant. Em ambos os casos, seguimos para a próxima iteração.
Se houver correspondência, preenchemos a nova instância, referenciada por obj, com os objetos relacionados da anterior, carregados por possíveis chamadas a select_related ou prefetch_related, e a retornamos. Se esgotarmos a lista sem correspondências, retorna a instância original.
O mixin em ação
Crie um projeto Django com estes modelos e coloque o mixin em mixins.py na mesma aplicação.
from django.db import models
from .mixins import CastableModelMixin
class Place(models.Model, CastableModelMixin):
name = models.CharField(max_length=50)
address = models.CharField(max_length=80)
class Restaurant(Place):
serves_hot_dogs = models.BooleanField(default=False)
serves_pizza = models.BooleanField(default=False)
class CoffeeShop(Place):
serves_decaffeinated = models.BooleanField(default=False)
Abra o shell do Django e crie instâncias:
(django) jayres:places jayres$ ./manage.py shell
Python 3.6.1 (v3.6.1:69c0db5050, Mar 21 2017, 01:21:04)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from app.models import *
>>> CoffeeShop.objects.create(name='The coffee shop', address='Street 1')
<CoffeeShop: CoffeeShop object>
>>> Restaurant.objects.create(name='The hot dog restaurant', address='Street 1')
<Restaurant: Restaurant object>
>>> Restaurant.objects.create(name='The pizza restaurant', address='Street 2')
<Restaurant: Restaurant object>
>>> Place.objects.create(name='A special place', address='Street 3')
<Place: Place object>
Há restaurantes, cafeterias e lugares simples. Podemos consultá-los juntos e converter cada instância à sua subclasse:
>>> [place.cast() for place in Place.objects.all()]
[<CoffeeShop: CoffeeShop object>, <Restaurant: Restaurant object>, <Restaurant: Restaurant object>, <Place: Place object>]
Isto acontece se não convertermos:
>>> Place.objects.all()
<QuerySet [<Place: Place object>, <Place: Place object>, <Place: Place object>, <Place: Place object>]>
Extra
Cada conversão acessa o modelo filho pelo OneToOneField, e Django consulta o banco para recuperar seus campos. Obter nossos 4 lugares no que parece uma consulta custa ao menos 5, talvez mais, porque cada acesso que falha também gera uma consulta sem resultados. Podemos usar select_related como em qualquer ForeignKey. Para não fazer isso manualmente toda vez, criamos um QuerySet que chama select_related para todas as subclasses possíveis.
Vamos testar a situação atual:
>>> from django.db import connection
>>> from django.db import reset_queries
>>> reset_queries()
>>> [place.cast() for place in Place.objects.all()]
[<CoffeeShop: CoffeeShop object>, <Restaurant: Restaurant object>, <Restaurant: Restaurant object>, <Place: Place object>]
>>> len(connection.queries)
7
Não é muito boa. Escreva este mixin de QuerySet em mixins.py:
class CastableQuerySetMixin:
def select_related_subclasses(self):
return self.select_related(*[subclass._meta.model_name for subclass in self.model.all_subclasses()
if not subclass._meta.proxy])
...e adicione como gerenciador ao Place.
from django.db import models
from .mixins import CastableModelMixin, CastableQuerySetMixin
class CustomQuerySet(CastableQuerySetMixin, models.QuerySet):
pass
class Place(models.Model, CastableModelMixin):
name = models.CharField(max_length=50)
address = models.CharField(max_length=80)
objects = CustomQuerySet.as_manager()
Vamos ver o que acontece agora:
(django) jayres:places jayres$ ./manage.py shell
Python 3.6.1 (v3.6.1:69c0db5050, Mar 21 2017, 01:21:04)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from app.models import *
>>> from django.db import connection
>>> [place.cast() for place in Place.objects.all().select_related_subclasses()]
[<CoffeeShop: CoffeeShop object>, <Restaurant: Restaurant object>, <Restaurant: Restaurant object>, <Place: Place object>]
>>> len(connection.queries)
1
>>>
Muito melhor!