🗄️

Django ORMで育ったエンジニアが生SQLを学び直す 【モデル定義編】

はじめに

私はこれまでDjangoを中心にWebアプリケーションを開発してきました。models.pyにモデルを定義して、User.objects.filter(...)でデータを取り出す。この書き方にすっかり慣れていて、たいていのことはDjango ORMで実現できました。

一方で、生のSQLはよくわかっていません。SELECTやWHERE、JOINといった単語は知っているものの、それぞれがどんな役割で、どう書くものなのかを正確に説明できない。ORMが全部やってくれるので、これまで困る場面が少なかったのです。

それでも学び直そうと思ったのは、Django以外の選択肢を持ちたくなったからです。PrismaやTypeORM、GraphQLなど、他の言語・フレームワークでもWebアプリを作ってみたいと考えたとき、それぞれのORMの使い方を個別に覚えるより、土台にあるSQLの知識・概念を一度ちゃんと押さえたほうが早いと気づきました。SQLがわかれば、どのORMのドキュメントも「これはあのSQLを組み立てているんだな」と読めるようになるはずです。

幸い、Django ORMで何ができるかは一通り知っています。なので、ゼロからSQLの教科書を読むのではなく、「普段書いているDjangoのコードが、裏では実際どんなSQLになっているのか」を確かめる形で学んでいきます。次のような4本立てのシリーズにする予定です。

  • モデル定義編(本記事): models.pyの定義がどんなテーブルになるのか
  • モデル変更編: makemigrationsとmigrateの裏でどんなALTER TABLEが走るのか
  • クエリ編: filterやupdateの裏でどんなクエリが走っているのか
  • パフォーマンスチューニング編: N+1問題とselect_related・prefetch_related、集計の裏側

本記事はその1本目、モデル定義編です。CharFieldForeignKeyManyToManyFieldと書いたとき、データベースには実際どんなテーブルが作られているのかを、生成されるSQLを見ながら確認していきます。

対象読者は、私と同じく「Django ORMは書けるけれど、SQLには自信がない」というWeb系エンジニアです。

独自に調べた内容のため、誤りを含むかもしれません。気づいた点があれば指摘してもらえると助かります。

検証環境

このシリーズのコードは、次の環境で動かして確認しています。

  • Python 3.14.0
  • Django 6.0.6
  • PostgreSQL 17(Dockerで起動)
  • psycopg 3.3.4

データベースにはPostgreSQLを使います。生成されるSQLはデータベースによって変わる部分があり(JSONFieldの型など)、SQLiteやMySQLとの違いが大きいところは本文で都度触れます。

この連載で使うサンプルモデル

このシリーズでは、4記事を通して同じモデルを使い回します。題材はこのブログにも馴染みのあるブログサービスで、User・Profile・Tag・Post・Commentの5モデルです。

  • User — 名前とメールアドレスだけの基準点。他のモデルから参照されます
  • Profile — Userと1対1(OneToOneField)。自己紹介文と、リンク集のJSONFieldを持ちます
  • Post — 主役。作者への外部キー、TextChoicesのステータス、閲覧数、公開日時、作成・更新日時、タグへのManyToManyと、確認したいものを全部盛りにしています
  • Tag — 名前だけのモデル。PostとのManyToManyの相手です
  • Comment — PostとUserへの外部キーを2本持ちます。クエリ編以降で活躍します

閲覧数(view_count)やコメントのような一見余計なフィールドは、続くクエリ編・パフォーマンスチューニング編で集計やN+1問題の題材にするために入れてあります。

models.pyの全文はこちらです。

blog/models.py 全文
from django.db import models


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

    email = models.EmailField(
        unique=True,
    )

    def __str__(self):
        return self.name


class Profile(models.Model):
    user = models.OneToOneField(
        User,
        on_delete=models.CASCADE,
    )

    bio = models.TextField(
        blank=True,
    )

    links = models.JSONField(
        default=dict,
        blank=True,
    )

    def __str__(self):
        return f"Profile of {self.user}"


class Tag(models.Model):
    name = models.CharField(
        max_length=50,
        unique=True,
    )

    def __str__(self):
        return self.name


