Artículos

El mixin de modelos convertibles

Si usaste herencia multitabla en Django, quizá escribiste consultas para el modelo padre y, al procesar instancias, quisiste convertirlas al modelo hijo adecuado. Tal vez escribiste código para hacerlo. Nos pasó lo mismo y compartimos nuestro enfoque.

Esta solución está pensada para Python 3.

El problema

Revisa la documentación de Django sobre herencia multitabla. Ampliaremos sus modelos con otra subclase de Place, llamada CoffeeShop.

class CoffeeShop(Place):
    serves_decaffeinated = models.BooleanField(default=False)

Tenemos dos subclases de Place: Restaurant y CoffeeShop. Podemos necesitar listar todos los lugares en el inicio del sitio sin dos consultas distintas, una por subclase, o tres si hay lugares simples, para mantener un orden específico entre todos. También necesitamos campos o lógica propios de las subclases.

La solución

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

Si Place extiende este mixin, podemos llamar a cast() y obtener una instancia de la subclase adecuada, o el mismo objeto si no tiene subclase.

Revisemos el código:

    def cast(self):
        cls = self.__class__
        subclasses = cls.all_subclasses()

        if len(subclasses) == 0:
            return self

cast guarda la clase actual en una variable y todas las subclases en otra. Si no hay subclases, devuelve la instancia. Veamos cómo obtiene 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 devuelve referencias a subclases inmediatas. Si la jerarquía tiene más de dos niveles, debemos seguir buscando. El método recorre recursivamente cada subclase y añade cada clase hoja al principio de la lista hasta terminar.

Siguiendo con 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

Recorremos las subclases y buscamos un campo del mismo nombre en la instancia; Django añade este OneToOneField automáticamente al extender el modelo. Puede no existir, lo que significa que la subclase no es un modelo, o lanzar ObjectDoesNotExist: es un modelo, pero no hay una fila de la subclase para esa instancia, por ejemplo un Place que no es Restaurant. En ambos casos pasamos a la siguiente iteración.

Si hay coincidencia, poblamos la nueva instancia, referenciada por obj, con los objetos relacionados de la anterior, cargados mediante posibles llamadas a select_related o prefetch_related, y la devolvemos. Si agotamos la lista sin coincidencias, devuelve la instancia original.

El mixin en acción

Crea un proyecto Django con estos modelos y coloca el mixin en mixins.py dentro de la misma aplicación.

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)

Abre la consola de Django y crea instancias:

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

Hay restaurantes, cafeterías y lugares simples. Podemos consultarlos juntos y convertir cada instancia a su subclase:

>>> [place.cast() for place in Place.objects.all()]
[<CoffeeShop: CoffeeShop object>, <Restaurant: Restaurant object>, <Restaurant: Restaurant object>, <Place: Place object>]

Esto ocurre si no los convertimos:

>>> Place.objects.all()
<QuerySet [<Place: Place object>, <Place: Place object>, <Place: Place object>, <Place: Place object>]>

Extra

Cada conversión accede al modelo hijo mediante OneToOneField, y Django consulta la base de datos para recuperar sus campos. Obtener nuestros 4 lugares en lo que parece una consulta cuesta al menos 5, quizá más porque cada acceso fallido también genera una consulta sin resultados. Podemos usar select_related como con cualquier ForeignKey. Para no hacerlo manualmente cada vez, creamos un QuerySet que llama a select_related para todas las subclases posibles.

Probemos la situación actual:

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

No es muy buena. Escribe este mixin de QuerySet en 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])

...y añádelo como gestor a 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()

Veamos qué ocurre ahora:

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

¡Mucho mejor!

“El mixin de modelos convertibles” de Javier Ayres está bajo la licencia CC BY SA. Los ejemplos de código fuente están bajo la licencia MIT.

Foto de Didssph.

Clasificado en Herencia / Django / Modelos / ORM.

Lecturas relacionadas