Django inheritance and polymorphism with proxy models

3.9k Views Asked by At

I'm working on a Django project that I did not start and I am facing a problem of inheritance.
I have a big model (simplified in the example) called MyModel that is supposed to represents different kind of items.

All the instance objects of MyModel should have the same fields but the methods behaviours varies a lot depending on the item type.

Up to this moment this has been designed using a single MyModel field called item_type.
Then methods defined in MyModel check for this field and perform different logic using multiple if:

def example_method(self):
    if self.item_type == TYPE_A:
        do_this()
    if self.item_type == TYPE_B1:
        do_that()

Even more, some of the sub-types have many things in common, so let's say the subtypes B and C represents a 1st level of inheritance. Then these types have sub-types being for example B1, B2, C1, C2 (better explained in the example code below).

I would say that's not the best approach to perform polymorphism.

Now I want to change these models to use real inheritance.

Since all submodels have the same fields I think multi-table inheritance is not necessary. I was thinking to use proxy models because only their behaviour should change depending on their types.

This a pseudo-solution I came up to:

ITEM_TYPE_CHOICES = (
    (TYPE_A, _('Type A')),
    (TYPE_B1, _('Type B1')),
    (TYPE_B2, _('Type B2')),
    (TYPE_C1, _('Type C1')),
    (TYPE_C2, _('Type C2')))


class MyModel(models.Model):
    item_type = models.CharField(max_length=12, choices=ITEM_TYPE_CHOICES)

    def common_thing(self):
        pass

    def do_something(self):
        pass


class ModelA(MyModel):
    class Meta:
        proxy = True

    def __init__(self, *args, **kwargs):
        super().__init__(self, *args, **kwargs)
        self.item_type = TYPE_A

    def do_something(self):
        return 'Hola'


class ModelB(MyModel):
    class Meta:
        proxy = True

    def common_thing(self):
        pass

class ModelB1(ModelB):
    class Meta:
        proxy = True

    def __init__(self, *args, **kwargs):
        super().__init__(self, *args, **kwargs)
        self.item_type = TYPE_B1

    def do_something(self):
        pass


class ModelB2(ModelB):
    class Meta:
        proxy = True

    def __init__(self, *args, **kwargs):
        super().__init__(self, *args, **kwargs)
        self.item_type = TYPE_B2

    def do_something(self):
        pass

This might work if we already know the type of the object we are working on.
Let's say we want to instantiate a MyModel object of type C1 then we could simply instantiate a ModelC1 and the item_type would be set up correctly.

The problem is how to get the correct proxy model from the generic MyModel instances?

The most common case is when we get a queryset result: MyModel.objects.all(), all these objects are instances of MyModel and they don't know anything about the proxies.

I've seen around different solution like django-polymorphic but as I've understood that relies on multi-table inheritance, isn't it?

Several SO answers and custom solutions I've seen:

but none of them convinced me 100%..

Considering this might be a common scenario did anyone came up with a better solution?

3

There are 3 best solutions below

2
On BEST ANSWER

I came up with a custom solution inspired by this SO answer and this blog post:

from django.db import models
from django.dispatch.dispatcher import receiver

ITEM_TYPE_CHOICES = (
  (TYPE_A, _('type_a')),
  (TYPE_B1, _('type_b1')),
  (TYPE_B2, _('type_b2')),
  (TYPE_C1, _('type_c1')),
  (TYPE_C2, _('type_c2')),
)

class MyModel(models.Model):
    item_type = models.CharField(max_length=12, choices=ITEM_TYPE_CHOICES)        
    description = models.TextField(blank=True, null=True)

    def common_thing(self):
        pass

    def do_something(self):
        pass

    # ****************
    # Hacking Django *
    # ****************
    PROXY_CLASS_MAP = {}  # We don't know this yet

    @classmethod
    def register_proxy_class(cls, item_type):
        """Class decorator for registering subclasses."""
        def decorate(subclass):
            cls.PROXY_CLASS_MAP[item_type] = subclass
            return subclass
        return decorate

    def get_proxy_class(self):
        return self.PROXY_CLASS_MAP.get(self.item_type, MyModel)


