🔧

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

はじめに

本記事は、Django ORMで育った私が生SQLを学び直すシリーズの2本目、モデル変更編です。シリーズは次の4本立てです。

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

前回のモデル定義編では、models.pyに書いたモデル定義が実際にはどんなCREATE TABLEになるのかを、sqlmigrateの出力を読みながら確かめました。

テーブルは作れるようになりましたが、実際の開発ではモデルは一度作って終わりではありません。フィールドを足したくなり、名前を変えたくなり、型や制約を見直したくなります。普段はmakemigrationsとmigrateを実行して済ませているこの作業の裏で、どんなSQLが実行されているのか。今回はそれを確かめていきます。主役になるのはALTER TABLE、作成済みのテーブルをあとから変更するDDLです。前回はForeignKeyの制約を追加する場面で少しだけ登場しましたが、今回はこれをメインに扱います。

進め方は前回と同じです。モデル定義編で作ったサンプルモデル(User・Profile・Post・Tag・Comment)に対して、フィールドの追加・リネーム・型変更といった変更を実際に加えながら、生成されるSQLをsqlmigrateで読んでいきます。検証環境とサンプルモデルの全文は、モデル定義編を参照してください。

学びたいのはDjangoの機構そのものではなく、その土台にあるSQLです。今回出てくる話の多くはマイグレーションという仕組みに共通の知識で、PrismaやRailsを使うときにも同じように効いてきます。ALTER TABLEをはじめとするDDLを自分で読めるようになるのが今回のゴールです。

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

今回変更するモデル

この記事で変更を加えるのはPostとTagの2つです。モデル定義編を終えた時点の定義に、この記事で加えていく変更を差分で書き込むとこうなります。User・Profile・Commentは今回登場しないので省略します。

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


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,
    )

    subtitle = models.CharField(  
        max_length=200,  
        blank=True,  
    )  

    slug = models.SlugField(
        max_length=200,
    )

    body = models.TextField()  
    content = 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,
        db_index=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",
            )
        ]

記事の中では、この差分を一度に適用するのではなく、次の順で1つずつ加えながら、それぞれがどんなSQLになるのかを確認していきます。

  1. Postにsubtitleフィールドを追加する
  2. Post.bodyをPost.contentにリネームする
  3. Tag.nameのmax_lengthを50から20に縮める
  4. Post.published_atにインデックスを張り、null=Trueを外す

フィールドの追加はALTER TABLE ADD COLUMN

1つ目の変更は、いちばんよくある「フィールドの追加」です。Postにサブタイトルを持たせます。

    title = models.CharField(
        max_length=200,
    )

    subtitle = models.CharField(  
        max_length=200,  
        blank=True,  
    )  

makemigrationsを実行すると、0002_post_subtitle.pyというマイグレーションファイルが生成されました。migrateする前に、sqlmigrateでSQLを確認します。

python manage.py sqlmigrate blog 0002
BEGIN;
--
-- Add field subtitle to post
--
ALTER TABLE "blog_post" ADD COLUMN "subtitle" varchar(200) DEFAULT '' NOT NULL;
ALTER TABLE "blog_post" ALTER COLUMN "subtitle" DROP DEFAULT;
COMMIT;

主役のALTER TABLEが出てきました。前回はForeignKeyの制約追加(ADD CONSTRAINT)で登場しましたが、今回のはADD COLUMN、その名のとおり列の追加です。ALTER TABLEは「作成済みのテーブルをあとから変更する」DDLで、後ろに続けるサブコマンドで何をするかが決まります。この記事で登場する基本形を先に並べておきます1

ALTER TABLE テーブル名 ADD COLUMN 列名 型名 制約;         -- 列の追加
ALTER TABLE テーブル名 DROP COLUMN 列名;                  -- 列の削除
ALTER TABLE テーブル名 RENAME COLUMN 旧列名 TO 新列名;    -- 列名の変更
ALTER TABLE テーブル名 ALTER COLUMN 列名 ...;             -- 列の型や制約の変更

ADD COLUMNの後ろは"subtitle" varchar(200) ... NOT NULLとなっていて、前回学んだCREATE TABLEの中の列定義と同じ「列名・型名・制約」の3点セットです。列を1つ足すだけの変更なので、ALTER TABLEが1文出てくるだけだと予想していたのですが、実際には2文あります。しかも1文目には、models.pyに書いていないDEFAULT ''が付いていて、2文目でわざわざそれを外しています。デフォルト値を付けて、すぐ外す。一見意味のない動きに見えます。