class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "下書き"
        PUBLISHED = "published", "公開"
        ARCHIVED = "archived", "アーカイブ"

    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name="posts",
    )

    title = models.CharField(
        max_length=200,
    )

    slug = models.SlugField(
        max_length=200,
    )

    body = models.TextField()

    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )

    view_count = models.PositiveIntegerField(
        default=0,
    )

    published_at = models.DateTimeField(
        null=True,
        blank=True,
    )

    created_at = models.DateTimeField(
        auto_now_add=True,
    )

    updated_at = models.DateTimeField(
        auto_now=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="posts",
        blank=True,
    )

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["author", "slug"],
                name="unique_author_slug",
            )
        ]

    def __str__(self):
        return self.title


class Comment(models.Model):
    post = models.ForeignKey(
        Post,
        on_delete=models.CASCADE,
        related_name="comments",
    )

    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name="comments",
    )

    body = models.TextField()

    created_at = models.DateTimeField(
        auto_now_add=True,
    )

    def __str__(self):
        return f"Comment by {self.author} on {self.post}"

モデル同士の関係をER図にするとこうなります。

erDiagram
    USER ||--o| PROFILE : "OneToOne"
    USER ||--o{ POST : "author"
    USER ||--o{ COMMENT : "author"
    POST ||--o{ COMMENT : "post"
    POST }o--o{ TAG : "ManyToMany"

    USER {
        bigint id PK
        varchar name
        varchar email UK
    }
    PROFILE {
        bigint id PK
        bigint user_id FK "UNIQUE"
        text bio
        jsonb links
    }
    POST {
        bigint id PK
        bigint author_id FK
        varchar title
        varchar slug "authorとの複合UNIQUE"
        text body
        varchar status "TextChoices: draft/published/archived"
        integer view_count
        timestamptz published_at "null可"
        timestamptz created_at "auto_now_add"
        timestamptz updated_at "auto_now"
    }
    TAG {
        bigint id PK
        varchar name UK
    }
    COMMENT {
        bigint id PK
        bigint post_id FK
        bigint author_id FK
        text body
        timestamptz created_at "auto_now_add"
    }

ORMが発行するSQLを確認する方法

モデルを定義したら、いつもどおりマイグレーションファイルを作ります。

python manage.py makemigrations blog

普段はこのままmigrateを実行して終わりですが、今回知りたいのは「このとき実際に何が実行されるのか」です。Djangoにはそのためのコマンドが用意されていて、sqlmigrateを使うとマイグレーションが発行するSQLを実行せずに表示できます。

python manage.py sqlmigrate blog 0001

先ほどの5つのモデルに対して出てきたSQLの全文がこちらです。

sqlmigrateの出力全文
BEGIN;
--
-- Create model Tag
--
CREATE TABLE "blog_tag" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "name" varchar(50) NOT NULL UNIQUE);
--
-- Create model User
--
CREATE TABLE "blog_user" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "name" varchar(100) NOT NULL, "email" varchar(254) NOT NULL UNIQUE);
--
-- Create model Profile
--
CREATE TABLE "blog_profile" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "bio" text NOT NULL, "links" jsonb NOT NULL, "user_id" bigint NOT NULL UNIQUE);
--
-- Create model Post
--
CREATE TABLE "blog_post" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "title" varchar(200) NOT NULL, "slug" varchar(200) NOT NULL, "body" text NOT NULL, "status" varchar(20) NOT NULL, "view_count" integer NOT NULL CHECK ("view_count" >= 0), "published_at" timestamp with time zone NULL, "created_at" timestamp with time zone NOT NULL, "updated_at" timestamp with time zone NOT NULL, "author_id" bigint NOT NULL);
CREATE TABLE "blog_post_tags" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "post_id" bigint NOT NULL, "tag_id" bigint NOT NULL);
--
-- Create model Comment
--
CREATE TABLE "blog_comment" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "body" text NOT NULL, "created_at" timestamp with time zone NOT NULL, "post_id" bigint NOT NULL, "author_id" bigint NOT NULL);
--
-- Create constraint unique_author_slug on model post
--
ALTER TABLE "blog_post" ADD CONSTRAINT "unique_author_slug" UNIQUE ("author_id", "slug");
CREATE INDEX "blog_tag_name_c5718cc8_like" ON "blog_tag" ("name" varchar_pattern_ops);
CREATE INDEX "blog_user_email_8f71103d_like" ON "blog_user" ("email" varchar_pattern_ops);
ALTER TABLE "blog_profile" ADD CONSTRAINT "blog_profile_user_id_2bc46caa_fk_blog_user_id" FOREIGN KEY ("user_id") REFERENCES "blog_user" ("id") DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE "blog_post" ADD CONSTRAINT "blog_post_author_id_dd7a8485_fk_blog_user_id" FOREIGN KEY ("author_id") REFERENCES "blog_user" ("id") DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "blog_post_slug_b95473f2" ON "blog_post" ("slug");
CREATE INDEX "blog_post_slug_b95473f2_like" ON "blog_post" ("slug" varchar_pattern_ops);
CREATE INDEX "blog_post_author_id_dd7a8485" ON "blog_post" ("author_id");
ALTER TABLE "blog_post_tags" ADD CONSTRAINT "blog_post_tags_post_id_tag_id_4925ec37_uniq" UNIQUE ("post_id", "tag_id");
ALTER TABLE "blog_post_tags" ADD CONSTRAINT "blog_post_tags_post_id_a1c71c8a_fk_blog_post_id" FOREIGN KEY ("post_id") REFERENCES "blog_post" ("id") DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE "blog_post_tags" ADD CONSTRAINT "blog_post_tags_tag_id_0875c551_fk_blog_tag_id" FOREIGN KEY ("tag_id") REFERENCES "blog_tag" ("id") DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "blog_post_tags_post_id_a1c71c8a" ON "blog_post_tags" ("post_id");
CREATE INDEX "blog_post_tags_tag_id_0875c551" ON "blog_post_tags" ("tag_id");
ALTER TABLE "blog_comment" ADD CONSTRAINT "blog_comment_post_id_580e96ef_fk_blog_post_id" FOREIGN KEY ("post_id") REFERENCES "blog_post" ("id") DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE "blog_comment" ADD CONSTRAINT "blog_comment_author_id_4f11e2e0_fk_blog_user_id" FOREIGN KEY ("author_id") REFERENCES "blog_user" ("id") DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "blog_comment_post_id_580e96ef" ON "blog_comment" ("post_id");
CREATE INDEX "blog_comment_author_id_4f11e2e0" ON "blog_comment" ("author_id");
COMMIT;

