Djangoの m2m_changed で見落としがちな3つの罠(forward/reverse・through直接編集・存在しない pre_set)
はじめに
DjangoにはM:Nの関係を表すため、ManyToManyFieldが存在します。これ自体は割とありふれている関係なので、使われたことがある方も多いのではないのでしょうか。
私も例に漏れず結構お世話になっているのですが、実務で割とピンポイントなやらかしをして、おまけにしばらく気づいていませんでした。今回はそんな失敗の記録です。
なお、初めにまとめを書いておきますと、
.add()をどちら側から呼ぶかで、シグナルに渡ってくる情報の意味が入れ替わる- 中間テーブル(through model)を直接編集する経路では、シグナル自体が発火しない
- 存在しないactionを幻視しても動作が止まらない
という3つの罠があります。いずれも「知っていれば数秒で気づけるが、知らないとテストが通っているのに本番だけ穴が空く」類のものです。本記事ではこれらを、実際にハマった実装例とともに整理します。
ManyToManyFieldとは
基本的な逆参照設定
冒頭で触れた通り、M:N、つまり多対多の関係を表すためのものです。これ自体はいろいろな場所で見られます。
たとえばアニメと視聴者の関係ですね。アニメ側は複数の視聴者を抱えているのが普通ですし、視聴者側も2つ以上アニメを抱えていることは珍しくありません。
このようなとき、Djangoでは以下のようにモデルを表現できます。
from django.db import models
class Animation(models.Model):
title = models.CharField(max_length=255)
description = models.TextField()
created_by = models.CharField(max_length=255)
class User(models.Model):
username = models.CharField(max_length=255)
email = models.EmailField()
animations = models.ManyToManyField(
Animation,
related_name="users",
blank=True
)
なお、ManyToManyField では null=True は意味を持ちません。M2Mは中間テーブルに「行がある/ない」で関連の有無を表すため、FKのNULLのような概念自体が存在しないからです。関連を空にできるかというフォーム上の扱いには blank=True を使います。
この時のポイントはrelated_nameという属性です。アニメ側からusersという形で逆参照可能になります。
これは非常に良い機能だと思います。自分が自然と思う表現が使えるからです。
見方によって呼称が変わるのは珍しくなくて、例えばシステム側から見れば自分のとこのシステムの利用者さん=userですが、アニメ側から見ると視聴者=viewerだよな…という発想です。
class User(models.Model):
username = models.CharField(max_length=255)
email = models.EmailField()
animations = models.ManyToManyField(
Animation,
related_name="viewers",
blank=True
)
こうすることで、アニメ側からはviewersの形で逆参照できるようになります。以下はアニメ側から視聴者を追加する形です。
spring_animation = Animation.objects.create(
title="Spring Animation",
description="A beautiful spring animation",
created_by="Swift"
)
user1 = User.objects.create(
username="user1",
email="user1@example.com"
)
spring_animation.viewers.add(user1)
逆にUser側からアニメを追加することもできます。
user1.animations.add(spring_animation)
以上が基本的なManyToManyの定義と逆参照の方法になります。
m2mとその監視
ここでは、以下のようなモデルを題材にしたいと思います。
class School(models.Model):
name = models.CharField(max_length=100)
class Teacher(models.Model):
name = models.CharField(max_length=100)
school = models.ForeignKey(School, on_delete=models.PROTECT)
class Student(models.Model):
name = models.CharField(max_length=100)
school = models.ForeignKey(School, on_delete=models.PROTECT)
teachers = models.ManyToManyField(
Teacher,
related_name="students"
)
- 生徒(
Student)と講師(Teacher)が多対多で担当関係を持つ - ただし、それぞれ所属する学校(
School)が異なる場合は担当関係を許可しない
ここで、素直に考えるのであれば、以下のように毎回学校が一致しているか?をチェックするというアイデアがあるでしょう。
if student.school_id == teacher.school_id:
student.teachers.add(teacher)
else:
print("Student and teacher belong to different schools.")
これは動きます。私も初めはこうしていました。
しかし、毎回毎回これを書くのは割と面倒ですし、忘れてしまう危険性があります。
また、面倒さに目を瞑るとしても、この手の処理が複数箇所に散らばっていること自体が危険だと思います。
以前Djangoで考える「依存の深さ」と「依存の広さ」にも似たようなことを投稿しましたが、たとえば単に「同じ学校である」だけでなく、講師側が「その学年の指導を行える資格があるか?」をチェックしたい、みたいなビジネス側のロジックの変更があると、一気に漏れる可能性が増えてしまいます。
以上のように、特にテナント境界や権限に関わる部分を毎回直書きするのは結構危険な書き方です。これの対処法は、たとえばrequirements.pyのようなファイルを作って、そこにまとめてしまうのがシンプルな解決法だと思います。
def can_assign_teacher_to_student(teacher: Teacher, student: Student) -> bool:
"""担当講師を生徒に割り当て可能かどうかを判定する
Args:
teacher (Teacher): 担当講師
student (Student): 生徒
Returns:
bool: 割り当て可能であれば True、そうでなければ False
"""
return teacher.school_id == student.school_id
こうしておけば、後々ロジックの変更があっても、この場所さえいじればOKとなります。
ただ、これも完璧な解決策であるとは言えません。というのも、たとえば忘れてしまっては元も子もありません。また、自分以外の開発者が気づいてくれないかもしれません。
signalsの利用
ここで登場するのがsignalsです。Djangoに備え付けられた機能で、モデルの変更やリクエスト開始時など、特定のイベントの発生を感知して、それを特定コードに通知してくれます。
signals自体は、django.core.signalsやdjango.contrib.auth.signalsなど、イベント元に応じて複数用意されていますが、今回はdjango.db.models.signalsを使います。
django.db.models.signalsは後述しますが、指定したモデルに対して、所定の動作を行う際に、決まった動作を行うことができるものです。ManyRelatedManager経由の操作(.add()など)を通ってさえいれば、呼び出し側が意識していなくても発火してくれるので、その意味では頼れる存在です(ただし後述の通り、DBレベルで保証してくれるわけではなく、あくまでORM経由の操作にしか反応しません)。
これが今のモデルで、
class School(models.Model):
name = models.CharField(max_length=100)
class Teacher(models.Model):
name = models.CharField(max_length=100)
school = models.ForeignKey(School, on_delete=models.PROTECT)
class Student(models.Model):
name = models.CharField(max_length=100)
school = models.ForeignKey(School, on_delete=models.PROTECT)
teachers = models.ManyToManyField(
Teacher,
related_name="students")
生徒に担当講師を追加するコードは以下のようになります。
student.teachers.add(teacher)
そしてこれに対して、m2m_changed レシーバーは、素直に書くとこうなります。
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django.core.exceptions import ValidationError
@receiver(m2m_changed, sender=Student.teachers.through)
def validate_student_teachers(sender, instance, action, pk_set, **kwargs):
if action != "pre_add":
return
invalid = Teacher.objects.filter(pk__in=pk_set).exclude(
school_id=instance.school_id
)
if invalid.exists():
raise ValidationError("所属校舎の異なる講師が含まれています。")
コードの仕様は以下のような感じです。
-
@receiverで、「いつ」「どのタイミングで」チェックするかを登録-
m2m_changed=ManyToManyに変化があったとき -
sender=Student.teachers.through=Student.teachersの中間モデルに関するM2M変更だけをreceiverの対象にする
-
-
actionで、「m2mの何の動作」に対して反応するかを設定- 今回は
pre_add= m2mへの追加前だけを見ている
- 今回は
それより下の部分は、不正な講師の割り当てがあるか?をチェックしていますね。追加される予定の講師のIDのうち、生徒と校舎のIDが一致しない、つまり違う校舎にいる講師が存在していれば、バリデーションエラーで弾く、という感じです。
実際、これはきちんと働きます。
other_school = School.objects.create(name="Other School")
other_teacher = Teacher.objects.create(name="Other Teacher", school=other_school)
student.teachers.add(other_teacher) # ここで ValidationError が発生するはず
こうすると、狙い通りに校舎不一致を検出してくれます。一見完成に見えますが、ここに3つの罠が潜んでいます。
罠1: forward/reverse で instance と pk_set の意味が入れ替わる
さっきのアニメの例で確認しましたが、ManyToManyField は宣言した側だけでなく、related_name 経由の逆側からも書き込むことができます。今回の例においても同様です。つまり以下のように、生徒側から講師を追加することもできれば、講師側から生徒を追加することもできます。大体以下のような感じです。
# 順方向(forward): Student側から
student.teachers.add(teacher)
# 逆方向(reverse): Teacher側から(related_name="students"経由)
teacher.students.add(student)
どちらも最終的には同じ中間テーブルへの書き込みですが、Djangoの内部実装(django/db/models/fields/related_descriptors.py)を見ると、m2m_changed は次のように送出されます。
signals.m2m_changed.send(
sender=self.through,
action=action,
instance=self.instance, # .add() を呼び出したインスタンス自身
reverse=self.reverse, # 逆方向から呼ばれたかどうか
model=self.model, # 相手側のモデルクラス
pk_set=pk_set, # 相手側の pk 集合
using=db,
)
ここでポイントになるのはpk_setが相手側のIDになっていることです。StudentからならTeacher, TeacherからならStudentのIDが与えられます。呼び出し方と呼び出し元のインスタンスと対応付けると、
| 呼び出し方 | instance |
pk_set |
|---|---|---|
student.teachers.add(teacher)(forward) |
Student インスタンス |
追加する Teacher の pk 集合 |
teacher.students.add(student)(reverse) |
Teacher インスタンス |
追加する Student の pk 集合 |
となります。
ここで、先ほどのレシーバーは instance が常に Student であることを前提に書かれています。 pk_setがTeacherのIDであるとして、以下のような処理をしているからです。
invalid = Teacher.objects.filter(pk__in=pk_set).exclude( # pk_setはTeacher前提
school_id=instance.school_id
)
ところが、逆方向から呼ばれると、pk_setはTeacherではなくStudentのPK集合になります。ここで注意したいのは、StudentとTeacherは別のテーブルなので、整数PKが同じ値になることは何も珍しくないという点です。たとえばStudent(pk=10)とTeacher(pk=10)が同時に存在している状態は、特別なことではありません。
つまり、このレシーバーの本質的な問題は「Teacherにヒットせず検証を素通りする」ことだけではありません。StudentのPKをTeacherのPKとして誤って解釈し、たまたまPKが一致した無関係なTeacherを検査対象にしてしまうことです。結果として、
- 偶然どの
TeacherのPKとも一致せず、不正な追加を素通りさせてしまう - 偶然別の
TeacherのPKと一致し、そのTeacherのschool_idで誤って判定してしまう(不正を見逃すこともあれば、逆に正しい操作を誤って拒否することもある)
というように、PKの重なり方次第で挙動が変わってしまいます。単に「検知漏れる」だけでなく「無関係なレコードを検査対象にしてしまう」ことこそが、この罠の本当の怖さです。そのため、
class StudentTeacherSignalTests(TransactionTestCase):
def test_add_teacher_different_school_raises_validation_error(self):
"""順方向: student.teachers.add() での校舎不一致"""
with self.assertRaises(ValidationError):
student.teachers.add(other_school_teacher)
のように順方向でしかテストを書いていないと、本当に気づくタイミングがなくなってしまいます。
対策: reverse kwarg で分岐する
ここの対策ではreverseを用います。順方向であればFalse,逆方向であればTrueになります。つまりこの場合は、
-
reverse=False= 順方向 =student.teachers.add(teacher)=instanceはStudent、pk_setはTeacherのPK -
reverse=True= 逆方向 =teacher.students.add(student)=instanceはTeacher、pk_setはStudentのPK
の2つで別の不整合検知を書けばOKです。たとえば以下のようになります。
@receiver(m2m_changed, sender=Student.teachers.through)
def validate_student_teachers(sender, instance, action, reverse, pk_set, **kwargs):
if action != "pre_add":
return
if reverse:
# instance は Teacher、pk_set は追加しようとしている Student の pk 集合
invalid = Student.objects.filter(pk__in=pk_set).exclude(
school_id=instance.school_id
)
else:
# instance は Student、pk_set は追加しようとしている Teacher の pk 集合
invalid = Teacher.objects.filter(pk__in=pk_set).exclude(
school_id=instance.school_id
)
if invalid.exists():
raise ValidationError("所属校舎の異なる相手が含まれています。")
加えて、テストは両方向で書くのを習慣づけるべきです。たとえば以下のような感じですね。
class StudentTeacherSignalTests(TransactionTestCase):
def test_add_teacher_different_school_raises_validation_error(self):
"""順方向: student.teachers.add() での校舎不一致"""
with self.assertRaises(ValidationError):
student.teachers.add(other_school_teacher)
def test_add_student_via_teacher_reverse_manager_raises_validation_error(self):
"""逆方向: teacher.students.add() での校舎不一致"""
with self.assertRaises(ValidationError):
teacher.students.add(other_school_student)
これを回帰テストとして追加しておけば、仮にm2m_changedのreceiverで逆方向を追加し忘れていても、気づくことができるようになります。
両側から触るのであれば、それ相応の設計とテストが必要になる、ということですね。
罠2: through model を直接編集すると m2m_changed は発火しない
ここは少しマニアックになります。具体的な内容としては発火点に気をつけようというものです。
たとえば、Django Adminで、生徒と講師の紐付けを画面から直接編集できるようにしたい、といった理由で、次のように through モデルを直接 TabularInline に指定することがあります(この例では中間テーブルに追加フィールドはなく、Djangoが自動生成する暗黙の through モデル Student.teachers.through をそのまま使っています)。
class TeachersInline(admin.TabularInline):
model = Student.teachers.through # 中間テーブルを直接指定
extra = 1
@admin.register(Student)
class StudentAdmin(admin.ModelAdmin):
inlines = [TeachersInline]
なお、中間テーブルに担当開始日のような追加フィールドを持たせたい場合は、この暗黙の through モデルではなく、明示的に through モデルを定義してM2Mフィールドの through に指定する必要があります(本記事の主題からは外れるため、ここではコード例は割愛します)。
これ自体はまぁまぁやる内容だとは思うのですが、ここで、m2m_changed は ManyRelatedManager(.add() / .set() / .remove() / .clear())を経由したときにしか発火しません。 つまり、中間モデルを直接弄ったときには、そもそもreceiverが反応しません。
つまり、このインラインを使ってAdmin画面から生徒と講師を紐付けると、m2m_changed によるチェックは完全にバイパスされます。
対策
m2m_changedに整合性の保証を任せるのであれば、through モデルを 直接触る経路を作らないのが安全、という表現になると思います。ManyRelatedManager 経由の標準的な操作(.add,.set,.remove,.clear)だけで賄えるなら、それが一番シンプルです。
ただ、どうしてもinlineなどで through モデルを直接編集したい場合は、明示的な through モデルを自作し、そのモデル自身に clean を実装するのも手です。
ただ、ここからはちょっと余談なのですが、clean自体が、特にformが絡んでいる時、
POST
↓
form.is_valid()
↓
field clean
↓
form.clean()
↓
_post_clean()
↓
model.full_clean()
↓
model.clean()
↓
form_valid()
↓
form.save()
という少し複雑な順番で呼ばれる点には要注意です。私は以前、model.cleanで必要な属性をform.saveで設定して本番で500エラーを発生させたことがあります。
また、m2mを作成・改修する際には、書き込み経路を見ておくのも重要です。こちらも個人的な話ですが、ManyToManyが関連している場所は、どうにも調査漏れが出やすいイメージがありますね。
罠3: 存在しないactionが幻視されても、動作が止まらない
これは罠というより、私がAIでやらかした案件の1つなのですが、signalsを初めて実装したとき、以下のようなコードが入っていました
if action not in ("pre_add", "pre_set"):
return
一見「追加と一括更新(.set)の両方を拾っている」ように読めます、というか私はそう理解していました。しかし、m2m_changed が実際に送出する action は次の6種類だけで、"pre_set" は存在しません。幻視です。
-
pre_add/post_add -
pre_remove/post_remove -
pre_clear/post_clear
とはいえ、これは理由もなく急に出てきた勘違いというわけでもなさそうです。というのもManyToManyにはaddとは別にsetというメソッドがあるんですよね。
teacher.students.add(student)
teacher.students.set([student])
addは単純な追加ですが、setは一括で再設定する感じです。内部的には、現在の関連との差分を計算し、必要な削除をremove、必要な追加をadd相当の処理として実行しますので、差分の内容に応じて~_addや~_removeが発火します。
そのため、.set() による新規追加分も最終的には pre_add として発火します。つまり "pre_set" を条件に含めていても実害はありませんが、存在しない値をチェックしているという誤解を招くデッドコードです。
# Before(誤解を招く)
if action not in ("pre_add", "pre_set"):
return
# After(実態に合わせる)
if action != "pre_add":
return
「addがあって、pre_add, post_addがある。じゃあsetがあるんだから、pre_set, post_setがあるのでは?」というのは自然な発想です。実際私も全く気づいていませんでした。
まとめ: チェックリスト
テナント境界・所有関係などの整合性を強制する目的でM2Mに m2m_changed を使うときは、以下を確認するようにしています。
- ちゃんと両方向の書き込みをreceiverとテストで検証しているか
- 中間テーブルを不必要に触っていないか
そして、ちゃぶ台返しになりますが、そもそもm2mの管理自体が大変なので、その保守も過剰になりがち、ということで、
- 単純なUI用のタグ付けM2Mなど、そもそも整合性保証が不要なM2Mまで過剰に守っていないか
ですかね。
ここまで敢えて書いてきませんでしたが、実はsignalsには公式からゴリゴリの警告が発されています。
Django公式ドキュメントのSignalsには、次のような警告が書かれています。
Signals give the appearance of loose coupling, but they can quickly lead to code that is hard to understand, adjust and debug.
Where possible you should opt for directly calling the handling code, rather than dispatching via a signal.
「signalsは疎結合っぽく見えるけど、実際には理解・修正・デバッグがしづらいコードになりやすい。可能なら、signal経由ではなく直接その処理を呼び出す方がいい」という趣旨です。
さらに、自作のsignalを送信する場面についても、
Signals are implicit function calls which make debugging harder. If the sender and receiver of your custom signal are both within your project, you're better off using an explicit function call.
と、送信元と受信先が同じプロジェクト内にあるなら明示的な関数呼び出しの方がいい、と重ねて釘を刺されています。
今回扱ったm2m_changedはDjango組み込みのsignalなので上の2つ目の話とは少し立ち位置が違いますが、「呼び出し元のコードからは見えない場所で処理が動く」という性質そのものは共通です。
そして本記事で挙げた3つの罠は、突き詰めればすべてこの性質に起因しています。
こういう強力な武器を、私は結構ホイホイ使いたくなるのですが、「テナント境界のように絶対に破ってはいけない制約だから、書き込み経路が増えてもsignalで一元的に守りたい」といった、明確な理由がある場合に絞って使うのがよさそうです。
今回はここまでになります。ご覧いただきありがとうございました。指摘、質問等はコメントによろしくお願いします。