調べてみると、これは既存行のためでした。このテーブルにはすでに行が入っているかもしれません。そこにNOT NULL、つまりNULL禁止の列を足すとしたら、既存行のsubtitleに何かしらの値を入れる必要があります。「値が無いことは許さない列を、値の無い既存行に足す」という矛盾を解消するのがDEFAULT句で、ADD COLUMNにDEFAULTを付けると、既存行はその値で埋められます。これはDjangoではなくRDB側の理屈で、どんなツールからALTER TABLEを流しても同じ問題に突き当たります。

では2文目でDEFAULTを外しているのはなぜか。これは前回の発見がそのまま答えになっていました。Djangoはdefaultの値埋めをDB側に置かず、INSERT時にPython側で埋める設計です。つまりDEFAULT句は既存行を埋めるための一時的な道具としてだけ使い、役目が終わったら即座に取り除いて、以降の新しい行はいつもどおりDjangoが面倒を見る、という整理になっています。

ちなみにこの''という値は、CharFieldの「空」を表す値です。blank=Trueを付けずにフィールドを追加しようとすると、makemigrationsが「既存行に入れる値を決めてほしい」と対話プロンプトで聞いてきます。あのプロンプトの答えがこのDEFAULT句に入る、という対応でした。

フィールドのリネームはRENAME COLUMN

2つ目の変更はフィールドのリネームです。Postの本文はbodyという名前にしていましたが、Comment.bodyと紛らわしいので、contentに変えます。

    body = models.TextField()  
    content = models.TextField()  

makemigrationsを実行すると、今回はファイルが生成される前に確認を求められました。

makemigrations実行時にWas post.body renamed to post.content (a TextField)? [y/N]と確認を求められている画面

「post.bodyはpost.contentにリネームされたものか?」という質問です。yと答えると、0003_rename_body_post_content.pyが生成されます。SQLを見てみます。

python manage.py sqlmigrate blog 0003
BEGIN;
--
-- Rename field body on post to content
--
ALTER TABLE "blog_post" RENAME COLUMN "body" TO "content";
COMMIT;

先ほど押さえた基本形のとおりの、RENAME COLUMN 旧列名 TO 新列名です。変わるのは列の名前という定義だけで、中に入っているデータはそのまま残ります。1文で終わる素直なSQLでした。

気になるのはSQLよりも、makemigrationsが確認を求めてきたことのほうです。フィールドの追加のときは黙ってファイルを作ったのに、なぜリネームでは人間に聞くのか。

理由は、models.pyの差分だけでは判定できないからです。makemigrationsから見えるのは「bodyというフィールドが消えて、contentというフィールドが増えた」という結果だけです。これが1つのフィールドのリネームなのか、それとも古いフィールドの削除と新しいフィールドの追加なのか、差分からは原理的に区別できません。だからDjangoは人間に聞くしかないわけです。

怖いのは、これが削除+追加と判定された場合です。そのときに生成されるSQLはこうなります。

-- リネームではなく削除+追加と判定された場合
ALTER TABLE "blog_post" DROP COLUMN "body";
ALTER TABLE "blog_post" ADD COLUMN "content" text NOT NULL;

このSQLは文法的に正しく、実行してもエラーになりません。テーブルの見た目も、contentという列があるという意味では意図どおりです。しかし中身はまったく違います。DROP COLUMNの時点で、全記事の本文データが消えています。

そしてDROP COLUMNで消えたデータは、SQLをどう書いても戻せません。書き戻そうにも、消えた値を知る手段がないからです。トランザクションの中ならROLLBACKで取り消せますが、COMMITを通過してしまったら、あとはバックアップから復元する以外に手がありません。マイグレーションのミスの中でも、リネームの誤判定が特に危険と言われるのはこのためです。

これはDjangoの出来が悪いという話ではありません。差分からリネームを見分けられないのは、マイグレーション機能を持つツールに共通の限界で、対応の仕方がツールごとに違うだけです。Djangoは対話で聞き、Prismaは削除+追加として生成するので必要なら生成されたマイグレーションを自分で直し、Railsはそもそも自動検出せずrename_columnを手で書きます。どのORMを使っていても、「リネームしたつもりが削除+追加になっていないか」は実行前に確認する価値があります。migrateの前にsqlmigrateでSQLを読む習慣は、まさにここで効いてきます。RENAME COLUMNになっていれば安心、DROP COLUMNが見えたら手を止める、という判断が実行前にできるからです。