とても長い文字列が出てきました。 このSQLが実際に実行されるものなので、1つずつ紐解いて見ていきます。

models.pyに書いた5つのモデルに対応するCREATE TABLEが並んでいるのがわかります。1文ずつの読み解きは次の章からやっていくとして、まずこの全文を眺めた時点で早速わからないものがありました。先頭のBEGIN;と末尾のCOMMIT;です。

調べてみると、これはトランザクションの構文でした1BEGIN;でトランザクションを開始すると、そこから先の変更はまだ確定されず、最後にCOMMIT;が実行されてはじめてまとめて適用されます。途中でエラーが起きた場合はそこまでの変更がすべて取り消されるので、「5つのうち3つだけテーブルが作られた」という中途半端な状態になりません。マイグレーション全体がひとつのトランザクションで囲まれているのは、失敗しても安全にやり直せるようにするためでした。

以降の章では、この出力を少しずつ切り出しながら疑問に思った部分を読み解いていきます。

基本のモデルはどんなCREATE TABLEになるのか

いちばん単純なTagモデルから見ていきます。models.pyの定義はこれだけです。

class Tag(models.Model):
    name = models.CharField(
        max_length=50,
        unique=True,
    )

sqlmigrateの出力のうち、対応するのはこの1文です。実際の出力は1行につながっていますが、見やすく整形しました。

CREATE TABLE "blog_tag" (
  "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  "name" varchar(50) NOT NULL UNIQUE
);

この時点での疑問と感想です。それぞれ調べてみました。

  • テーブル名が”blog_tag”になっています。モデル名はTagなので、「Djangoのアプリ名_モデル名」という規則で付けられているのか
    • → そのとおりで、デフォルトは「アプリ名_モデル名(小文字)」でした。
  • idの行、NOT NULLまではいいのですがPRIMARY KEY GENERATED BY DEFAULT AS IDENTITYが長い。英文みたいですが、SQLを手で書く人は毎度これを書いているのか
    • → これがいまのPostgreSQLで標準的な自動採番の書き方だそうです。昔ながらのbigserialという短い書き方もあるそうです。
  • NOT NULL UNIQUEは直感的ですね。

