🦾

【深く】DjangoのMigrationを理解する

2024/02/12に公開

Djangoがどのようにマイグレーションを追跡するか

既にマイグレーションを適用している状態で再度、migrateコマンドを実行すると以下のようになりますね。

python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  No migrations to apply.

『適用するマイグレーションはありません。』と返ってきています。
一度適用されたマイグレーションは、同じデータベースに対してDjangoが再度適用することはありません。マイグレーションが一度だけ適用されるようにするためには、どのマイグレーションが適用されたかを追跡する必要があります。

Djangoはdjango_migrationsという名前のテーブルを利用してこの追跡を行います。
マイグレーションを初めて適用すると、Djangoは自動的にこのテーブルをあなたのデータベースに作成します。

django_migrationsのレコードを見てみましょう。

sqlite> select * from django_migrations;
1|contenttypes|0001_initial|2023-10-05 13:03:21.812094
2|auth|0001_initial|2023-10-05 13:03:21.827085
3|admin|0001_initial|2023-10-05 13:03:21.833784
4|admin|0002_logentry_remove_auto_add|2023-10-05 13:03:21.851418
5|admin|0003_logentry_add_action_flag_choices|2023-10-05 13:03:21.862233
6|contenttypes|0002_remove_content_type_name|2023-10-05 13:03:21.890687
7|auth|0002_alter_permission_name_max_length|2023-10-05 13:03:21.903067
8|auth|0003_alter_user_email_max_length|2023-10-05 13:03:21.912475
9|auth|0004_alter_user_username_opts|2023-10-05 13:03:21.918880
10|auth|0005_alter_user_last_login_null|2023-10-05 13:03:21.928425

このようにマイグレーションしたアプリ、そのマイグレーションファイル名、マイグレーション適用時間が順番に記録されています。
これによって次にマイグレーションを実行する際に、Djangoはdjango_migrationsテーブルに記録されているマイグレーションをスキップします。

showmigrationsの仕組み

ここまででDjangoのマイグレーションにはdjango_migrationsというテーブルが管理してくれているといった話をしてきました。
それではshowmigrationsはどのようにして、マイグレーション済みのものにチェックを入れて、マイグレーションしていないもの、すなわち新規にmakemigrationsしたものにはチェックをつけないといったように判別しているのでしょうか?
ここにもdjango_migrationsテーブルが当然関わってきます。
それではどのようにして判別しているのか順に説明します。

  1. マイグレーションファイルの検出
    Djangoは各アプリケーションのmigrationsディレクトリを走査し、そこに存在するマイグレーションファイル(0001_initial.pyなど)を検出します。

  2. django_migrationsテーブルの参照
    Djangoはデータベース内のdjango_migrationsテーブルを参照します。

  3. マイグレーションの状態の比較
    ステップ1で検出したマイグレーションファイルと、ステップ2で参照したdjango_migrationsテーブル内の記録を比較します。ファイルがdjango_migrationsテーブルに記録されていれば、そのマイグレーションは適用されたとみなされます。

  4. 出力の生成
    適用されたマイグレーションにはチェックマーク(例: [X])が付けられます。
    まだ適用されていないマイグレーションには、チェックマークが付けられずに表示されます(例: [ ])。

上記のようなプロセスにより、showmigrationsコマンドは、各マイグレーションの状態を視覚的に表示することができているわけです。

実際に確認してみると分かりやすいです。
モデルに変更を加えて、makemigrationしてみます。

python manage.py makemigrations
Migrations for 'blog':
  blog/migrations/0003_alter_post_title.py
    - Alter field title on post

次にshowmigrationすると以下のようになっていることが確認できました。

python manage.py showmigrations   
blog
 [X] 0001_initial
 [X] 0002_comment
 [ ] 0003_alter_post_title

これはdjango_migrationsには0001と0002の履歴があるが、0003は履歴がないがmigrationsディレクトリを探し回って検出してきたことを示しています。

本来ならここでmigrateを実行するのですが、migrateせずにdjango_migrationsテーブルにSQLでこのデータを流してみます。

python manage.py dbshell
INSERT INTO django_migrations (app, name, applied) VALUES ('blog', '0003_alter_post_title', datetime('now'));

django_migrationsにmigrateを実際にはしていないけれどレコードを作成しました。
この後にshowmigrationすると、以下のようにチェックがついていることが分かります。

blog
 [X] 0001_initial
 [X] 0002_comment
 [X] 0003_alter_post_title

showmigrationがdjango_migrationsを確認していることが分かりますね。

migrateする際にもdjango_migrationsを参照しているので、このときmigrateコマンドを実行しても、既にマイグレーションされていることになってしまいます。

Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  No migrations to apply.

makemigrationsの仕組み

以下の手順でモデルの変更を検知して、マイグレーションファイルを作成します。

  1. 適用済みマイグレーションの読み込み
    Djangoはmakemigrationsコマンドが実行されると、すべての適用済みマイグレーションを読み込みます。このプロセスで最後のマイグレーションが適用された後の、プロジェクト内の全モデルの理論上の状態を把握します。