型や制約の変更

次は、フィールドそのものではなく型や制約を変える変更です。ここでは2つの変更をまとめて加えます。Tag.nameのmax_lengthを50から20に縮めるのと、Post.published_atにインデックスを張るためのdb_index=Trueです。

class Tag(models.Model):
    name = models.CharField(
        max_length=50,  
        max_length=20,  
        unique=True,
    )
    published_at = models.DateTimeField(
        null=True,
        blank=True,
        db_index=True,  
    )

この状態でmakemigrationsを実行すると、生成されたのは1つのファイル(0004_alter_post_published_at_alter_tag_name.py)でした。makemigrationsは、未反映の変更が複数あれば1つのマイグレーションファイルにまとめます。SQLを見てみます。

python manage.py sqlmigrate blog 0004
BEGIN;
--
-- Alter field published_at on post
--
CREATE INDEX "blog_post_published_at_9524a659" ON "blog_post" ("published_at");
--
-- Alter field name on tag
--
ALTER TABLE "blog_tag" ALTER COLUMN "name" TYPE varchar(20);
COMMIT;

2文とも初登場です。1文目のCREATE INDEXは次の節に回して、まず2文目から読みます。

基本形の4つ目、ALTER COLUMNです。後ろにTYPE varchar(20)と続けると列の型を変更できます。max_lengthの変更はDjangoから見ればフィールドの属性変更ですが、SQLから見ると「varchar(50)からvarchar(20)への型変更」でした。

型を縮める変更は失敗することがある

このSQL、見た目は1文で穏やかですが、max_lengthを増やす変更とは危険度が違います。varchar(20)にした後のテーブルには20文字までの値しか入りませんが、varchar(50)だったこれまでのテーブルには、20文字を超える値がすでに入っているかもしれないからです。

実際に試してみます。psqlで20文字を超えるタグを入れてから、同じALTER TABLEを流してみました。

INSERT INTO blog_tag (name) VALUES ('database-performance-tuning');  -- 27文字
ALTER TABLE blog_tag ALTER COLUMN name TYPE varchar(20);
ERROR:  value too long for type character varying(20)

エラーになりました。既存データが新しい型に収まらない場合、ALTER TABLEはその場で失敗します。migrateで流しても同じで、このエラーでマイグレーション全体が止まります。マイグレーションはBEGINとCOMMITで囲まれているので、同じファイルに入っていたCREATE INDEXまで巻き戻り、中途半端には適用されません。前回学んだトランザクションが、ここで効いています。

これは型の縮小に限らず、「既存データが違反しうる変更」すべてに共通する構図です。あとで出てくるNOT NULL制約の追加も同じで、NULLの行が1行でもあれば失敗します。順序としては、先にデータを直してから、型や制約を変える。この話は次の章で扱います。

もうひとつ、PostgreSQLで型変更をするときに知っておきたいのがUSING句です。varchar同士のような近い型ならそのまま変換されますが、たとえばvarcharの列をintegerに変えるような場合、既存の値をどう変換するかをDBは自動では決められません。ALTER COLUMN 列名 TYPE integer USING 列名::integerのように、変換方法をUSING句で明示します1。Djangoのmakemigrationsはここまでは面倒を見てくれないので、こうした型変更ではマイグレーションに手を入れることになります。

インデックスの追加はCREATE INDEX

後回しにしていた0004の1文目に戻ります。

CREATE INDEX "blog_post_published_at_9524a659" ON "blog_post" ("published_at");

db_index=Trueの実体はCREATE INDEXでした。構文はCREATE INDEX インデックス名 ON テーブル名 (列名)で、インデックス名はForeignKeyの制約名と同じく「テーブル名_列名_ハッシュ」の形でDjangoが自動生成しています。ALTER TABLEではなく独立した命令なのは、インデックスがテーブル定義の一部ではなく、テーブルに付随する別のオブジェクトだからです。