CREATE TABLEの読み方

まず気になったのがCREATE TABLEです。これは知らないなりにも、なんとなく聞いたことがあるものです。

調べてみると、その名のとおりテーブルを作成する命令文で、DDL(Data Definition Language、データ定義言語)と呼ばれる種類のSQLでした2。SELECTやUPDATEのようにデータを操作するSQLとは分類が違い、テーブルという入れ物そのものを定義するための構文です。基本構文は次の形です。

CREATE TABLE テーブル名 (
  列名1 型名 制約,
  列名2 型名 制約,
  列名3 型名 制約,
  ...
);

列の定義は「列名・型名・制約」の3点セットです。制約は省略でき、逆にスペース区切りで複数並べることもできます。

これを踏まえて出力されたSQLを見ると、"blog_tag"がテーブル名で、"id""name"が列名。bigintvarchar(50)が型名にあたります。varchar(50)の50は、CharFieldに書いたmax_length=50がそのまま渡ってきたものです。そして型名の後ろに続くNOT NULL(NULLを禁止する)やUNIQUE(値の重複を禁止する)が制約です。nameの列定義を分解すると、"name"が列名、varchar(50)が型名、NOT NULL UNIQUEが制約2つ、ときれいに3点セットに収まっています。UNIQUEは、モデルに書いたunique=Trueがそのまま渡ってきたものです。

フィールドと型の対応表

CharFieldがvarcharになることはわかりました。出力の全文を見渡すと、他にもtext、integer、timestamp、bigintといった型が登場しています。どのフィールドがどの型になったのか、サンプルモデルの範囲で照らし合わせてみます。

DjangoのフィールドPostgreSQLの型サンプルモデルでの例
BigAutoFieldbigint各テーブルのid(自動で生える)
CharField(max_length=50)varchar(50)Tag.name
EmailFieldvarchar(254)User.email
SlugFieldvarchar(200)Post.slug
TextFieldtextPost.body、Profile.bio
PositiveIntegerFieldinteger + CHECK制約Post.view_count
DateTimeFieldtimestamp with time zonePost.created_atなど
JSONFieldjsonbProfile.links
ForeignKey / OneToOneFieldbigintPost.author_idなど

見比べていくつか気づいたことがあります。

  • EmailFieldもSlugFieldも、DB上はただのvarcharです。メールアドレスかどうかの検証はDjango側のバリデーションで、データベースは何も知りません。varchar(254)の254は、EmailFieldのmax_lengthのデフォルト値です
  • PositiveIntegerFieldは「positiveなinteger型」がDBにあるわけではなく、integer型にCHECK ("view_count" >= 0)という制約を付けて実現されています
  • ForeignKeyの列(author_idなど)がbigintなのは、参照先のidがbigintだからです。外部キーの型は相手のidの型に合わせて決まります

EmailFieldとSlugFieldがSQL上では同じvarcharになる、というのが面白いところです。形式のチェックはDjango側で引き受ける設計になっているのだと感じました。

サンプルモデルに登場しなかったフィールドはどうなるのか気になったのですが、サンプルモデル以外のフィールドは、このような対応付けになっていました。

PostgreSQLバックエンドの対応表(抜粋)
DjangoのフィールドPostgreSQLの型
BooleanFieldboolean
IntegerFieldinteger
SmallIntegerFieldsmallint
BigIntegerFieldbigint
FloatFielddouble precision
DecimalFieldnumeric(max_digits, decimal_places)
DateFielddate
TimeFieldtime
DurationFieldinterval
UUIDFielduuid

JSONFieldの実体

対応表の中でひとつ気になったのがJSONFieldです。jsonbという見慣れない型になっていました。これはPostgreSQLが持っているJSON専用の型で、ただの文字列としてではなく、構造を解釈した状態で保存されます。おかげで「linksの中のこのキーの値で検索する」といったことがDB側でできます3

null・blank・defaultはどこに現れるのか

Postモデルにはnull=Trueblank=Truedefaultを指定したフィールドがあります。これらがDDLのどこに行ったのかも確かめてみます。

status = models.CharField(
    max_length=20,
    choices=Status.choices,
    default=Status.DRAFT,
)

published_at = models.DateTimeField(
    null=True,
    blank=True,
)

blog_postのCREATE TABLEから該当する列を切り出すとこうです。

