はじめに
DjangoとDRFを触ってきて、ORM部分をもっと深掘りしたくなった。
Django ORMはDjango入門の記事で基本的なCRUDは触れたが、JOIN・集計・サブクエリ・N+1問題あたりの実践的な使い方を整理できていなかった。LaravelのEloquentと比較しながらまとめる。
モデルのリレーション定義
まず今回使うモデルを定義する。
# models.py
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=50)
class Meta:
db_table = "categories"
def __str__(self):
return self.name
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
author = models.ForeignKey(
"Author",
on_delete=models.CASCADE,
related_name="posts",
)
category = models.ForeignKey(
Category,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="posts",
)
tags = models.ManyToManyField("Tag", related_name="posts")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "posts"
class Author(models.Model):
name = models.CharField(max_length=50)
email = models.EmailField(unique=True)
class Meta:
db_table = "authors"
class Tag(models.Model):
name = models.CharField(max_length=30, unique=True)
class Meta:
db_table = "tags"
PHPのEloquentと並べると:
<?php
// Laravel Eloquent
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(Author::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}
Laravelはモデルにリレーションメソッドを書くが、DjangoはForeignKeyやManyToManyFieldをフィールドとして定義する。related_nameでリバースリレーションの名前を指定する(LaravelのhasManyに相当)。
基本的なCRUD
作成
<?php
// Laravel
$post = Post::create([
"title" => "Pythonの学習記録",
"body" => "...",
]);
# Django
post = Post.objects.create(
title="Pythonの学習記録",
body="...",
author=author,
)
# または2ステップで
post = Post(title="Pythonの学習記録", body="...")
post.author = author
post.save()
読み取り
<?php
// Laravel
$posts = Post::all();
$post = Post::find(1);
$post = Post::findOrFail(1);
$posts = Post::where("title", "like", "%Python%")->get();
# Django
posts = Post.objects.all()
post = Post.objects.get(id=1) # 見つからなければDoesNotExist
post = Post.objects.filter(id=1).first() # 見つからなければNone
posts = Post.objects.filter(title__contains="Python")
更新
<?php
// Laravel
Post::where("id", 1)->update(["title" => "新しいタイトル"]);
$post->update(["title" => "新しいタイトル"]);
# Django
Post.objects.filter(id=1).update(title="新しいタイトル")
# インスタンス経由
post.title = "新しいタイトル"
post.save()
# 特定フィールドだけ保存(update_fields)
post.title = "新しいタイトル"
post.save(update_fields=["title"]) # titleカラムだけUPDATE
update_fieldsを使うと必要なカラムだけUPDATEするSQLが発行される。Laravelにはない細かい制御ができる。
削除
Post.objects.filter(id=1).delete()
post = Post.objects.get(id=1)
post.delete()
クエリのルックアップ記法
Django ORMの独特な部分。__(ダブルアンダースコア)でフィールド名とルックアップを繋ぐ。
# 数値の比較
Post.objects.filter(id__lt=10) # id < 10
Post.objects.filter(id__lte=10) # id <= 10
Post.objects.filter(id__gt=5) # id > 5
Post.objects.filter(id__gte=5) # id >= 5
Post.objects.filter(id__in=[1, 2, 3]) # IN
# 文字列
Post.objects.filter(title__exact="Python") # = 'Python'
Post.objects.filter(title__iexact="python") # 大文字小文字無視
Post.objects.filter(title__contains="Python") # LIKE '%Python%'
Post.objects.filter(title__icontains="python") # 大文字小文字無視
Post.objects.filter(title__startswith="P") # LIKE 'P%'
Post.objects.filter(title__endswith="n") # LIKE '%n'
# NULL
Post.objects.filter(category__isnull=True) # IS NULL
Post.objects.filter(category__isnull=False) # IS NOT NULL
# 日付
Post.objects.filter(created_at__date=date.today())
Post.objects.filter(created_at__year=2024)
Post.objects.filter(created_at__month=4)
Laravelのwhere("title", "like", "%Python%")がfilter(title__contains="Python")になる。最初は記法が独特だが、型ヒントなしでも補完が効くエディタが多い。
AND / OR / NOT
<?php
// Laravel
Post::where("is_published", true)
->where("author_id", 1)
->get();
Post::where("category_id", 1)
->orWhere("category_id", 2)
->get();
from django.db.models import Q
# AND(filter()を繋ぐかカンマ区切り)
Post.objects.filter(is_published=True, author_id=1)
Post.objects.filter(is_published=True).filter(author_id=1)
# OR(Qオブジェクト)
Post.objects.filter(Q(category_id=1) | Q(category_id=2))
# NOT
Post.objects.filter(~Q(is_published=True))
# または
Post.objects.exclude(is_published=True)
# 複合条件
Post.objects.filter(
Q(title__contains="Python") | Q(title__contains="Django"),
is_published=True, # AND条件はカンマで追加
)
QオブジェクトがLaravelのorWhereに相当する。最初は「なんでQオブジェクト?」と思ったが、OR条件とAND条件を組み合わせた複雑なクエリが書きやすい設計だとわかった。
リレーションのクエリ
# ForeignKeyのリバースリレーション
author = Author.objects.get(id=1)
posts = author.posts.all() # related_name="posts"で定義
# 関連モデルをまたいだフィルタ(JOINが自動で発行)
posts = Post.objects.filter(author__name="田中")
posts = Post.objects.filter(author__email__contains="example.com")
# ManyToManyのフィルタ
posts = Post.objects.filter(tags__name="Python")
# 逆方向のフィルタ
authors = Author.objects.filter(posts__created_at__year=2024)
__でリレーションを跨ぐのがDjango ORMの独特な書き方。LaravelのwhereHas()に相当する操作がフィルタ記法で書ける。
<?php
// Laravel
$posts = Post::whereHas("author", fn($q) => $q->where("name", "田中"))->get();
# Django
posts = Post.objects.filter(author__name="田中")
Djangoのほうが短く書ける。
N+1問題と対処
N+1問題はDjango ORMでも発生する。
# 悪い例 — N+1が発生する
posts = Post.objects.all()
for post in posts:
print(post.author.name) # 各postごとにSELECTが発行される
# select_related — INNER JOIN(ForeignKey/OneToOne用)
posts = Post.objects.select_related("author", "category").all()
for post in posts:
print(post.author.name) # 追加のSELECTは発生しない
# prefetch_related — 別途SELECTしてPythonでマージ(ManyToMany/逆ForeignKey用)
posts = Post.objects.prefetch_related("tags").all()
for post in posts:
print([tag.name for tag in post.tags.all()])
PHPのEloquentとの比較:
<?php
// Laravel
Post::with(["author", "category"])->get(); // eager loading
Post::with("tags")->get();
# Django
Post.objects.select_related("author", "category") # JOIN
Post.objects.prefetch_related("tags") # 別クエリ
LaravelはEager LoadingをすべてEager Loadingで統一しているが、DjangoはJOINするか別クエリかを使い分ける。一般的に:
-
ForeignKey・OneToOne →
select_related()(JOIN) -
ManyToMany・逆ForeignKey →
prefetch_related()(別クエリ)
集計クエリ
from django.db.models import Count, Sum, Avg, Max, Min, F
# 全体集計(Laravelのaggregate()に相当)
total = Post.objects.count()
avg_id = Post.objects.aggregate(avg=Avg("id"))["avg"]
# GROUP BY(annotateを使う)
from django.db.models import Count
authors = Author.objects.annotate(
post_count=Count("posts")
).order_by("-post_count")
for author in authors:
print(f"{author.name}: {author.post_count}件")
<?php
// Laravel
Author::withCount("posts")->orderByDesc("posts_count")->get();
LaravelのwithCount()に相当するのがDjangoのannotate(Count(...))。
# HAVINGに相当(annotate後にfilter)
prolific_authors = Author.objects.annotate(
post_count=Count("posts")
).filter(post_count__gte=5)
# 複数の集計を同時に
from django.db.models import Count, Avg
stats = Post.objects.aggregate(
total=Count("id"),
avg_length=Avg("body__length"),
)
Fオブジェクト — フィールド同士の比較
from django.db.models import F
# フィールド同士の比較(SQLでカラム同士を比較する)
posts = Post.objects.filter(updated_at__gt=F("created_at"))
# フィールドの値を使った更新(Laravelのincrement/decrementに相当)
Post.objects.filter(id=1).update(view_count=F("view_count") + 1)
# race conditionを避けられる(SELECT + UPDATEではなく単一UPDATE)
Laravelのincrement()に相当するのがupdate(field=F("field") + 1)。一度取得して加算してsaveするのと違い、SQLで直接加算するので競合が起きない。
サブクエリ
from django.db.models import OuterRef, Subquery
# 各著者の最新記事タイトルを取得
latest_post = Post.objects.filter(
author=OuterRef("pk")
).order_by("-created_at").values("title")[:1]
authors = Author.objects.annotate(
latest_post_title=Subquery(latest_post)
)
for author in authors:
print(f"{author.name}: {author.latest_post_title}")
LaravelのサブクエリよりDjangoのほうが書きやすいと感じた。OuterRefで外側のクエリを参照できる。
トランザクション
<?php
// Laravel
DB::transaction(function () {
Post::create([...]);
Author::where("id", 1)->increment("post_count");
});
from django.db import transaction
# デコレータ
@transaction.atomic
def create_post_with_count(data):
post = Post.objects.create(**data)
Author.objects.filter(id=data["author_id"]).update(
post_count=F("post_count") + 1
)
return post
# コンテキストマネージャ
def create_post(data):
with transaction.atomic():
post = Post.objects.create(**data)
Author.objects.filter(id=data["author_id"]).update(
post_count=F("post_count") + 1
)
return post
@transaction.atomicデコレータとwith transaction.atomic():の両方が使える。withブロックの中で例外が発生するとロールバックされる。
発行されるSQLを確認する
# クエリセットにstrをかけるとSQLが見える
qs = Post.objects.select_related("author").filter(is_published=True)
print(str(qs.query))
# SELECT "posts"."id", "posts"."title", "authors"."name"
# FROM "posts"
# INNER JOIN "authors" ON ("posts"."author_id" = "authors"."id")
# WHERE "posts"."is_published" = True
# Django Debug Toolbarを使うとブラウザ上で確認できる
pip install django-debug-toolbar
発行されるSQLを確認できるのはN+1問題を発見するときに便利。LaravelのDB::listen()やdebugbarに相当する。
EloquentとDjango ORMの対応表
| 操作 | Laravel Eloquent | Django ORM |
|---|---|---|
| 全件取得 | Post::all() |
Post.objects.all() |
| ID検索 | Post::find(1) |
Post.objects.get(id=1) |
| 条件検索 | Post::where(...) |
Post.objects.filter(...) |
| OR条件 | ->orWhere(...) |
Q(...) |
| NOT条件 | ->whereNot(...) |
~Q(...) / .exclude()
|
| Eager Loading | Post::with("author") |
select_related() / prefetch_related()
|
| 件数 | ->count() |
.count() |
| GROUP BY |
->groupBy() + withCount()
|
.annotate() |
| HAVING | ->having() |
.annotate().filter() |
| サブクエリ | ->whereIn(subquery) |
Subquery() + OuterRef()
|
| increment | ->increment("count") |
.update(count=F("count")+1) |
| トランザクション | DB::transaction() |
transaction.atomic() |
まとめ
-
__ルックアップ記法でJOINもフィルタも書ける - N+1はForeignKey系は
select_related()、ManyToMany系はprefetch_related()で解決 -
QオブジェクトでOR/NOT条件を表現する -
annotate()でGROUP BY、F()でフィールド参照・更新 -
transaction.atomic()でトランザクション管理
Eloquent慣れだと最初は__記法やQオブジェクトに戸惑うが、慣れるとJOINを意識せずにリレーションをまたいだクエリが書けるのは便利。FとQの使い方を覚えてからは複雑なクエリも書けるようになってきた。