djangoのモデルを保存する際に特定のカラムを除外できるようにする方法

djangoのモデルはsaveメソッドを使ってDBに保存できます。その際に特定のカラムのみを更新したい場合、saveメソッドのupdate_fieldsで指定できます。

ただ少なくともdjangoのバージョン1.9.7では 特定のカラム以外を更新する という方法は見当たらなかったので、mixinを作成してみました。作成したmixinを使用するとsave(exclude_fields=['name1', 'name2'])の様に更新対象外とするカラムを指定することができるようになります。

update_fieldsを使う

まずはupdate_fieldsで更新するカラムを指定してみます。それを確認するためのモデルをmyapp1アプリに、Sampleというクラス名で用意します。

# coding: utf-8
from django.db import models


class Sample(models.Model):
    name1 = models.CharField(max_length=100)
    name2 = models.CharField(max_length=100)
    name3 = models.CharField(max_length=100)

djangoのshellを使ってupdate_fieldsを試してみます。

まずはレコードを作成します。

$ ./manage.py shell
>>> from myapp1.models import Sample

>>> s = Sample(name1='Name1', name2='Name2', name3='Name3')
>>> s.save()
INSERT INTO "myapp1_sample" ("name1", "name2", "name3")
VALUES ('Name1', 'Name2', 'Name3')

これでINSERT文が実行されました。次はupdate_fieldsを使わずにレコードを更新してみます。

>>> s.name1 = 'NAME1'
>>> s.save()
UPDATE "myapp1_sample"
SET "name1" = 'NAME1', "name2" = 'Name2', "name3" = 'Name3'
WHERE "myapp1_sample"."id" = 1

今度はupdate_fieldsを指定します。

>>> s.name2 = 'NAME2'
>>> s.name3 = 'NAME3'
>>> s.save(update_fields=['name2', 'name3'])
UPDATE "myapp1_sample"
SET "name2" = 'NAME2', "name3" = 'NAME3'
WHERE "myapp1_sample"."id" = 1

このようにupdate_fieldsを指定すると、UPDATE文で更新するカラムを指定できます。

exclude_fieldsを使えるようにする

今度はexclude_fieldsを実装してみます。これを実装するためにはモデルに含まれるフィールド名を取得する方法があるので、Model _meta APIを使ってみます。また、使用するモデルはmyapp2アプリに以下のように用意しておきます。

# coding: utf-8
from django.db import models
from common.models import BaseModelMixin


class Sample(BaseModelMixin, models.Model):
    name1 = models.CharField(max_length=100)
    name2 = models.CharField(max_length=100)
    name3 = models.CharField(max_length=100)
    relation3 = models.ForeignKey('Relation3')
    relation4 = models.ManyToManyField('Relation4')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


class Relation1(models.Model):
    name = models.CharField(max_length=100)
    sample = models.ForeignKey('Sample')

class Relation2(models.Model):
    name = models.CharField(max_length=100)
    samples = models.ManyToManyField('Sample')

class Relation3(models.Model):
    name = models.CharField(max_length=100)

class Relation4(models.Model):
    name = models.CharField(max_length=100)

※Sampleが継承しているBaseModelMixinは空のファイルを用意しておきます。

Model _meta APIを使ってフィールド名の一覧を取得する

Model _meta APIを使うと、Model._meta.get_fields()のようにしてフィールドのオブジェクトを全て取得することができます。

>>> from myapp2.models import Sample

>>> Sample._meta.get_fields()
(<ManyToOneRel: myapp2.relation1>,
 <ManyToManyRel: myapp2.relation2>,
 <django.db.models.fields.AutoField: id>,
 <django.db.models.fields.CharField: name1>,
 <django.db.models.fields.CharField: name2>,
 <django.db.models.fields.CharField: name3>,
 <django.db.models.fields.related.ForeignKey: relation3>,
 <django.db.models.fields.DateTimeField: created_at>,
 <django.db.models.fields.DateTimeField: updated_at>,
 <django.db.models.fields.related.ManyToManyField: relation4>)

カラム名はフィールドのオブジェクトのname属性から取得できます。先ほどのget_fieldsと組み合わせると、以下のようにカラム名の一覧を表示できます。

>>> [f.name for f in Sample._meta.get_fields()]
['relation1', 'relation2', 'id', 'name1', 'name2', 'name3',
 'relation3', 'created_at', 'updated_at', 'relation4']