"status" varchar(20) NOT NULL,
"published_at" timestamp with time zone NULL,

まずnull=Trueです。blog_postの列の中で、published_atだけがNOT NULLではなくNULLになっています。Djangoのフィールドはデフォルトがnull=Falseなので、何も指定しなければNOT NULL制約が付き、null=Trueを書いたときだけNULLが許可される、という素直な対応でした。

次にblank=Trueですが、これはDDLのどこにも現れません。blankはフォームバリデーションで空を許すかどうかというDjango側だけの設定で、データベースは関知していませんでした。

意外だったのはdefaultです。SQLにはDEFAULT句でデフォルト値を指定する構文があるのですが2、DDLには反映されていません。defaultの値はINSERTを発行する時点でDjangoが埋めているようです。

TextChoicesの実体

Post.statusはTextChoicesを使って、draft/published/archivedの3択で定義しています。

class Status(models.TextChoices):
    DRAFT = "draft", "下書き"
    PUBLISHED = "published", "公開"
    ARCHIVED = "archived", "アーカイブ"

status = models.CharField(
    max_length=20,
    choices=Status.choices,
    default=Status.DRAFT,
)

これがDDLでどうなっていたかというと、こうです。

"status" varchar(20) NOT NULL,

ただのvarcharです。draft/published/archivedという選択肢の情報が、DDLのどこにも見当たりません。ということは、選択肢のチェックはDBでは行われていないのか気になりました。試しに、選択肢に無い値を直接INSERTしてみます。python manage.py dbshellでpsql(PostgreSQLの対話コンソール)に入り、statusに’banana’を入れてみました。

psqlでstatusにbananaを指定したINSERT文を実行し、INSERT 0 1と成功が返っている画面

INSERT 0 1、つまり成功です。テーブルを見ると、選択肢に無いはずのbananaが普通に登録されています。

blog_postテーブルの中身を表示し、statusカラムにbananaという値が入っている画面

この結果から、TextChoicesの選択肢はDBには関与しておらず、チェックしているのはDjangoのバリデーションということがわかりました。choicesは「フォームの選択肢を作る+バリデーション時に検証する」ためのもので、データベースの制約ではありませんでした。

auto_now_addとauto_nowの正体

次はcreated_atとupdated_atです。

created_at = models.DateTimeField(
    auto_now_add=True,
)

updated_at = models.DateTimeField(
    auto_now=True,
)

DDLではこうなっていました。

"created_at" timestamp with time zone NOT NULL,
"updated_at" timestamp with time zone NOT NULL,

auto_now_addもauto_nowも、DDLには何の痕跡もありません。SQLで「作成時に現在時刻を入れる」をやるならどうするのか調べてみると、SQLにはCURRENT_TIMESTAMPやCURRENT_DATEといった日時の関数があり、これをDEFAULTに入れるのが定番のようです4

-- SQLでやるならこう書ける
"created_at" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,

しかしDjangoはこれも使っていません。auto_now_add/auto_nowの日時は、保存するたびにDjangoがPython側で現在時刻を取得し、INSERTやUPDATEに値として直接埋め込んでいます。choicesやdefaultと同じ「実はDjango側」パターンで、そのためORMを通らない書き込み(生SQLやDBのGUIツールなど)では日時が自動では入りません。

リレーションはどうテーブルになるのか

次はリレーションです。サンプルモデルにはForeignKey(Post.author、Comment)、OneToOneField(Profile.user)、ManyToManyField(Post.tags)の3種類が入っています。出力をざっと見た時点での私の予想はこうでした。

  • OneToOneは、相手のidを入れる列をNOT NULL UNIQUEにしているっぽい
  • ForeignKeyは、bigint NOT NULLの列に相手のidの数字を入れているだけに見える
  • ManyToManyは新しくテーブルが作られていて、参照元と参照先のidが入ったレコードがたくさん作られるイメージ

だいたい合っていたのですが、答え合わせをしていくと、どの予想にも1段ずつ大事な要素が隠れていました。

ForeignKey

Post.authorの定義はこうです。

author = models.ForeignKey(
    User,
    on_delete=models.CASCADE,
    related_name="posts",
)

対応するSQLは、blog_postのCREATE TABLEの中の1列と、その後ろのALTER TABLEの2か所に分かれていました。見やすく整形しています(author_idにはCREATE INDEXも発行されていますが、インデックスの話はパフォーマンスチューニング編で扱う予定です)。

