DjangoBrothers BLOG ✍️

2019/07/15

このエントリーをはてなブックマークに追加
Django モデル クエリセット ManyToMany

【Django】ManyToManyフィールド throughで中間テーブルを自作する

Djangoで多対多の関係を作りたいときはManyToManyフィールドを使いますが、その引数としてthroughを使うことができます。 throughを使えば独自の中間テーブルを使うことができます。その使い方についてです。

ManyToManyFieldを使った時の参照や逆参照の仕方は、こちらの記事で書いています。

ManyToManyフィールドを使うと、中間テーブルは自動生成される

まずは、throughオプションを使わない単純なManyToManyFieldの例からです。

ManyToManyFieldを使うと、Djangoは自動で中間テーブルを作成してくれます。(中間テーブルのテーブル名は、モデル名とフィールド名から自動で命名されます。db_tableオプションを使えば、自分で命名することもできます。)

models.py

from django.db import models

class Person(models.Model):
    """人"""
    name = models.CharField("名前", max_length=100)

class Team(models.Model):
    """チーム"""
    name = models.CharField("チーム名", max_length=100)
    members = models.ManyToManyField("Person")

TeamクラスのmembersフィールドでManyToManyFieldを使いPersonモデルを参照する設定にしています。 これにより、PersonとTeamを紐づける中間テーブルが自動生成されます。

上のようなモデルを作った場合は、以下のようなオブジェクト操作が可能です。

オブジェクト操作

>>> p1 = Person.objects.create(name='Jobs')
>>> team_A = Team.objects.create(name='チームA')

# team_AのメンバーとしてJobsを登録する
>>> team_A.members.add(p1)

# team_Aに所属する全てのメンバーを取得する
>>> team_A.members.all()

# Jobsが所属する全てのチームを取得する
>>> p1.team_set.all()

中間テーブルのフィールド名

自動生成された中間テーブルは、3つのフィールドを持ちます。

他モデルに対してリレーションを作った場合(今回の例のような場合)

  • id : リレーションの主キーフィールド
  • 参照元モデル_id(team_id) : ManyToManyFieldを使用しているモデルのインスタンスが保存されるフィールド
  • 参照先モデル_id(person_id) : ManyToManyフィールドで指定された参照先モデルのインスタンスが保存されるフィールド

自身のモデルに対してリレーションを作った(参照元モデル=参照先モデル)の場合

  • id: リレーションの主キー
  • from_モデル_id: 参照元インスタンスのid
  • to_モデル_id: 参照先インスタンスのid

throughで独自の中間テーブルを作る

ManyToManyフィールドを使えば中間テーブルが自動生成されると書きましたが、through引数を使えば、自分で作ったモデルを中間テーブルとして使うことができるようになります。

上で紹介したように、自動生成された中間テーブルは3つしかフィールドを持ちませんが、独自の中間テーブルではフィールドを自由に追加するなどのカスタマイズが可能になります。

独自の中間テーブルを使うには、以下のようにします。

models.py

class Person(models.Model):
    """人"""
    name = models.CharField("名前", max_length=100)

class Team(models.Model):
    """チーム"""
    name = models.CharField("チーム名", max_length=100)
    members = models.ManyToManyField(
        "Person",
        through="PersonTeamRelation",  # 追加
    )

# 追加
class PersonTeamRelation(models.Model):
    """人とチームの中間テーブル"""
    team = models.ForeignKey("Team", on_delete=models.CASCADE)
    person = models.ForeignKey("Person", on_delete=models.CASCADE)
    joined_date = models.DateField()
    reason = models.CharField(max_length=64, verbose_name="きっかけ")

PersonTeamRelationという中間モデルを作り、それをManyToManyFieldのthrough引数で指定することで、中間テーブルとして使用しています。 中間テーブルを独自実装することで、「人とチームのリレーション」に対してjoined_dateフィールドやreasonフィールドのように必要な情報を付け加えることができます。

オブジェクト操作