ただrelation1, relation2, relation4といった余計なカラムも含まれています。それを除外するために、concreteを合わせて取得してみます。concreteはこのページによるとDBにカラムがあるかを表しているみたいです。

>>> [(f.name, f.concrete) for f in Sample._meta.get_fields()]
[('relation1', False), ('relation2', False), ('id', True),
 ('name1', True), ('name2', True), ('name3', True), ('relation3', True),
 ('created_at', True), ('updated_at', True), ('relation4', True)]

relation1, relation2のconcreteがFalseになっているので、この2つは除外できそうです。ただ除外したいrelation4はTrueになっているので、これを判別するためにmany_to_manyという属性を見てみます。

>>> [(f.name, f.many_to_many) for f in Sample._meta.get_fields()]
[('relation1', False), ('relation2', True), ('id', None), ('name1', None),
 ('name2', None), ('name3', None), ('relation3', False), ('created_at', None),
 ('updated_at', None), ('relation4', True)]

relation4のmany_to_manyがTrueになっているので、この属性で除外可能だとわかりました。他にもprimary_keyを更新対象から外したいので、primary_key属性を見てみます。

>>> [(f.name, getattr(f, 'primary_key', False)) for f in Sample._meta.get_fields()]
[('relation1', False), ('relation2', False), ('id', True), ('name1', False),
 ('name2', False), ('name3', False), ('relation3', False), ('created_at', False),
 ('updated_at', False), ('relation4', False)]

この属性はそのまま参照するとAttributeError: 'ManyToOneRel' object has no attribute 'primary_key'のようなエラーが発生してしまったので、getattrを使っています。

これらをふまえて、以下のように更新対象とするフィールド名を取得することができます。

>>> [f.name for f in Sample._meta.get_fields()
...  if f.concrete and
...  not f.many_to_many and
...  not getattr(f, 'primary_key', False)]
['name1', 'name2', 'name3', 'relation3', 'created_at', 'updated_at']

Mixinを作成する

カラム一覧を取得できるようになったのでMixinの作成に移行します。大まかな流れは以下の通りです。

  1. saveメソッドを定義してexclude_fieldsを受け取れるようにする。
  2. Model _meta APIでカラム名の一覧を取得する。
  3. 取得したカラム一覧からexclude_fieldsで指定されたカラムを除外する。
  4. 親のsaveメソッドをupdate_fieldsを指定して呼ぶ。

https://gist.github.com/yusukemurayama/59ac27386a14a8a19001b8a9d977879b

exclude_fieldsの動作を確認する

Mixinを実装したので動作を確認してみます。まずは必要なレコードを作成します。

>>> from myapp2.models import Sample, Relation3
>>> r3 = Relation3(name='r31')
>>> r3.save()
INSERT INTO "myapp2_relation3" ("name") VALUES ('r31')

>>> sample = Sample(name1='Name1', name2='Name2', name3='Name3', relation3=r3)
>>> sample.save()
INSERT INTO "myapp2_sample" 
("name1", "name2", "name3", "relation3_id", 
 "created_at", "updated_at") VALUES 
('Name1', 'Name2', 'Name3', 1, 
'2016-07-05 06:16:37.603779', '2016-07-05 06:16:37.605093')

exclude_fieldsを指定して、name3以外を更新してみます。

>>> sample.name1 = 'New Name1'
>>> sample.name2 = 'New Name2'
>>> sample.name3 = 'New Name3'
>>> sample.save(exclude_fields=['name3'])
UPDATE "myapp2_sample" 
SET
  "name1" = 'New Name1', 
  "name2" = 'New Name2', 
  "relation3_id" = 1,
  "created_at" = '2016-07-05 06:16:37.603779',
  "updated_at" = '2016-07-05 06:18:33.373840'
WHERE "myapp2_sample"."id" = 1

update_fieldsも有効なままです。

>>> sample.save(update_fields=['name3'])
UPDATE "myapp2_sample" 
SET "name3" = 'New Name3'
WHERE "myapp2_sample"."id" = 1

引数を指定しなければ、今までどおり全てのカラムが更新対象になります。

>>> sample.save()
UPDATE "myapp2_sample" 
SET 
  "name1" = 'New Name1', 
  "name2" = 'New Name2', 
  "name3" = 'New Name3', 
  "relation3_id" = 1, 
  "created_at" = '2016-07-05 06:16:37.603779', 
  "updated_at" = '2016-07-05 06:20:29.082860' 
WHERE "myapp2_sample"."id" = 1

この記事が役に立った場合、シェアしていただけると励みになります!!