-- CREATE TABLE "blog_post" の中
"author_id" bigint NOT NULL

-- 後ろでまとめて実行される
ALTER TABLE "blog_post"
  ADD CONSTRAINT "blog_post_author_id_dd7a8485_fk_blog_user_id"
  FOREIGN KEY ("author_id") REFERENCES "blog_user" ("id")
  DEFERRABLE INITIALLY DEFERRED;

まず、モデルではauthorという名前だったのに、列名はauthor_idになっています。列そのものは予想どおり「相手のidの数字を入れるbigintの列」でした。相手のidがbigintだから、こちらもbigintです。

次にALTER TABLEのほうです。ALTER TABLEは作成済みのテーブルをあとから変更する命令で、ADD CONSTRAINTは「制約を追加する」という意味です。では直後の"blog_post_author_id_dd7a8485_fk_blog_user_id"という長い文字列は何かというと、これは追加する制約に付けた名前でした。「テーブル名_列名_ハッシュ_fk_参照先」という形でDjangoが自動生成しています。制約に名前を付けておくと、あとからこの制約だけをDROP CONSTRAINT 制約名で削除したり付け替えたりできます。マイグレーションでスキーマを変更し続けるDjangoには必須の仕組みで、モデル変更で実際に使う場面が出てきます。真ん中のdd7a8485というハッシュは、制約名の衝突を避けるためのものです。

そしてFOREIGN KEY ... REFERENCES ...の部分が外部キー制約の本体です。構文を一般化すると、外部キー制約の書き方は3つありました。

-- 1. 列定義に直接書く(列制約)
CREATE TABLE テーブル名 (
  列名 型名 REFERENCES 参照先テーブル名 (参照先列名)
);

-- 2. テーブル制約として書く
CREATE TABLE テーブル名 (
  列名 型名,
  FOREIGN KEY (列名) REFERENCES 参照先テーブル名 (参照先列名)
);

-- 3. あとからALTER TABLEで追加する(Djangoはこれ)
ALTER TABLE テーブル名
  ADD CONSTRAINT 制約名
  FOREIGN KEY (列名) REFERENCES 参照先テーブル名 (参照先列名);

Djangoが3つ目の方式を使っているのは、テーブルを全部作り終えてから制約をまとめて張れば、テーブル同士の参照関係の順序を気にしなくて済むからのようです。

改めて調べてみると、私は外部キー制約そのものを全然理解していませんでした。author_idのような列は「DB側で数字をいい感じに解釈してくれている」くらいの認識だったのです。実際の外部キー制約の仕事はシンプルで、「この列に入れていいのは、参照先テーブルに実際に存在する値だけ」というチェックです。参照元には数字があるのに参照先にその行がない、という壊れた参照ができてしまうと辿った先でエラーになってしまうので、そうなるINSERTやUPDATEを実行した時点でDBが拒否してくれます。逆方向も守られていて、まだ参照されている行を消そうとするとエラーになります。

最初はNOT NULLやUNIQUEも外部キー制約の一部なのかと思っていたのですが、これらは別の制約です。author_idのNOT NULLは、DjangoのForeignKeyがデフォルトでnull=Falseだから付いているだけで、null=TrueにすればNOT NULLは消えます(NULL、つまり「参照先なし」が許されるようになる)。UNIQUEはForeignKeyには付いておらず、次のOneToOneFieldで登場します。

ここで面白い発見がありました。モデルにはon_delete=models.CASCADEと書いたのに、DDLのどこにも現れていません。SQLにはON DELETE CASCADEという構文がちゃんとあるのに、です。

調べてみると、これはDjangoの意図的な設計でした。公式ドキュメントに「on_deleteに指定されたSQL制約の挙動をDjangoがエミュレートする」「on_deleteはデータベースにSQL制約を作らない」と明記されていました5。つまり削除の連鎖は、DBではなくPython側で実行されています。

理由として一番大きいのはシグナルです。Djangoにはpre_delete/post_deleteというシグナルの仕組みがあり、CASCADEで巻き込まれて消える関連オブジェクトすべてに対しても発火する仕様になっています。削除をDBに任せてしまうと、何が消えたのかPython側では把握できず、シグナルを発火できません。そのためDjangoは、削除前に関連オブジェクトをSELECTで集めてから自分でDELETEを発行しています。

