SQLを書かない代わりに何が起きているのか ― モデル・マイグレーション・QuerySet
Article.objects.filter(is_published=True) の直後でSQLログを見ても、何も出ていません。絞り込みが失敗したのではなく、まだQuerySetが評価されていないためです。
ORMは「SQLを書かなくていい」と説明されます。ですがDBへ届いているのはSQLです。書かなくていいのではなく、誰かが、こちらの知らないタイミングで書いているわけで、その誰かが何をしているかは見えたほうがいいです。
本記事は「Djangoフレームワーク入門と実践」の第2回です。第1回では、urls.py がURLと処理の対応を決め、views.py が呼ばれ、models.py 経由でデータを取り、テンプレートがHTMLにする、という道順を見ました。今回はその models.py を書き、テーブルができるまでを追いかけます。
この記事でわかること
- モデルのクラスが、どうやってテーブルになるか
- マイグレーションという中間ファイルが、なぜ存在するか
- 流す前に実際のSQLを読む方法
- QuerySetの基本操作と、SQLが飛ぶタイミング
モデルを書く
第1回で作った articles アプリに、記事とカテゴリを定義します。
# articles/models.py
from django.db import models
class Category(models.Model):
name = models.CharField("カテゴリ名", max_length=50, unique=True)
class Meta:
verbose_name = "カテゴリ"
verbose_name_plural = "カテゴリ"
def __str__(self):
return self.name
class Article(models.Model):
title = models.CharField("タイトル", max_length=200)
body = models.TextField("本文")
category = models.ForeignKey(
Category, on_delete=models.PROTECT, related_name="articles",
verbose_name="カテゴリ",
)
is_published = models.BooleanField("公開する", default=False)
published_at = models.DateTimeField("公開日時", auto_now_add=True)
class Meta:
verbose_name = "記事"
verbose_name_plural = "記事"
ordering = ["-published_at"]
def __str__(self):
return self.title
いくつか補足します。
フィールドの第1引数は表示名です(verbose_name)。第3回で管理画面を開くと、この名前が一覧の列見出しとフォームのラベルになります。
class Meta は、フィールドではなくモデル全体の設定を書く場所です。表示名や既定の並び順を置きます。ordering を書いておくと、明示的に並び順を指定しなかったときの既定になります。
__str__() は、そのオブジェクトを文字列で表したときの見た目です。管理画面やshellの表示に使われます。
on_delete は必須です。参照先が消えたときにどうするかを、Djangoは既定値で決めてくれません。PROTECT は「参照している記事があるならカテゴリを消させない」、CASCADE は「一緒に消す」です。CMSのカテゴリを消したときに記事まで消えては困るので、ここでは PROTECT にしています。
related_name は逆方向の名前で、category.articles.all() と書けるようになります。
id は書きません。Djangoが自動で主キーを足します。Pythonからは article.id、汎用的には article.pk で参照します(第3回のURL設計で使います)。
クラスがテーブルになるまで
ここが今回の中心です。モデルの変更は、必ず「マイグレーション」というファイルを経由してDBへ届きます。
この図は4段に分かれています。自分が書くもの、Djangoが生成するもの、Djangoが発行するSQL、DBにできるもの。3段目が、この記事の題である「誰が代わりに書いているのか」の答えです。
まず、変更内容を表すファイルを作ります。
$ python manage.py makemigrations articles
Migrations for 'articles':
articles/migrations/0001_initial.py
+ Create model Category
+ Create model Article
この時点ではまだDBに何も起きていません。できたのはファイルだけです。中身を見ると、ただのPythonコードです。
# articles/migrations/0001_initial.py(抜粋)
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Category",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, ...)),
("name", models.CharField(max_length=50, unique=True, verbose_name="カテゴリ名")),
],
...
),
migrations.CreateModel(name="Article", ...),
]
このファイルはgitで管理します。生成物ですがコミットします。これが「いつ誰がどうスキーマを変えたか」の履歴そのものになるからです。モデルとDBを直接同期させる方式だと、この履歴が残りません。
流す前にSQLを読む
適用する前に、実際に発行されるSQLを確認できます。
python manage.py sqlmigrate articles 0001
BEGIN;
CREATE TABLE "articles_category" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" varchar(50) NOT NULL UNIQUE
);
CREATE TABLE "articles_article" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" varchar(200) NOT NULL,
"body" text NOT NULL,
"is_published" bool NOT NULL,
"published_at" datetime NOT NULL,
"category_id" bigint NOT NULL REFERENCES "articles_category" ("id") DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX "articles_article_category_id_633dad2b" ON "articles_article" ("category_id");
COMMIT;
makemigrations の出力(+ Create model Article)だけでは、DBへ何が適用されるかまでは分かりません。sqlmigrate を読むと、知らないうちに決まっていたことが並んでいます。テーブル名は「アプリ名_モデル名」の小文字。category と書いたのにカラムは category_id。索引は明示していないのに作られています。そして unique=True は UNIQUE 制約に、max_length=200 はSQLite向けDDLでは varchar(200) になっています。
UNIQUE、外部キー、NULL可否など、モデルの一部の指定はDB制約として残ります。 一方、すべての業務ルールやバリデーションが自動的にDB制約になるわけではありません。この境界は第4回で、Springと比較しながらもう一度扱います。
そして適用します。
$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, articles, auth, contenttypes, sessions
Running migrations:
...
Applying articles.0001_initial... OK
articles 以外のマイグレーションも一緒に流れています。第1回で INSTALLED_APPS に並んでいた管理画面や認証も、テーブルを必要とするためです。
「No changes detected」と出たら、直したつもりで直っていません
まず確認するのは、第1回のつまずき所①(アプリが INSTALLED_APPS に無い)、ファイルが保存されていない、モデルの変更が既存マイグレーションと同じ、の3点です。エラーではないので見落とします。
filter() は空でも通り、get() は例外を投げる
テーブルができたので、データを操作します。python manage.py shell で対話的に試せます。
from articles.models import Article, Category
tech = Category.objects.create(name="技術")
Article.objects.create(title="Djangoを始めた", body="本文A", category=tech, is_published=True)
objects は、そのモデルに対するDB操作の窓口です(マネージャと呼びます)。検索もここを通します。
Article.objects.all() # 全件
Article.objects.filter(is_published=True) # 条件で絞る
Article.objects.get(title="ORMを触った") # 1件だけ取る
Article.objects.filter(category__name="技術") # 関連先の条件で絞る
tech.articles.all() # 逆方向(related_name)
category__name のアンダースコア2つが関連をたどる記法です。SQLでいうJOINにあたります。__gte(以上)、__contains(部分一致)のように条件も指定できます。
filter() と get() の違いは、あとで効いてきます。filter() は0件でも例外になりませんが、get() は0件なら DoesNotExist、2件以上なら MultipleObjectsReturned を投げます。
SQLが飛ぶのは、書いた行ではない
冒頭の話がここにつながります。
qs = Article.objects.filter(is_published=True) # ← この時点ではSQLは飛んでいない
qs = qs.filter(category__name="技術") # ← まだ飛ばない
for a in qs: # ← ここで初めて実行される
print(a.title)
QuerySetは評価されるまでDBへ行きません(遅延評価)。だからメソッドを繋いで条件を足していけますし、繋いだ結果が1本のSQLになります。冒頭でSQLログが空だったのは、絞り込みが失敗していたからではなく、まだDBへ行っていなかったためです。
評価のきっかけは、ループ・len()・list()・bool()・インデックス指定(qs[0])などです。逆に qs[:5] のようなスライスは評価されず、LIMITの付いたQuerySetが返るだけです(qs[::2] のようにステップを付けた場合だけ、その場でリストとして評価されます)。
組み立てられたSQLの形は str(qs.query) で覗けます。ただしこれはそのままでは実行できない表示用の文字列で、値のクォートが省かれます。実際に発行されたSQLを見たいときは、DEBUG = True のもとで django.db.connection.queries を読むか、qs.explain() を使います。
なお、遅延するのはQuerySetの実行だけではありません。取ってきたオブジェクトから関連先(article.category)へ触ったときも、そこで初めてSQLが飛びます。こちらが件数ぶんのクエリを生むのがN+1で、第5回で実測しながら扱います。
実務ではこうなる
別々のブランチで同じアプリのモデルを変更すると、末端のマイグレーションが2本に分岐することがあります。まず python manage.py makemigrations --merge が生成する依存関係と操作を読み、両方の変更を同時に適用できるかをテストします。互いに独立した変更なら、両方へ依存するマージ用マイグレーションで解決できます。
同じカラムを別々に変更している場合は、モデルの最終形と適用順を先に決め、必要なら前進方向の修正マイグレーションを追加します。共有済み、またはステージング/本番へ適用済みのマイグレーションを安易に削除・書き換えてはいけません。 過去へ戻す migrate <app> <migration> も、操作が可逆であることを確認したうえで、破棄可能な開発DBなど範囲を限定して使います。
本番に当てる前は sqlmigrate を読みます。カラムの型変更やNOT NULL追加は、テーブルの作り直しやロックを伴うことがあります。件数の多いテーブルでは、流す時間帯まで含めて考える必要があります。
このサンプルでは published_at に auto_now_add=True を付けていますが、実際に記録されるのは作成時刻です。下書きとして作って後で公開する運用なら、作成日時とは別にNULL可の公開日時を持ち、公開時に値を入れる設計が合います。サンプルは説明を短くするためこのまま進めますが、モデルは後から変えられても、既存データの移行が付いて回ります。最初に名前と意味を合わせておくのが安上がりです。
確認した範囲
Django 5.2.17、Python 3.11、SQLite。上記のモデルで makemigrations/migrate を実行し、生成された 0001_initial.py と sqlmigrate の出力(記事中のSQLはその抜粋)を確認しました。QuerySetの各操作(all/filter/get/category__name での絞り込み/related_name の逆参照/Meta.ordering の効き方)も実行しています。
遅延評価の挙動は、django.db.connection.queries の本数を数えて確かめました。qs[:3] は0本のままQuerySetが返り、qs[0] で1本、qs[0:6:2] はリストになって1本でした。--merge の動作も、コンフリクトを作ってから実行し、生成された 0003_merge_*.py が operations = [] であることまで確認しています。
SQLの文言はDBエンジンによって変わります。
公式リファレンス
モデルとスキーマ
クエリ
参考文献・参照資料
根拠は公式ドキュメントとサンプルのローカル実行結果です。掲載したSQLは sqlmigrate の出力から必要部分を抜粋しています。
次の第3回では、ここで作ったモデルを管理画面から編集できるようにし、一覧・詳細ページを表示します。「モデルを1つ定義しただけで管理画面が手に入る」というDjangoの一番おいしい部分です。
