wiki:DjangoUniqueTogetherWithInheritance

(2009/09/08 ymasuda)

マルチテーブル継承と unique_together を一緒に使うとちょっと困る

Django のマルチテーブル継承は、親のモデルをデータベース上で独立のテーブルを持ったモデルとして表現しておき、子のモデルでは拡張したフィールドと親モデルへの一対一リレーションを張ることで、モデルを拡張できる仕組みです。他の人が作ったモデルを拡張したり、運用中のモデルに手を加えずに拡張したい場合に使えます。

このマルチテーブル継承について、社内からこんな質問を受けました。

Djangoのマルチテーブル継承で、
継承した側のクラスにunique_togetherを指定する。

unique_together条件を破るようなデータを入れてsave()すると、
継承された側のクラスのオブジェクトだけが保存される。

簡単な対処はトランザクションにつっこむことだけど、
いちいちトランザクションにしてるととても遅い。

うまい対処は?

で、ちょっと試してみました。まずはこんな感じで、親モデルと子モデルを定義します。

from django.db import models

class MtParent(models.Model):
    main = models.CharField(max_length=100)
    
    def __unicode__(self):
        return self.main

class MtChild(MtParent):
    sub = models.CharField(max_length=100)
    extra = models.CharField(max_length=100)

    class Meta:
        unique_together = ('sub', 'extra',)

    def __unicode__(self):
        return '%s, %s' %(self.sub, self.extra)

子モデルで unique_together に反するオブジェクトを保存すると ... oops! 例外がでるにもかかわらず、親モデルのインスタンスが保存されてしまいます。

    >>> from models import *
    >>> MtParent.objects.all().delete()
    >>> MtChild.objects.all().delete()
    >>> MtParent.objects.all(), MtChild.objects.all() # clear existing objects
    ([], [])
    >>> ch1 = MtChild(main='spam', sub='egg', extra='bacon') # create one
    >>> ch1.save()
    >>> MtParent.objects.all(), MtChild.objects.all() # should show single parent/child.
    ([<MtParent: spam>], [<MtChild: egg, bacon>])
    >>> ch2 = MtChild(main='ham', sub='egg', extra='bacon') # create another which is not unique-together
    >>> ch2.save() # should raise IntegrityError
    Traceback (most recent call last):
    ...
    IntegrityError: columns sub, extra are not unique
    >>> MtParent.objects.all(), MtChild.objects.all()
    ([<MtParent: spam>, <MtParent: ham>], [<MtChild: egg, bacon>])

なんだこりゃ。 マルチテーブル継承では、子モデルのインスタンスは親モデルのインスタンスへのリレーションを張っているので、 子モデルのインスタンスを save() するときには、先に親モデルのインスタンスを save() して、親モデルインスタンスの id を決める 処理が実行されます。その後で子モデル自身の保存が実行されるので、 IntegrityError? が送出されても後の祭りなんですね。

トランザクションを使わないで、save() のオーバライドで逃げてみました。

方法その1

先に unique_together かどうか手動で確かめる。

class MtChildRevised(MtParent):
    sub = models.CharField(max_length=100)
    extra = models.CharField(max_length=100)

    class Meta:
        unique_together = ('sub', 'extra',)

    def __unicode__(self):
        return '%s, %s' %(self.sub, self.extra)

    def save(self, **kwargs):
        if self.__class__.objects.filter(sub=self.sub, extra=self.extra):
            raise IntegrityError(u'columns %s are not unique'
                                 %(', '.join(*self.__class__._meta.unique_together)))
        super(MtChildRevised, self).save(**kwargs)

一応、動きます。(注意: この方法では if 節と super(...).save の間に他のプロセスがunique_togetherの条件を変えてしまう余地があるので、 unique_together の一貫性を保てません!''')

    >>> MtParent.objects.all().delete()
    >>> MtChildRevised.objects.all().delete()
    >>> MtParent.objects.all(), MtChildRevised.objects.all()
    ([], [])
    >>> ch1 = MtChildRevised(main='spam', sub='egg', extra='bacon') # create one.
    >>> ch1.save() # should be saved successufully.
    >>> MtParent.objects.all(), MtChildRevised.objects.all()
    ([<MtParent: spam>], [<MtChildRevised: egg, bacon>])
    >>> ch2 = MtChildRevised(main='ham', sub='egg', extra='bacon') # create non-unique another.
    >>> ch2.save() # should raise IntegrityError.
    Traceback (most recent call last):
    ...
    IntegrityError: columns sub, extra are not unique
    >>> MtParent.objects.all(), MtChildRevised.objects.all() # parent should not be added.
    ([<MtParent: spam>], [<MtChildRevised: egg, bacon>])

方法その2

IntegrityError? が送出されたら、すでに一度保存されている親モデルインスタンスを削除する。

    def save(self, **kwargs):
        is_new = (self.id is None)
        try:
            super(MtChildRevised, self).save(**kwargs)
        except IntegrityError:
            if is_new:
                self.mtparent_ptr.delete()
            raise

この方法だと、上と同じように動作して、かつ複数プロセスの環境でも子モデルの unique_togeter を保てます。 ただし、データベースに一瞬だけ子モデルを持たない親モデルのレコードができてしまうので、親モデルを検索するときには注意が必要です。

おわりに

結局のところ、Djangoのマルチテーブル継承のモデルインスタンスを一貫性を保たせつつ変更したいのなら、一番素直なのはトランザクションを使う方法です。ここに挙げた方法は、あくまでも「何かと引きかえにトランザクションを使わない」方法でしかないので注意してください。