もうひとつの理由は、on_deleteの選択肢がSQLのON DELETE句と1対1に対応していないことです。SQL側にもCASCADE/RESTRICT/SET NULL/SET DEFAULTといった選択肢があり、たとえばDjangoのPROTECTがあります。これは「参照されている行の削除を拒否する」という意味でSQLのRESTRICTに相当します。1対1に対応させずに、すべてをPython側に寄せることで、どの選択肢・どのデータベースでも同じ挙動になるよう統一されています。

なお、FOREIGN KEY制約の末尾に付いているDEFERRABLE INITIALLY DEFERREDは「制約のチェックをCOMMITの時点まで遅らせる」という指定です6

OneToOneField

Profile.userの定義はこうです。

user = models.OneToOneField(
    User,
    on_delete=models.CASCADE,
)

対応するSQLを切り出します。

-- CREATE TABLE "blog_profile" の中
"user_id" bigint NOT NULL UNIQUE

-- 後ろでまとめて実行される
ALTER TABLE "blog_profile"
  ADD CONSTRAINT "blog_profile_user_id_2bc46caa_fk_blog_user_id"
  FOREIGN KEY ("user_id") REFERENCES "blog_user" ("id")
  DEFERRABLE INITIALLY DEFERRED;

私の予想は「相手のidをNOT NULL UNIQUEにしている」でしたが、正確にはUNIQUEが付いているのはProfile自身のidではなく、相手を指すuser_id列です。

そして見比べるとわかるとおり、これはForeignKeyのauthor_idとまったく同じ構造で、違いはUNIQUEが1つ付いているかどうかだけです。UNIQUEがあることで「同じUserを指すProfileは2つ作れない」ため、1対1が保証されます。つまりOneToOneFieldの実体はForeignKey + UNIQUEでした。

ManyToManyField

最後にManyToManyです。Post.tagsの定義はこうです。

tags = models.ManyToManyField(
    Tag,
    related_name="posts",
    blank=True,
)

これに対応するのは、列ではなくテーブルまるごと1つです。models.pyには書いていないblog_post_tagsというテーブルが作られていました。

CREATE TABLE "blog_post_tags" (
  "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  "post_id" bigint NOT NULL,
  "tag_id" bigint NOT NULL
);

ALTER TABLE "blog_post_tags"
  ADD CONSTRAINT "blog_post_tags_post_id_tag_id_4925ec37_uniq"
  UNIQUE ("post_id", "tag_id");

ALTER TABLE "blog_post_tags"
  ADD CONSTRAINT "blog_post_tags_post_id_a1c71c8a_fk_blog_post_id"
  FOREIGN KEY ("post_id") REFERENCES "blog_post" ("id")
  DEFERRABLE INITIALLY DEFERRED;

ALTER TABLE "blog_post_tags"
  ADD CONSTRAINT "blog_post_tags_tag_id_0875c551_fk_blog_tag_id"
  FOREIGN KEY ("tag_id") REFERENCES "blog_tag" ("id")
  DEFERRABLE INITIALLY DEFERRED;

CREATE INDEX "blog_post_tags_post_id_a1c71c8a" ON "blog_post_tags" ("post_id");
CREATE INDEX "blog_post_tags_tag_id_0875c551" ON "blog_post_tags" ("tag_id");

こういうテーブルは中間テーブルと呼ばれるそうです。構成を図にするとこうなります。

