4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

DjangoAdvent Calendar 2023

Day 7

軽い気持ちで ManyToMany を使ってはいけないかもしれない

Last updated at Posted at 2023-12-06

とても便利な ManyToMany

 Django の ORM に ManyToMany という便利な機能があります。ブログ記事とタグのような多対多の関係をとても簡単に定義し、操作することができます。しかし、その恐ろしい副作用に気付いているでしょうか……?

 ここでは、簡単な例を用いてそれを説明していきたいと思います。

航空会社のシステムを作りたい

 ジャンゴ航空に頼まれたあなたは、Django で航空会社のフライト情報を管理するシステムを作ることになりました。必要な情報は顧客一覧とフライト便一覧と、どのフライトにどの顧客が乗るかの対応表です。これはまさに ManyToMany が役に立つところです。

 あなたは以下のようなモデルを定義することにしました。

./app/models.py
from django.db import models

# Create your models here.

class Customer(models.Model):
    name = models.CharField(max_length=100)
    
    def __str__(self) -> str:
        return self.name

class Flight(models.Model):
    code = models.CharField(max_length=10)
    passengers = models.ManyToManyField(Customer)
    
    def __str__(self) -> str:
        return self.code

 ダミーデータも用意することにします。

./seed_data.py
import os
import django
import random

# Djangoプロジェクトの設定をセットアップ
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'flight.settings')
django.setup()

from app.models import Customer, Flight, PassengerInfo 

# カスタマーとフライトを作成
customer_names = ["Alice", "Bob", "Charlie", "David", "Eve"]
flight_codes = ["FL100", "FL200", "FL300", "FL400", "FL500"]

# 顧客データをデータベースに追加
for name in customer_names:
   Customer.objects.create(name=name)

# フライトデータをデータベースに追加
for code in flight_codes:
   Flight.objects.create(code=code)

# すべての顧客とフライトを取得
all_customers = Customer.objects.all()
all_flights = Flight.objects.all()

# ランダムにリレーションを設定
for flight in all_flights:
   # 各フライトにランダムな数の顧客を追加(1人から5人)
   random_customers = random.sample(list(all_customers), random.randint(1, 5))
   flight.passengers.add(*random_customers)

print("データ作成完了!")

 その結果、以下のような対応表ができました。

image.png

 これを html で確かめられるようにします。

./app/views.py
from django.shortcuts import render

# Create your views here.

from django.shortcuts import render
from .models import Customer, Flight

def flight_list(request):
   flights = Flight.objects.all()
   return render(request, 'app/flight_list.html', {'flights': flights})
./app/template/app/flight_list.html
<!DOCTYPE html>
<html>
<head>
    <title>Flight List</title>
</head>
<body>
    <h1>Available Flights</h1>
    <ul>
        {% for flight in flights %}
        <li>{{ flight.code }}</li>
        <ul>
            {% for passenger in flight.passengers.all %}
            <li>
                {{ passenger }}
            </li>
            {% endfor %}
        </ul>
        {% endfor %}
    </ul>
</body>
</html>

 表示は以下のようになります。

image.png

 実質 1 行の views だけで、ここまでデータが出せるようになりました。これが ManyToMany の威力です。

 もちろん、逆引きもできます。さすがに冗長なので、キーポイントだけ抜き出しますが、

{% for flight in customer.flight_set.all %}

 と、テンプレートで書くことにより、以下のような html を作ることもできます。

image.png

仕様変更

 しかし、ジャンゴ航空の担当者はあなたに恐ろしいことを告げました。

「フライト情報に等級の情報が必要だから、付け加えてくれない?」

 データベースのスキーマを構築した後なのに!

 弱ったあなたは、ChatGPT に泣きつくことにしました。

image.png

 ふむふむ、through というフィールドを付け加えてマイグレーションすればいいのか。しかし、

image.png

 なんとマイグレーションでエラーが出てしまいます。この場合、新たに中間モデルを定義し直さなければいけません。既に顧客情報が積み上がってしまっていた場合は、非常に面倒な話になります。