>>> from datetime import date
# p1をteam_Aとteam_Bのmemberにする
>>> PersonTeamRelation.objects.create(team=team_A, person=p1, joined_date=date.today(), reason="友達に誘われたから")
>>> PersonTeamRelation.objects.create(team=team_B, person=p1, joined_date=date.today(), reason="友達に誘われたから")

# team_Aのmemberを全て取得
>>> team_A.members.all()

# p1が持つリレーションを全て取得
>>> p1.personteamrelation_set.all()

# p1が所属するチームを、joined_dateと共に全て取得
>>> p1.personteamrelation_set.all().values_list('team', 'joined_date')
<QuerySet [(1, datetime.date(2019, 7, 15)), (2, datetime.date(2019, 7, 15))]>

through_fieldsで参照先フィールドを明示する

上のPersonTeamRelationモデルの例では、Teamモデルを参照するForeignKeyフィールドが1つ、Personモデルを参照するForeignKeyフィールドが1つあります。

このように、ForeignKeyを使ったフィールドが2つあると、それぞれの参照先モデルを結びつける多対多の関係を作ってくれます。

では、以下の例ではどうでしょう。Teamを参照するフィールドが1つという点では上と同じですが、Personを参照するフィールドが2つ(person, inviter)あります。

models.py

class Person(models.Model):
    """人"""
    name = models.CharField("名前", max_length=100)


class Team(models.Model):
    """チーム"""
    name = models.CharField("チーム名", max_length=100)
    members = models.ManyToManyField(
        "Person",
        through="PersonTeamRelation",
    )

class PersonTeamRelation(models.Model):
    """人とチームの中間テーブル"""
    team = models.ForeignKey("Team", on_delete=models.CASCADE)
    person = models.ForeignKey("Person", on_delete=models.CASCADE)
    inviter = models.ForeignKey(
        "Person",
        on_delete=models.CASCADE,
        verbose_name="チームへの招待者",
        related_name="invited_teams",
    )
    joined_date = models.DateField()
    reason = models.CharField(max_length=64, verbose_name="きっかけ")

personフィールドとinviterフィールドがどちらもPersonモデルを参照しています。 こうなった場合、Djangoはどちらのフィールドを使って多対多の関係を作って良いかが判別できなくなり、エラーとなってします。

エラーを防ぐためには、次のようにthrough_fieldsを使います。

models.py

class Person(models.Model):
    """人"""
    name = models.CharField("名前", max_length=100)


class Team(models.Model):
    """チーム"""
    name = models.CharField("チーム名", max_length=100)
    members = models.ManyToManyField(
        "Person",
        through="PersonTeamRelation",
        through_fields=("team", "person")  # 追加
    )

class PersonTeamRelation(models.Model):
    """人とチームの中間テーブル"""
    team = models.ForeignKey("Team", on_delete=models.CASCADE)
    person = models.ForeignKey("Person", on_delete=models.CASCADE)
    inviter = models.ForeignKey(
        "Person",
        on_delete=models.CASCADE,
        verbose_name="チームへの招待者",
        related_name="invited_teams",
    )
    joined_date = models.DateField()
    reason = models.CharField(max_length=64, verbose_name="きっかけ")

through_fieldsで明示的に2つのフィールドを指定してあげることによって、指定されたteamフィールドとpersonフィールドのオブジェクトで多対多の関係を作ってくれます。

オブジェクト操作

>>> p1 = Person.objects.create(name='Jobs')
>>> p2 = Person.objects.create(name='Rola')
>>> team_A = Team.objects.create(name='チームA')
>>> PersonTeamRelation.objects.create(team=team_A, person=p2, inviter=p1, joined_date=date.today(), reason='Jobsに誘われたから')

# inviterがJobsのPersonTeamRelationオブジェクトを全て取得
>>> p1.invited_teams.all()

# Jobsがどのチームにどの人を招待したかの一覧を取得
>>> p1.invited_teams.all().values_list('person', 'team')
<QuerySet [(2, 1)]>