[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

Djangoでモデルにauto_nowを指定しても更新されない事がある

Djangoのモデルの時刻系のField(DateTimeFieldなど)にはauto_nowという属性があります。これをauto_now=Trueとするとモデルを更新するたびにその時点での時刻を自動で設定してくれるので、更新時刻などを保持しておきたい場合に便利です。

class AutoAddTest(models.Model):

    data = models.CharField(max_length=255)
    # モデルの更新ごとに変更される
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return '{} at {}'.format(self.data, self.updated_at)

しかし、更新方法によってはauto_now=Trueが動作しないケースがあり、ハマったのでそれを備忘録として残しておきます。

そもそもの動作確認

先程のAutoAddTestで一応auto_now=Trueの動作を確認しておきます。

In [1]: from myapp.models import AutoAddTest

In [4]: for a in AutoAddTest.objects.all(): print(a)

In [5]: AutoAddTest.objects.create(data='test1')
Out[5]: <AutoAddTest: test1 at 2018-06-30 14:46:39.974447+00:00>

In [6]: for a in AutoAddTest.objects.all(): print(a)
test1 at 2018-06-30 14:46:39.974447+00:00

モデルをcreateした時点でその時刻が期待どおり設定されています。(2018-06-30 14:46:39.974447+00:00)

では、更新してみます。

In [7]: test1 = AutoAddTest.objects.get(data='test1')

In [8]: test1
Out[8]: <AutoAddTest: test1 at 2018-06-30 14:46:39.974447+00:00>

In [9]: test1.data = 'test1 updated'

In [10]: test1.save()

In [12]: test1
Out[12]: <AutoAddTest: test1 updated at 2018-06-30 14:48:15.676862+00:00>

14:46:39であったupdated_at14:48:15に期待通り変更されていることがわかります。一応updated_atを明示した場合の動作も見ておきます。

In [30]: import datetime

In [31]: test1 = AutoAddTest.objects.get(data='test1 updated')

In [32]: test1.updated_at = datetime.datetime(1970, 1, 1)

In [33]: test1.save()

In [34]: test1
Out[34]: <AutoAddTest: test1 updated at 2018-06-30 14:52:11.182157+00:00>

任意の日付を明示したとしてもそれは無視され、あくまで更新した時刻が設定されます。

auto_addのソース部分

基本的な動作はわかりましたが、もう少し深く見ておきます。auto_nowDateTimeFieldのコンストラクタの引数ですので、そのあたりのソースを確認します。厳密にはDateFieldで定義されておりそちらを継承しています。

# django.db.models.fields.DateField
class DateField(DateTimeCheckMixin, Field):
    :
    def __init__(self, verbose_name=None, name=None, auto_now=False,
                 auto_now_add=False, **kwargs):
        self.auto_now, self.auto_now_add = auto_now, auto_now_add
        if auto_now or auto_now_add:
            kwargs['editable'] = False
            kwargs['blank'] = True
        super(DateField, self).__init__(verbose_name, name, **kwargs)

実際に現在時刻が設定される部分はDateTimeFieldにあります。

# django.db.models.fields.DateTimeField
class DateTimeField(DateField):
    :
    :
    # __init__ is inherited from DateField
    :
    def pre_save(self, model_instance, add):
        if self.auto_now or (self.auto_now_add and add):
            value = timezone.now()
            setattr(model_instance, self.attname, value)
            return value
        else:
            return super(DateTimeField, self).pre_save(model_instance, add)

まぁわかりやすいですね。save実行時の手前で呼び出されるpre_save内でauto_nowが設定されていれば、自身の属性名(self.attname)に対してtimezone.now()を設定しています。なお、orで繋がれているauto_now_addauto_nowと似ていますが、create時のみ時刻を保存するものです。

つまり、auto_addsaveが呼び出される場合にのみ計算され代入されるのです。

auto_addが動かないケース

ここまでの流れを踏まえると、自明ではあるのですがauto_addが働かないのは以下のようにQuerySet経由で更新を行うケースです。

AutoAddTest.objects.filter(data='test1').update(data='test1')

この場合、updateで指定した情報以外は変更されずauto_addを指定した列はpre_saveが呼び出されないため更新が発生しないのです。一応結果を見ておきます。

In [40]: AutoAddTest.objects.filter(data='test1')[0].updated_at
Out[40]: datetime.datetime(2018, 6, 30, 14, 52, 11, 182157, tzinfo=<UTC>)

In [41]: AutoAddTest.objects.filter(data='test1').update(data='test1')
Out[41]: 1

In [42]: AutoAddTest.objects.filter(data='test1')[0].updated_at
Out[42]: datetime.datetime(2018, 6, 30, 14, 52, 11, 182157, tzinfo=<UTC>)  # 変更前と変わっていない

これはQuerySetupdatesaveを経由せず、直接SQLを発行しているためです。

# django.db.models.query.QuerySet#update
    def update(self, **kwargs):
        """
        Updates all elements in the current QuerySet, setting all the given
        fields to the appropriate values.
        """
        assert self.query.can_filter(), \
            "Cannot update a query once a slice has been taken."
        self._for_write = True
        query = self.query.clone(sql.UpdateQuery)
        query.add_update_values(kwargs)
        # Clear any annotations so that they won't be present in subqueries.
        query._annotations = None
        with transaction.atomic(using=self.db, savepoint=False):
            rows = query.get_compiler(self.db).execute_sql(CURSOR)
        self._result_cache = None
        return rows

いろいろやっていますがrows = query.get_compiler(self.db).execute_sql(CURSOR)で直接組み立てたUPDATE文を発行しているため、auto_addの値の計算などは考慮されないのです。ここはできれば対応してほしいような気もするのですが、、関連するっぽいBUGがありました。

Document auto_now behavior with QuerySet.update()

しかし、このBugはこれまで述べてきた動作がドキュメントになかったことを問題として扱い、ドキュメントに注意書きを追加することでCloseしていますので動作が変更されることは、恐らくなさそうです(少なくともしばらくは?)

一応今回のケースであれば以下のように、updated_atをちゃんと明示することで対応できます。

In [43]: from django.utils import timezone

In [44]: AutoAddTest.objects.filter(data='test1').update(data='test1', updated_at=timezone.now())
Out[44]: 1