0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SQLを書かない代わりに何が起きているのか ― モデル・マイグレーション・QuerySet

0
Posted at

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のクラスが、CREATE TABLE になるまで

まず、変更内容を表すファイルを作ります。

$ 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=TrueUNIQUE 制約に、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_atauto_now_add=True を付けていますが、実際に記録されるのは作成時刻です。下書きとして作って後で公開する運用なら、作成日時とは別にNULL可の公開日時を持ち、公開時に値を入れる設計が合います。サンプルは説明を短くするためこのまま進めますが、モデルは後から変えられても、既存データの移行が付いて回ります。最初に名前と意味を合わせておくのが安上がりです。

確認した範囲

Django 5.2.17、Python 3.11、SQLite。上記のモデルで makemigrationsmigrate を実行し、生成された 0001_initial.pysqlmigrate の出力(記事中のSQLはその抜粋)を確認しました。QuerySetの各操作(allfiltergetcategory__name での絞り込み/related_name の逆参照/Meta.ordering の効き方)も実行しています。

遅延評価の挙動は、django.db.connection.queries の本数を数えて確かめました。qs[:3] は0本のままQuerySetが返り、qs[0] で1本、qs[0:6:2] はリストになって1本でした。--merge の動作も、コンフリクトを作ってから実行し、生成された 0003_merge_*.pyoperations = [] であることまで確認しています。

SQLの文言はDBエンジンによって変わります。

公式リファレンス

モデルとスキーマ

クエリ

参考文献・参照資料

根拠は公式ドキュメントとサンプルのローカル実行結果です。掲載したSQLは sqlmigrate の出力から必要部分を抜粋しています。

次の第3回では、ここで作ったモデルを管理画面から編集できるようにし、一覧・詳細ページを表示します。「モデルを1つ定義しただけで管理画面が手に入る」というDjangoの一番おいしい部分です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?