erDiagram
    POST ||--o{ POST_TAGS : ""
    TAG ||--o{ POST_TAGS : ""
    POST_TAGS {
        bigint id PK
        bigint post_id FK
        bigint tag_id FK "post_idと複合UNIQUE"
    }

予想どおり、「記事とタグの紐付け1件につき1行」のレコードがこのテーブルにたまっていきます。記事1がタグAとタグBを持てば2行、タグAが記事1と記事2に付いていれば、それも別の行。だから多対多が表現できるわけです。

予想に足りていなかったのは2点です。まずUNIQUE ("post_id", "tag_id")という複合UNIQUE制約が付いていて、同じ記事に同じタグを2回登録することはDBレベルで防がれています。1列のUNIQUEしか知らなかったので、複数列の組み合わせに対してUNIQUEを張れるのは発見でした。そしてpost_idとtag_idの両方に、ForeignKeyのときと同じFOREIGN KEY制約とインデックスが付いています。

つまり中間テーブルの正体は「ForeignKey2本 + 複合UNIQUE」で、OneToOneが「ForeignKey + UNIQUE」だったことも合わせると、リレーション3種はすべてForeignKeyの応用でできていました。

ちなみに、中間テーブルに列を足したい場合(タグを付けた日時を記録したい、など)は、throughオプションで中間テーブルを自分のモデルとして定義できます。その場合も、できあがるテーブルの基本構造はこれと同じです。

ここまでで、EmailFieldのメール形式チェック、choicesの選択肢チェック、defaultの値埋め、auto_now_add/auto_nowの日時、そしてon_deleteの削除連鎖と、「SQL側に対応する仕組みがあってもDjango側でやる」パターンが繰り返し出てきました。型はDBに渡すが、制約やロジックの多くはDjangoが握っている。models.pyを読むときは「これはDDLに出るのか、Djangoがやるのか」を意識すると、テーブルの実像が見えるようになってきました。

Metaオプションはどこに現れるのか

最後にモデルのMetaオプションです。ここは概要だけ押さえます。

サンプルモデルで使っているのはconstraintsです。PostのMetaに書いた複合UNIQUE制約は、出力の中にそのまま見つかりました。

class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["author", "slug"],
            name="unique_author_slug",
        )
    ]
ALTER TABLE "blog_post" ADD CONSTRAINT "unique_author_slug" UNIQUE ("author_id", "slug");

ManyToManyの中間テーブルで見た複合UNIQUEと同じ形です。「author_idとslugの組み合わせが重複しない」、つまり同じ作者が同じslugを2回使えないという制約で、slug単体のUNIQUEではない点がポイントです。面白いのは制約名で、これまでのハッシュ入りの自動生成と違い、自分で付けたunique_author_slugがそのまま使われていました。

サンプルでは使っていませんが、DDLに関わる代表的なMetaオプションを2つ挙げておきます。

  • db_table — テーブル名の上書きです。CREATE TABLEのテーブル名がその名前になります。最初の感想パートで出た「アプリ名_モデル名」という命名規則は、これを指定しなかった場合のデフォルトでした。
  • ordering — これはDDLに一切現れません。SQLのテーブルには行の順序という概念がそもそも無く、順序はSELECTのORDER BY句で毎回指定するものだからです。orderingの実体は「クエリを発行するたびにDjangoが自動でORDER BYを付ける」というDjango側の設定で、テーブル定義とは無関係でした。

まとめ

sqlmigrateの出力を最初に見たときは「とても長い文字列」でしたが、いま読み返すと、1文ずつ意味がわかるようになっていました。今回わかったことを並べます。

  • CREATE TABLE文の列の定義は「列名・型名・制約」の3点セットで読めるようになりました
  • リレーション3種はすべてForeignKeyの応用でした。OneToOneは「ForeignKey + UNIQUE」、ManyToManyは「ForeignKey2本 + 複合UNIQUEの中間テーブル」です。そして外部キーの本体は列ではなく、参照整合性を守るFOREIGN KEY制約のほうでした
  • 一方で、SQL側に仕組みがあってもDjangoがPython側でやっているものが想像以上に多くありました。choicesの選択肢チェック、defaultの値埋め、auto_now_add/auto_nowの日時、on_deleteの削除連鎖。DjangoなのかSQLなのかという視点を持てるようになったのが、今回一番の収穫です

次回はモデル変更編です。テーブルは作れるようになったので、フィールドの追加やリネームといったモデルの変更が、makemigrationsとmigrateの裏でどんなALTER TABLEになるのかを見ていきます。

参考文献

脚注

  1. https://zenn.dev/umi_mori/books/331c0c9ef9e5f0/viewer/aba691

  2. https://sukkiri.jp/books/sukkiri_sql4 2

  3. https://www.postgresql.jp/docs/17/datatype-json.html

  4. https://qiita.com/ruemura3/items/7bdca11243c8f1b49ae2

  5. https://docs.djangoproject.com/en/6.0/ref/models/fields/#django.db.models.ForeignKey.on_delete

  6. https://www.postgresql.jp/docs/17/sql-set-constraints.html

新着記事

関連ネットワーク(beta)

ドラッグで移動 / Ctrl+ホイールでズーム