※ここでもdjango_migrationsにアクセスし、マイグレーションが正しく適用されているかの確認を行なっている

  1. 現在のモデル定義の読み込み
    次に、Djangoはプロジェクト内の全アプリケーションのmodels.pyファイル(およびモデル定義が含まれるその他のファイル)を走査して、現在のモデル定義を読み込みます。この時点でのモデル定義は、開発者が最後に適用したマイグレーション以降に行われた変更を含んでいます。

  2. 変更の検出
    上記の2つのステップで取得した情報を比較します。具体的には、最終的なプロジェクト状態(適用済みマイグレーションによって形成された)と、現在のモデル定義を比較して、違いを検出します。変更が検出されると、makemigrationsはそれらの変更を表すマイグレーションファイルを生成します。生成されたマイグレーションファイルは、対応するアプリケーションのmigrationsディレクトリに保存されます。

実際にmigrate時のコードを追ってみる

マイグレーション実施前にチェックします
django/django/core/management/commands/migrate.py

       # Work out which apps have migrations and which do not
        executor = MigrationExecutor(connection, self.migration_progress_callback)

        # Raise an error if any migrations are applied before their dependencies.
        executor.loader.check_consistent_history(connection)

django/db/migrations/executor.py

class MigrationExecutor:
    """
    End-to-end migration execution - load migrations and run them up or down
    to a specified set of targets.
    """

    def __init__(self, connection, progress_callback=None):
        self.connection = connection
        self.loader = MigrationLoader(self.connection)
        self.recorder = MigrationRecorder(self.connection)
        self.progress_callback = progress_callback
# MigrationLoaderとMigrationRecorderのインスタンスが作成
## MigrationLoader: プロジェクト内のマイグレーションファイルを検出する
## MigrationRecorder: django_migrationsテーブルからマイグレーション情報を照会する

django/db/migrations/loader.py

    def check_consistent_history(self, connection):
        """
        Raise InconsistentMigrationHistory if any applied migrations have
        unapplied dependencies.
        """
        recorder = MigrationRecorder(connection)
        applied = recorder.applied_migrations()
        for migration in applied:
            # If the migration is unknown, skip it.
            if migration not in self.graph.nodes:
                continue
            for parent in self.graph.node_map[migration].parents:
                if parent not in applied:
                    # Skip unapplied squashed migrations that have all of their
                    # `replaces` applied.
                    if parent in self.replacements:
                        if all(
                            m in applied for m in self.replacements[parent].replaces
                        ):
                            continue
                    raise InconsistentMigrationHistory(
                        "Migration {}.{} is applied before its dependency "
                        "{}.{} on database '{}'.".format(
                            migration[0],
                            migration[1],
                            parent[0],
                            parent[1],
                            connection.alias,
                        )
                    )
# django_migrationsから適用済みマイグレーションを取得
# それぞれのマイグレーションが依存している他のマイグレーションが全て適用されているか、マイグレーションに不整合がないかを検査
# 検出した場合にエラーを発生させる

例えば、あるマイグレーションBがマイグレーションAに依存しているとします。この時、BはAが適用された後にのみ適用することができます。もしBだけが適用されてAが適用されていない状態が発見された場合、これはマイグレーションの依存関係が一貫していないということになり、エラーが発生します。

マイグレ=ションの計画を作成し、実行します

plan = executor.migration_plan(targets)
post_migrate_state = executor.migrate(
    targets, plan=plan, state=pre_migrate_state.clone(), fake=fake, fake_initial=fake_initial,
)

migration_planは以下のようにして、マイグレーションする対象を絞っています。(計画)
例えばアプリblogには、4つのマイグレーションファイルがあります

 [X] 0001_initial
 [X] 0002_comment
 [ ] 0003_alter_field
 [ ] 0004_remove_field

現在、0001_initialと0002_add_fieldが適用済みです。
目標は0004_remove_fieldまで全てのマイグレーションを適用することです。(python manage.py migrate blogを指定したと仮定)

applied = dict(self.loader.applied_migrations)
MigrationLoaderは、全マイグレーションとそれらの依存関係を読み込みます。MigrationRecorderを用いてdjango_migrationsテーブルから適用済みマイグレーション(この例では0001_initialと0002_add_field)を識別します。

self.loader.build_graph()
MigrationLoaderが構築した依存関係グラフを用いて、0003_alter_fieldと0004_remove_fieldが未適用であること、およびそれらが適用可能(依存するマイグレーションが全て適用済み)であることを確認し、
0003_alter_fieldと0004_remove_fieldをマイグレーション対象(migration_plan)とするわけです。

生成された計画に従い、MigrationExecutorは以下のステップでマイグレーションを適用します:

0003_alter_fieldがデータベースに適用され、適用済みマイグレーションとしてdjango_migrationsテーブルに記録されます。
次に、0004_remove_fieldが適用され、同様に記録されます。

Discussion