カテゴリー
アーカイブ

03.19
2025

[Django] DB設計:リレーションの定義とSQLクエリを理解する(前編)

  • LINE

Djangoは強力なORMを持つWebフレームワークで、リレーションの管理を簡単に行うことができます。一方で、実際のテーブル定義やSQLクエリが見えにくいため、思わぬところで問題が発生する場合があります。

これから2回に渡って、ForeignKey、OneToOne、ManyToManyを使ったリレーションの定義方法と、Django ORMが発行するSQLクエリについて確認し、処理効率を上げる方法について見ていきます。

本記事(前編)では、ForeignKeyについて見ていきます。

 

環境構築

Dockerで立てたPostgreSQLとホストOSで立ち上げたDjangoアプリを使用し、動作確認環境を構築していきます。

docker-compose.yml

services:
  db:
    image: postgres:17
    environment:
      - POSTGRES_DB=djangoapp
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"

PostgreSQLの起動

$ docker compose up

Django アプリの構築

$ mkdir djangoapp
$ cd djangoapp
$ python3.12 -m venv .venv 
$ source .venv/bin/activate 
$ pip install django~=5.0.0
$ django-admin startproject djangoapp .
$ django-admin startapp exampleapp

アプリの追加、DB設定の更新

INSTALLED_APPS = [
    ...,
    "exampleapp.apps.ExampleappConfig",
]

...

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "djangoapp",
        "USER": "postgres",
        "PASSWORD": "postgres",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

Djangoアプリの起動

# 初回のみ実行
$ python manage.py migrate 

$ python manage.py runserver

 

一対多のリレーション:ForeignKeyを使う

まずは、ForeignKeyを使って一対多のリレーションを定義する方法を見ていきます。

例えば、「著者と書籍」の関係です。一人の著者が複数の書籍を持つという関係を定義します。

モデルの定義

from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

    class Meta:
        db_table = "author"

    def __str__(self):
        return self.name

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

    class Meta:
        db_table = "book"

    def __str__(self):
        return self.title

このモデル設計では、Bookモデルの中にauthorという外部キーが定義されています。これにより、書籍(Book)は著者(Author)と関連付けられます。on_delete=models.CASCADEは、著者が削除された場合にその著者に関連する書籍も削除される設定です。

なお、ForeignKeyのフィールドは一対多の「多」側のモデルに定義します。素のDB設計で外部キーを定義するのと同じ考え方ですね。

生成されるDDL

このモデル設計に基づいたテーブル作成のDDLを確認していきます。

$ python manage.py makemigrations
Migrations for 'exampleapp':
  exampleapp/migrations/0001_initial.py
    - Create model Author
    - Create model Book
$ python manage.py sqlmigrate exampleapp 0001
BEGIN;

--
-- Create model Author
--
CREATE TABLE "author" (
    "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    "name" varchar(100) NOT NULL
);

--
-- Create model Book
--
CREATE TABLE "book" (
    "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    "title" varchar(200) NOT NULL,
    "author_id" bigint NOT NULL
);

ALTER TABLE
    "book"
ADD
    CONSTRAINT "book_author_id_c4d52965_fk_author_id" FOREIGN KEY ("author_id") REFERENCES "author" ("id") DEFERRABLE INITIALLY DEFERRED;

CREATE INDEX "book_author_id_c4d52965" ON "book" ("author_id");

COMMIT;

BookモデルでForeignKeyとして定義したauthorがbookテーブルではauthor_idという外部キーとして定義され、authorテーブルのidを参照していることがわかります。外部キー制約により、著者が削除されると、それに関連する書籍も削除されます。

データ取得方法と発行されるSQLクエリ

DDLの実行とサンプルデータの投入を行います。

$ python manage.py migrate exampleapp 0001

exampleapp/fixtures/authors_and_books.json

[
    {"model": "exampleapp.author", "pk": 1, "fields": {"name": "J.K. Rowling"}},
    {"model": "exampleapp.author", "pk": 2, "fields": {"name": "George R.R. Martin"}},
    {"model": "exampleapp.book", "pk": 1, "fields": {"title": "Harry Potter and the Philosopher's Stone", "author": 1}},
    {"model": "exampleapp.book", "pk": 2, "fields": {"title": "Harry Potter and the Chamber of Secrets", "author": 1}},
    {"model": "exampleapp.book", "pk": 3, "fields": {"title": "Harry Potter and the Prisoner of Azkaban", "author": 1}},
    {"model": "exampleapp.book", "pk": 4, "fields": {"title": "A Game of Thrones", "author": 2}},
    {"model": "exampleapp.book", "pk": 5, "fields": {"title": "A Clash of Kings", "author": 2}},
    {"model": "exampleapp.book", "pk": 6, "fields": {"title": "A Storm of Swords", "author": 2}}
]
$ python manage.py loaddata authors_and_books.json

authorテーブルに2レコードと、それぞれに紐づく形でbookテーブルに3レコードずつ登録されました。

動作確認用にviewを用意します。

exampleapp/views.py

from django.http import HttpResponse
from django.template import Context, Template

