◀ 9.

【django】モデルの内部結合(1対1)

この記事の要点
  • Django ではモデルにリレーション(ForeignKey / OneToOneField)を定義すると、SQL を書かずに結合できる
  • select_related('dept') を付けると 1 回の SQL(JOIN)で関連テーブルも取得する
  • 結合列が NOT NULL なら INNER JOIN、null=True なら LEFT OUTER JOIN になる
  • 「ユーザー 1 人に部署 1 つ」は ForeignKey(多対一)。本当に 1 対 1 の関係なら OneToOneField を使う
  • print(queryset.query) で、実際に発行される SQL を確認できる

Django の結合の考え方

Django の ORM では、SQL の JOIN を直接書く代わりに、モデルどうしの関係をフィールドとして定義します。関係を定義しておけば、user.dept.dept_name のように属性をたどるだけで関連テーブルの値を参照できます。

ただし、何も指定しないと関連テーブルの値は参照した時点で別の SQL を発行して取得されます。一覧表示などで件数分の SQL が走る「N+1 問題」を避けるため、最初の SQL で JOIN させる select_related を使います。この記事では、ユーザー(user テーブル)と部署(dept テーブル)を結合する例で説明します。

モデル

ユーザー側に部署への外部キーを定義します。

from db_itn.models.dept import Dept
from django.db import models

class User(models.Model):
    user_id = models.IntegerField(primary_key=True)
    dept = models.ForeignKey(Dept, on_delete=models.DO_NOTHING)

    class Meta:
        db_table = 'user'
from django.db import models

class Dept(models.Model):
    dept_id = models.IntegerField(primary_key=True)
    dept_name = models.CharField(max_length=2000)

    class Meta:
        db_table = 'dept'

ForeignKey のフィールド名を dept にすると、データベース上の列名は自動的に dept_id になります。既存のテーブルで列名が違う場合は db_column='列名' で指定します。

ForeignKey と OneToOneField の違い

フィールド関係例逆向きの参照
ForeignKey多対一(部署 1 つにユーザー複数)ユーザーと所属部署dept.user_set.all()
OneToOneField1 対 1(ユーザー 1 人にプロフィール 1 件)ユーザーと詳細プロフィールuser.profile

上の例は「ユーザーから見て部署が 1 つ」なので、ユーザー側から見れば 1 対 1 で値を取れますが、データモデルとしては多対一です。部署から所属ユーザーを一覧することもあるなら、ForeignKey のままで正解です。相手側も必ず 1 件だけと決まっている場合(ユーザーとプロフィールなど)は、OneToOneField を使うと重複登録をデータベースの一意制約で防げます。結合の書き方はどちらも同じです。

ビュー

users = User.objects.filter(user_id=1).select_related('dept')

select_related に関連フィールド名を渡すと、次のような JOIN 付きの SQL が 1 回だけ発行されます(実際の列名は省略しています)。

SELECT user.user_id, user.dept_id, dept.dept_id, dept.dept_name
FROM user
INNER JOIN dept ON (user.dept_id = dept.dept_id)
WHERE user.user_id = 1

dept フィールドに null=True を付けている場合は、部署が未設定のユーザーも取得できるよう LEFT OUTER JOIN になります。

結合先の列で絞り込みたいときは、__(アンダースコア 2 つ)で関連をたどります。この場合も内部結合の SQL が生成されます。

# 部署名が「営業部」のユーザー
users = User.objects.filter(dept__dept_name='営業部').select_related('dept')

# 必要な列だけ辞書で取得する場合
rows = User.objects.values('user_id', 'dept__dept_name')

テンプレート

filter() の結果は 1 件でも QuerySet(複数件の入れ物)なので、テンプレートでは for で取り出します。

{% for user in users %}
    {{ user.user_id }}
    {{ user.dept.dept_id }}
    {{ user.dept.dept_name }}
{% endfor %}

user.user_id が user テーブルの列、user.dept.dept_id と user.dept.dept_name が dept テーブルの列です。1 件だけ取得したい場合は、ビューで User.objects.select_related('dept').get(user_id=1) とすれば、テンプレートで直接 {{ user.dept.dept_name }} と書けます。

注意点

  • select_related は「1 つ」の側にだけ使える: ForeignKey・OneToOneField の順方向(と OneToOne の逆方向)が対象です。部署から所属ユーザー一覧のような「多」の側や多対多には prefetch_related を使います(SQL は 2 回に分かれます)
  • DO_NOTHING の扱い: on_delete=models.DO_NOTHING は、部署が削除されても Django 側では何もしない設定です。データベースに外部キー制約があれば削除時にエラー、なければ存在しない部署を指すユーザーが残ります。新規に設計するなら PROTECT や CASCADE など、意図に合ったものを選びます
  • select_related の付け忘れ: 付けなくても動作はしますが、一覧で user.dept を参照するたびに SQL が発行されます。件数が多いと急に遅くなる原因になります

確認方法

qs = User.objects.filter(user_id=1).select_related('dept')
print(qs.query)  # 発行される SQL を表示

出力に INNER JOIN が含まれていれば結合されています。開発中は SQL ログを出力して、ページ表示時に発行される SQL の回数も確認しておくと、N+1 問題に早く気づけます。

関連

Post Share
子ページ

子ページはありません

同階層のページ
  1. MySQL/MariaDBへの接続
  2. sqliteへの接続
  3. SELECT, INSERT, UPDATE, DELETE
  4. 素のSQLを直接実行する方法
  5. Order by DESCの指定方法
  6. limit, offsetの指定方法
  7. filterの検索オプション
  8. django-filterのlookup_expr検索オプション
  9. モデルの内部結合(1対1)