django-polymorphicを使ってMTIを実装してみた

published
2025-02-10

はじめに

MTIで定義したDjangoモデルをより柔軟に扱う方法はないかなぁと調べていたところdjango-polymorphicというよさげなライブラリを発見しました。
今回はそんなよさげなライブラリdjango-polymorphicを使ってみたの会になります。

また、今回は具体例として以下のようなPlanをスーパータイプとして、サブタイプにHobbyPlanProPlanが存在するサブタイプを例にまとめていきます。

環境

  • Python 3,11.4
  • Django 5.1.6
  • django-polymorphic 3.1.0

悩んでいた事

概念データモデルのサブタイプを、テーブルに定義する際にいくつかの方法があるかと思います。
(サブタイプにも排他的サブタイプであったりといくつか種類があるのですが、本質ではないので割愛します。)

  • 単一テーブル継承(STI)
  • 具象テーブル継承(CCI)
  • クラステーブル継承(CTI)

各テーブル継承のメリデメ等はこちらがわかりやすかったです。

上記をDjangoのModelで定義する。となった場合に一番に思いつくのは以下2つになるかなと思います。

また、サブタイプの各属性の定義をスーパータイプに譲渡して、Proxyを用いることもあり得るかと思います。

しかし、上記どの方法を選択したとしても親クラスから子クラスのインスタンスとしてクエリ結果を取得することが出来ず(Abstractの場合はそもそもクエリ自体投げれませんが...)、結果として判定処理が増えてしまうことが多々ありました。

以下MTIで、Planの例を実装します。

from django.db import models

# Create your models here.

class Plan(models.Model):
    name = models.CharField(max_length=100)
    monthly_base_fee = models.IntegerField(default=0)

    def price(self):
        raise NotImplementedError

class HobbyPlan(Plan):
    def price(self):
        return self.monthly_base_fee

class ProPlan(Plan):
    pro_rate = models.IntegerField(default=100)
    def price(self):
        return self.monthly_base_fee * self.pro_rate

適当にデータを作成後、Planに対してallクエリを投げるてみます。

In [1]: from demo.models import *

In [2]: HobbyPlan.objects.create(name='hobby')
Out[2]: <HobbyPlan: HobbyPlan object (1)>

In [3]: ProPlan.objects.create(name='pro', monthly_base_fee=5000)
Out[3]: <ProPlan: ProPlan object (2)>

In [4]: for p in Plan.objects.all():
   ...:     print(type(p))
   ...: 
<class 'demo.models.Plan'>
<class 'demo.models.Plan'>

allクエリセットでは子クラスではなくPlanのインスタンスとして返却されます。
また、インスタンスがPlanな為、もちろん各子クラスでOverrideしたメソッドは呼ばれません。

In [5]: for p in Plan.objects.all():
   ...:     p.price()
   ...: 
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)

このため、実際に使用する場合には子クラスに条件分岐で絞り込んだうえでメソッドを実行する。若しくは子クラスとしてインスタンス取得を複数回繰り返す必要がありました。

django-polymorphicライブラリ

上記のような場合にdjango-polymorphicのライブラリを用いることで、Planからクエリセットを呼んだ場合でも子クラスのインスタンスとして取得することができ、実装をシンプルにすることができます。

使い方

以下を参考にセットアップします。(と言ってもsettingsに値を追加するだけですが)

INSTALLED_APPS += (
    'polymorphic',
    'django.contrib.contenttypes',
)

先ほどのPlanの継承元をPolymorphicModelに変更します。

from django.db import models
from polymorphic.models import PolymorphicModel

class Plan(PolymorphicModel):
    name = models.CharField(max_length=100)
    monthly_base_fee = models.IntegerField(default=0)

    def price(self):
        raise NotImplementedError

class HobbyPlan(Plan):
    def price(self):
        return self.monthly_base_fee

class ProPlan(Plan):
    pro_rate = models.IntegerField(default=100)
    def price(self):
        return self.monthly_base_fee * self.pro_rate

なんとこれだけ。

確認

DBのmigrationを再作成+再実行し、先ほどのデフォルトで実装したMTIモデルと同様の操作を行ってみます。

In [1]: from demo.models import *

In [2]: HobbyPlan.objects.create(name='hobby')
Out[2]: <HobbyPlan: HobbyPlan object (1)>

In [3]: ProPlan.objects.create(name='pro', monthly_base_fee=5000)
Out[3]: <ProPlan: ProPlan object (2)>

In [4]: for p in Plan.objects.all():
   ...:     print(type(p))
   ...: 
<class 'demo.models.HobbyPlan'>
<class 'demo.models.ProPlan'>

先ほどまでは<class 'demo.models.Plan'>と親クラスになっていたのですが、今回は子クラスのインスタンスとして返却されました!これは良さそう。

勿論子クラスのインスタンスな為、メソッドもOverrideした子クラスのメソッドが呼ばれます。

In [11]: for p in Plan.objects.all():
    ...:     print(f'{p.__class__.__name__}: {p.price()}')
    ...: 
HobbyPlan: 0
ProPlan: 500000

ライブラリ覗き見

実際にどのようにして親クラスのクエリから子クラスのインスタンスとして返却するのか少し覗いてみます。

公式Docをちらっと見たところ、non-polymorphicなオブジェクトをreal class/typeに変換するget_real_instanceメソッドがありました。(要は親から子クラスにキャストできるってことですね)

使用するとこんな感じです。

In [20]: data = Plan.objects.all().non_polymorphic()[0]

In [21]: data
Out[21]: <Plan: Plan object (1)>

In [22]: data.get_real_instance()
Out[22]: <HobbyPlan: HobbyPlan object (1)>

また、上記のNoteにManagerのget_real_instancesについて記述がありました。
両者の違いは主に、get_real_instanceはオブジェクトに対してのキャストに対し、get_real_instancesの場合はManagerに定義されており、return値がQuerySetな為、QuerySetに対してのキャストとなります。

また、get_real_instancesPolymorphicModelIterableの__iter__特殊メソッドから呼ばれているようでした。

そのため、forループなどのイテレータ操作を行うと、各オブジェクトが自動的に子クラスとしてインスタンス化されるのだと解釈しました。

また、もし親クラスとして取得したい場合はnon_polymorphicを用いることで実現可能です。
(上記で既に使用していますが...)

In [9]: for p in Plan.objects.all().non_polymorphic():
   ...:     print(type(p))
   ...: 
<class 'demo.models.Plan'>
<class 'demo.models.Plan'>

まとめ

django-polymorphicを使うことで、より柔軟性のあるMTIが定義できるようになるなぁと思いました。
また、今回は触れませんがdjango-typed-modelsというフレームワークもあります。

こちらはテーブルがMTIではなくSTIで実装されます。そのためテーブル設計方法のメリデメ+PJによってはこちらを採用することもありかなと思いました。
(まだメジャーバージョンリリースがないので、少し不安ではありますが...)

今回のコードはこちらのgithubに上げてあります。