# REGISTER SUBCLASSES

@MyModel.register_proxy_class(TYPE_A)
class ModelA(MyModel):
    class Meta:
        proxy = True

    def __init__(self, *args, **kwargs):
        super().__init__(self, *args, **kwargs)
        self.item_type = TYPE_A

    def do_something(self):
        pass

# No need to register this, it's never instantiated directly 
class ModelB(MyModel):
    class Meta:
        proxy = True

    def common_thing(self):
        pass

@MyModel.register_proxy_class(TYPE_B1)
class ModelB1(ModelB):
    class Meta:
        proxy = True

    def __init__(self, *args, **kwargs):
        super().__init__(self, *args, **kwargs)
        self.item_type = TYPE_B1

    def do_something(self):
        pass

@MyModel.register_proxy_class(TYPE_B2)
class ModelB2(ModelB):
    class Meta:
        proxy = True

    def __init__(self, *args, **kwargs):
        super().__init__(self, *args, **kwargs)
        self.item_type = TYPE_B2

    def do_something(self):
        pass


# USING SIGNAL TO CHANGE `__class__` at runtime

@receiver(models.signals.post_init, sender=MyModel)
def update_proxy_object(sender, **kwargs):
    instance = kwargs['instance']
    if hasattr(instance, "get_proxy_class") and not instance._meta.proxy:
        proxy_class = instance.get_proxy_class()
        if proxy_class is not None:
            instance.__class__ = proxy_class

I'm using the decorator register_proxy_class to register each subclass after MyModel has been declared otherwise I would have needed to explicitly declare a map of {type: subclass} inside MyModel. This would have been bad:

  1. because at declaration we can't reference any of the proxy subclasses from MyModel (we could solve these with string names)
  2. the parent would be aware of its subclasses which breaks OOP principles.

How it works:

Using the @register_proxy_class(type) decorator each subclass register itself, in fact creating an entry into MyModel.PROXY_CLASS_MAP dict when the module is loaded.

Then update_proxy_object is executed whenever MyModel dispatch a post_init signal. It change the __class__ of MyModel instances at runtime to select the right proxy subclass.

So basically:

# a1: MyModel dispatch a post_init signal -> `update_proxy_object` set the proper instance __class__ = ModelA
# Do NOT call ModelA.__init__
a1 = MyModel(item_type=TYPE_A)  
isinstance(a1, MyModel) # True
isinstance(a1, ModelA)  # True

# a2: calls ModelA.__init__ that call the parent MyModel.__init__ then it sets up the item_type for us
a2 = ModelA() # <- no need to pass item_type
isinstance(a2,MyModel) # True
isinstance(a2, ModelA)  #True

# Using custom managers of MyModel return all objects having item_type == 'TYPE_B1'
b1 = MyModel.objects.b1()[0]  # get the first one
isinstance(b1, ModelB1)  # True
isinstance(b1, ModelB)   # True
isinstance(b1, MyModel)  # True
isinstance(b1, ModelA)   # False

It seems to work so far but I will experiment a bit more for possible problems I haven't think about.

Cool!

0
On

I have few experience with model proxies so I can't tell if this would properly work (without bearking anything I mean) nor how complicated this might be, but you could use an item_type:ProxyClass mapping and override your model's queryset (or provide a second manager with custom queryset etc) that actually lookup this mapping and instanciates the correct proxy model.

BTW you may want at django.models.base.Model.from_db, which (from a very quick glance at the source code) seems to be the method called by QuerySet.populate() to instanciate models. Just overriding this method might possibly be enough to solve the problem - but here again it might also breaks something...

0
On

When you use django-polymorphic in your base model, you'll get this casting behavior for free:

class MyModel(PolymorphicModel):
    pass

Each model that extends from it (proxy model or concrete model), will be casted back to that model when you do a MyModel.objects.all()