image.png

 ManyToMany で十分な場合というのは、この表に載る情報が純粋な「◯」だけの場合であり、何かひとつでも付加的な情報が必要な場合は、関係するデータを複合キーとする中間テーブルを最初から自作するべきだった というのがこれの教訓になります。

./app/models.py
class Customer(models.Model):
   name = models.CharField(max_length=100)
   
   def __str__(self) -> str:
       return self.name

class Flight(models.Model):
   code = models.CharField(max_length=10)
   customers = models.ManyToManyField(Customer, through='Passenger')
   
   def __str__(self) -> str:
       return self.code

class Passenger(models.Model):
   flight = models.ForeignKey(Flight, on_delete=models.CASCADE, related_name='passengers')
   customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
   grade = models.CharField(max_length=50)  # 例: "First", "Business", "Economy"など

   class Meta:
       unique_together = ('flight', 'customer')
       
   def __str__(self) -> str:
       return f'{self.customer} at {self.grade} class'

 要は最初からこういう定義をすれば良かったという話。こうすれば、もし等級以外に別の付加的な情報が必要になったとしても、柔軟に付け足すことができます。

じゃあ through って何のためにあるの?

 このような中間モデルがあれば、わざわざ ManyToMany という関係を作らなくても事実上の多対多関係は実装できます。では、through は何のためにあるのでしょうか? このあたり、Django Brothers や関係記事などを見ても腹落ちするものがなかったので(記事で紹介されているメリットは、すべて through を指定しなくても実現可能なものに思えます)、自分なりに考察してみました。

弱いメリット:Related 先に直接アクセスできる

 ManyToMany の関係があれば、flight.customers.all() といったようなクエリで Flight から Customer に直接アクセスできます。人数を数えるなどのごくかんたなクエリであればこれでコードが簡潔になるかもしれません。しかし、これは付加情報をいっさい持つことができないという弱点をそのまま引き継ぎます。

 そのため、これは弱いメリットであると考えます。なぜなら、わざわざ情報を増やしたということは、乗客一覧の情報を get する時は、なにがしかのソートや集計である必要がある場合がほとんどと考えるのが自然です。そのためには、中間モデルをそのまま使わざるを得ません。

弱いメリット:add()remove() が使える

 ManyToMany の関係があるオブジェクトは、add()remove() で簡潔に追加や削除ができます。これを使えない場合、Passenger.object.create(Customer=customer, Flight=flight... といった形で、やや長いコードを書く必要があります。

 しかし、実は中間テーブルが定義されているモデルを add() した場合、付加情報はすべて null またはデフォルト値で登録されてしまうという挙動のようです。いくらコードが簡潔に書けても、これでは実用的ではありません。これも弱いメリットだと思います。

 一応、remove() は、そのような副作用がないので、良いかもしれません……?(削除の処理はそれなりに重大なので、気軽にしてはいけないというか、コードの簡潔さはあまりメリットにならないと思いますが)

弱いメリット:可読性が高まる

 これは開発者体験的な話になりますが、事実上の中間テーブルがモデルとして定義されている場合、独立して存在するよりも、中間テーブルであることが明記されている方がコードの意図が明確になります。また、誰かに引き継いだ時も「あのフィールドと ManyToMany がないから追加しちゃお~」という事故を防ぐことができます(適切なコミュニケーションがあれば 100 %ないと思いますが)。

結論

 以上のように、はっきりとしたメリットがあるとは言えないですが、つけるに越したことはないので、付加情報を持った ManyToMany をつける場合には、最初から(超重要) through で独自中間モデルを定義していた方がよいです。というか、through 使うかに関わらず、とにかく独自中間モデルは必要です。
 
 とは言っても、開発の未来をすべて予見するのは困難。もし単純な ManyToMany が付加情報を持ちそうな気配を感じたら、その瞬間に勇気を出して、移行コストが積み上がる前にスキーマを作り直して移行スクリプトを走らせた方がいいかもしれません。

 いままさに、そのような問題(単純な ManyToMany が付加情報を持ってしまった)で苦しんでいる筆者からの提言でした。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?