from exampleapp.models import Author, Book

def index(request):
    return HttpResponse("hello")

exampleapp/urls.py

from django.urls import path

from . import views

app_name = "exampleapp"
urlpatterns = [
    path("", views.index),
]

djangoapp/urls.py

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("exampleapp/", include("exampleapp.urls")),
]

書籍から著者を取得したい場合、.authorで取得できます。

exampleapp/views.py

def index(request):
    books = Book.objects.all()
    template = Template("""
        {% for book in books %}
            <p>{{ book }} written by {{ book.author }}</p>
        {% endfor %}
    """)
    context = Context({"books": books})
    return HttpResponse(template.render(context))

先ほど用意した画面を表示します。

画面上は問題なさそうですが、実はこのやり方には問題があります。実際に発行されるSQLを見ていきましょう。

実行されたSQLを確認できるよう、ロガーの設定を追加します。

djangoapp/settings.py

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "console": {
            "level": "DEBUG",
            "class": "logging.StreamHandler",
        },
    },
    "loggers": {
        "django.db.backends": {
            "level": "DEBUG",
            "handlers": ["console"],
        },
    },
}

もう一度画面を表示し、ログ出力からSQLクエリを確認します。

(0.001) SELECT "book"."id", "book"."title", "book"."author_id" FROM "book"; args=(); alias=default
(0.001) SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 2 LIMIT 21; args=(2,); alias=default
(0.000) SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 2 LIMIT 21; args=(2,); alias=default
(0.000) SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 2 LIMIT 21; args=(2,); alias=default

書籍の一覧を取得した後、.authorを参照するたびにauthorテーブルからデータを取得するSQLクエリが実行されていることがわかります。

これはいわゆる N+1 問題であり、データ量が増えた時に処理時間への影響が顕著に現れます。

解決策として、select_related という関数が用意されています。

先ほどの処理を改善していきます。

exampleapp/views.py

def index(request):
    books = Book.objects.select_related("author").all()
    ...

もう一度画面を表示し、ログ出力からSQLクエリを確認します。

(0.003) SELECT "book"."id", "book"."title", "book"."author_id", "author"."id", "author"."name" FROM "book" INNER JOIN "author" ON ("book"."author_id" = "author"."id"); args=(); alias=default

INNER JOIN句が追加され、1回のSQLクエリ実行でデータを取得できていますね!

 

では、逆に著者から書籍を取得する場合を考えましょう。

関連データが複数存在する場合は xxx_set を使って取得できます。なお、プロパティ名はForeignKeyインスタンスの初期化時にrelated_nameを指定することで変更可能です。今回は指定しませんでしたが、"books"のような名前を指定するとより直感的になるでしょう。

def index(request):
    authors = Author.objects.all()
    template = Template("""
        {% for author in authors %}
            {{ author }}
            <ul>
                {% for book in author.book_set.all %}
                    <li>{{ book }}</li>
                {% endfor %}
            </ul>
        {% endfor %}
    """)
    context = Context({"authors": authors})
    return HttpResponse(template.render(context))

こちらも N+1 問題が起きています。

(0.002) SELECT "author"."id", "author"."name" FROM "author"; args=(); alias=default
(0.002) SELECT "book"."id", "book"."title", "book"."author_id" FROM "book" WHERE "book"."author_id" = 1; args=(1,); alias=default
(0.002) SELECT "book"."id", "book"."title", "book"."author_id" FROM "book" WHERE "book"."author_id" = 2; args=(2,); alias=default

先ほど使用したselect_relatedを使えば良さそうにも思えますが、今回のケースでは使えません。

def index(request):
    authors = Author.objects.select_related("book_set").all()
django.core.exceptions.FieldError: Invalid field name(s) given in select_related: 'book_set'. Choices are: (none)

その理由について、公式ドキュメントでは次のように述べられています。

'大量の' のリレーションを結合することで、膨大な結果になってしまうのを避けるため、 select_related を適用できるのは単一値のリレーション、つまり外部キーと一対一の関係に制限されています。

今回は複数値のリレーションがあるケースであり、解決策として prefetch_related という関数が用意されています。

先ほどの処理を改善していきます。

def index(request):
    authors = Author.objects.prefetch_related("book_set").all()

もう一度画面を表示し、ログ出力からSQLクエリを確認します。

(0.002) SELECT "author"."id", "author"."name" FROM "author"; args=(); alias=default
(0.002) SELECT "book"."id", "book"."title", "book"."author_id" FROM "book" WHERE "book"."author_id" IN (1, 2); args=(1, 2); alias=default

著者のIDを元に書籍の一覧を取得するSQLクエリが発行され、計2回のSQLクエリ実行でデータを取得できていることがわかります。

 

さて、select_relatedとprefetch_relatedの2つを紹介しましたが、これらの使い分けを整理すると次のようになります。

select_related:自身から見て、単一のレコードが紐づく場合に使用する。

prefetch_related:自身から見て、複数のレコードが紐づく場合に使用する。

 


 

次回の記事(後編)では、OneToOne、ManyToManyを使ったリレーションの定義方法について見ていきます。