インデックスは、特定の列で検索しやすいように値を整列させた索引データです。本の索引と同じで、published_atで絞り込むクエリが来たとき、テーブルの全行を順に見る代わりに索引から目的の行へ直接たどり着けるようになります。「公開日時の新しい順に記事を並べる」といったクエリを見越しての追加です。代わりに、書き込みのたびに索引の更新コストがかかるので、何にでも張ればよいものではありません。インデックスの中身と効き方はパフォーマンスチューニング編で正面から扱うので、ここでは「db_index=TrueはCREATE INDEXになる」まで押さえて先に進みます。

ちなみに外すときは、これも独立した命令のDROP INDEXです。列の追加とDROP COLUMNの関係と同じで、作る命令と対になる壊す命令がいる、という対称性はSQL全体で一貫しています。

制約を変える前にデータを直す: UPDATE文

最後は前章の宿題、「先にデータを直してから、型や制約を変える」の話です。この章は手を動かさず、知識として押さえておきます。

published_atのnull=Trueを外したい、という場面を考えます。NOT NULL制約を足すマイグレーションは、published_atがNULLの行が残っていると失敗します。なので制約を変える前に、NULLの行を何かの値で埋めておく必要があります。この「埋める」がデータの書き換えで、SQLではUPDATE文の仕事です。基本形はこうです2

UPDATE テーブル名 SET 列名 =WHERE 条件;

WHEREで対象の行を絞り、SETで値を書き換えます。今回書きたいのは、NULLだったpublished_atをとりあえず作成日時で埋める、という1文です。

UPDATE blog_post SET published_at = created_at WHERE published_at IS NULL;

SETの値には、固定値だけでなく同じ行の別の列も使えます。もうひとつ、WHEREが= NULLではなくIS NULLになっているのがSQLらしいところです。SQLのNULLは「値が無い」という状態であって値ではないので、=で比較しても真になりません。NULLかどうかの判定には専用のIS NULL / IS NOT NULLを使います。

そしてこのUPDATEは、psqlで直接実行するのではなく、マイグレーションの1本として書けます。DjangoならRunSQLという仕組みで、マイグレーションファイルにSQLをそのまま書いておくと、migrate時に実行されます。

operations = [
    migrations.RunSQL(
        "UPDATE blog_post SET published_at = created_at WHERE published_at IS NULL;"
    ),
]

これを挟んでからnull=Trueを外すマイグレーションを続ければ、NULLの行はもういないので、NOT NULL制約の追加は安全に通ります。「データを直すUPDATE→制約を変えるALTER TABLE」という順序を、マイグレーションの並びとして残せるわけです。

まとめ

モデルの変更がどんなSQLになるのかを、1つずつ確かめてきました。今回登場したSQLを並べ直します。

  • ALTER TABLEの基本形4つ。列を足すADD COLUMN、消すDROP COLUMN、名前を変えるRENAME COLUMN、型や制約を変えるALTER COLUMN
  • インデックスを作るCREATE INDEX。テーブル定義とは別のオブジェクトなので、ALTER TABLEではなく独立した命令でした
  • データを書き換えるUPDATE。SETで値を、WHEREで対象の行を指定し、NULLの判定はIS NULLで書くのでした

そして今回の収穫は、個々の構文より「変更のSQLには順序と不可逆性がある」という感覚だと思っています。

  • リネームと削除+追加は、差分からは原理的に区別できない。誤判定されるとDROP COLUMNが走り、消えたデータはSQLをどう書いても戻せない
  • 型を縮める・NOT NULLを足すといった変更は、既存データが違反していると失敗する。先にUPDATEでデータを直してから、型や制約を変えるという順序で組む

どれもDjangoの知識ではなくSQL側の性質なので、他のORMに移ってもそのまま通用するはずです。

実践的な結論は、モデル定義編から変わらず「migrateの前にsqlmigrateで読む」です。リネームがDROP COLUMNになっていないか、既存データと衝突する変更が混ざっていないかは、実行前のSQLを読めば見えます。相当する機能は他のORMにもあるので(Prismaならprisma migrate diff)、どこへ行っても使える習慣だと思います。

次回はクエリ編です。テーブルを作れるようになり、変えられるようになったので、いよいよfilterやupdateの裏で走るSELECTやUPDATEといったクエリそのものを学びます。

参考文献

脚注

  1. https://www.postgresql.jp/docs/17/sql-altertable.html 2

  2. https://www.postgresql.jp/docs/17/sql-update.html

新着記事

関連ネットワーク(beta)

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