Django の migration ファイルを squash してスカッとしよう！

Djangoに限りませんが、Webアプリケーションを継続的に開発をしていくとデータベースのテーブル定義の変更が幾度となく起こり、migrationファイルが増えてきます。
migrationファイルが増えてくると、テスト実行用のデータベースを作るときに時間がかかるようになり `$ python manage.py test` を打ってからテストが実行されるまで暫く待つ必要がでてきます。

ちなみに [scout.lapras.com](https://scout.lapras.com/) のアプリケーションは 459 migrationファイルあり、テスト実行までに2分10秒かかります。(Docker for Macの上で動かしているのでそれがいくらかオーバーヘッドになっていますが)

Djangoには `squashmigrations` というコマンドがあり、migrationsファイルを1ファイルにまとめることができます。その際に賢くまとめてくれるので、例えば同じカラムに対する複数回の変更があっても、migrationファイルに記載されるのは最終的なカラムの情報1つだけになります。それにより`ALTER TABLE`文の発行回数が減り migrate を高速化することに繋がります。

公式ドキュメント: https://docs.djangoproject.com/en/2.2/topics/migrations/#squashing-migrations


## シンプルなアプリケーションで計測してみた

本当はこの記事を書くまでに↑のプロダクトのmigrationsをsquashしてこれだけ早くなったよ！をやりたかったのですが残念ながら間に合わなかったので、シンプルなアプリケーションを用意して計測してみました。

適当なBookモデルを用意して

```python
from django.db import models

class Book(models.Model):
    name = models.CharField(max_length=128)
    author = models.CharField(max_length=128)
    price = models.IntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
```

これに対して

```python
price = models.IntegerField(default=0)
```
のカラムの追加削除を繰り返すだけのmigrationファイルを50用意しました。(DBはsqlite3です)

```bash
.
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│   ├── 0001_initial.py
│   ├── 0002_remove_book_price.py
│   ├── 0003_book_price.py
│   ├── 0004_remove_book_price.py
│   ├── 0005_book_price.py
│   ├── 0006_remove_book_price.py
│   ├── 0007_book_price.py
│   ├── 0008_remove_book_price.py
│   ├── 0009_book_price.py
│   ├── 0010_remove_book_price.py
│   ├── 0011_book_price.py
│   ├── 0012_remove_book_price.py
│   ├── 0013_book_price.py
│   ├── 0014_remove_book_price.py
│   ├── 0015_book_price.py
│   ├── 0016_remove_book_price.py
│   ├── 0017_book_price.py
│   ├── 0018_remove_book_price.py
│   ├── 0019_book_price.py
│   ├── 0020_remove_book_price.py
│   ├── 0021_book_price.py
│   ├── 0022_remove_book_price.py
│   ├── 0023_book_price.py
│   ├── 0024_remove_book_price.py
│   ├── 0025_book_price.py
│   ├── 0026_remove_book_price.py
│   ├── 0027_book_price.py
│   ├── 0028_remove_book_price.py
│   ├── 0029_book_price.py
│   ├── 0030_remove_book_price.py
│   ├── 0031_book_price.py
│   ├── 0032_remove_book_price.py
│   ├── 0033_book_price.py
│   ├── 0034_remove_book_price.py
│   ├── 0035_book_price.py
│   ├── 0036_remove_book_price.py
│   ├── 0037_book_price.py
│   ├── 0038_remove_book_price.py
│   ├── 0039_book_price.py
│   ├── 0040_remove_book_price.py
│   ├── 0041_book_price.py
│   ├── 0042_remove_book_price.py
│   ├── 0043_book_price.py
│   ├── 0044_remove_book_price.py
│   ├── 0045_book_price.py
│   ├── 0046_remove_book_price.py
│   ├── 0047_book_price.py
│   ├── 0048_remove_book_price.py
│   ├── 0049_book_price.py
│   ├── 0050_remove_book_price.py
│   └── __init__.py
├── models.py
└── tests.py
```

これに対してテストを実行すると…

```bash
$ time python ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
Destroying test database for alias 'default'...
python ./manage.py test  0.73s user 0.20s system 73% cpu 1.261 total
```

なんと0.73秒で終わってしまいました。。。
僕の想像ではこれで少なくとも5秒ぐらいかかって、squashすると0.5秒ぐらいになる想定だったのですが、、、
先行きが怪しくなります。。

squashしてみます。

```bash
$ python manage.py squashmigrations squash_test 0050_remove_book_price
Will squash the following migrations:
 - 0001_initial
 - 0002_remove_book_price
 - 0003_book_price
(略)
 - 0050_remove_book_price
Do you wish to proceed? [yN] y

Optimizing...
  Optimized from 50 operations to 1 operations.
Created new squashed migration /django-squash-test/app/squash_test/migrations/0001_squashed_0050_remove_book_price.py
  You should commit this migration but leave the old ones in place;
  the new migration will be used for new installs. Once you are sure
  all instances of the codebase have applied the migrations you squashed,
  you can delete them.
```

これですべてが詰まった1つのmigrationファイルができました。
前後を比較すると

**before**

```python:0001_initial.py
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 12:13
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Book',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=128)),
                ('author', models.CharField(max_length=128)),
                ('price', models.IntegerField()),
                ('created_at', models.DateTimeField(auto_now_add=True)),
            ],
        ),
    ]
```

```python:0002_remove_book_price.py
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 12:14
from __future__ import unicode_literals

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('squash_test', '0001_initial'),
    ]

    operations = [
        migrations.RemoveField(
            model_name='book',
            name='price',
        ),
    ]
```

```python:0003_book_price.py
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 12:14
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('squash_test', '0002_remove_book_price'),
    ]

    operations = [
        migrations.AddField(
            model_name='book',
            name='price',
            field=models.IntegerField(default=0),
            preserve_default=False,
        ),
    ]
```

(以下、2と3と同じファイルの繰り返し)

が

**after**

```0001_squashed_0050_remove_book_price.py
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 13:53
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

    replaces = [('squash_test', '0001_initial'), ('squash_test', '0002_remove_book_price'), ('squash_test', '0003_book_price'), ('squash_test', '0004_remove_book_price'), ('squash_test', '0005_book_price'), ('squash_test', '0006_remove_book_price'), ('squash_test', '0007_book_price'), ('squash_test', '0008_remove_book_price'), ('squash_test', '0009_book_price'), ('squash_test', '0010_remove_book_price'), ('squash_test', '0011_book_price'), ('squash_test', '0012_remove_book_price'), ('squash_test', '0013_book_price'), ('squash_test', '0014_remove_book_price'), ('squash_test', '0015_book_price'), ('squash_test', '0016_remove_book_price'), ('squash_test', '0017_book_price'), ('squash_test', '0018_remove_book_price'), ('squash_test', '0019_book_price'), ('squash_test', '0020_remove_book_price'), ('squash_test', '0021_book_price'), ('squash_test', '0022_remove_book_price'), ('squash_test', '0023_book_price'), ('squash_test', '0024_remove_book_price'), ('squash_test', '0025_book_price'), ('squash_test', '0026_remove_book_price'), ('squash_test', '0027_book_price'), ('squash_test', '0028_remove_book_price'), ('squash_test', '0029_book_price'), ('squash_test', '0030_remove_book_price'), ('squash_test', '0031_book_price'), ('squash_test', '0032_remove_book_price'), ('squash_test', '0033_book_price'), ('squash_test', '0034_remove_book_price'), ('squash_test', '0035_book_price'), ('squash_test', '0036_remove_book_price'), ('squash_test', '0037_book_price'), ('squash_test', '0038_remove_book_price'), ('squash_test', '0039_book_price'), ('squash_test', '0040_remove_book_price'), ('squash_test', '0041_book_price'), ('squash_test', '0042_remove_book_price'), ('squash_test', '0043_book_price'), ('squash_test', '0044_remove_book_price'), ('squash_test', '0045_book_price'), ('squash_test', '0046_remove_book_price'), ('squash_test', '0047_book_price'), ('squash_test', '0048_remove_book_price'), ('squash_test', '0049_book_price'), ('squash_test', '0050_remove_book_price')]

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Book',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=128)),
                ('author', models.CharField(max_length=128)),
                ('created_at', models.DateTimeField(auto_now_add=True)),
            ],
        ),
    ]

```

になりました。
実行時間は

```bash
$ time python ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
Destroying test database for alias 'default'...
python ./manage.py test  0.52s user 0.09s system 98% cpu 0.611 total
```

0.52秒で、50ファイルあったときの71%になりました！ (微妙ですが減ってくれてよかった…)

## squash された migration ファイルの解説

公式ドキュメント https://docs.djangoproject.com/en/2.2/topics/migrations/#squashing-migrations にも書かれているので、より詳細な情報は原文を読んで頂くのがよいですが、簡単に説明します。

`operations` の部分は見慣れた構文と同じですが、普段のmigrationファイルでは見ない `replaces` という変数が新たに定義されています。
これはどのmigrationsファイルたちをまとめてこのmigrationファイルを作ったかという情報になります。

注意点として、squashされたmigrationファイルができたからといってsquash元のmigrationファイルたちをすぐには削除できません。すでに部分的にmigrationが当たっている環境、 例えば、`0010_remove_book_price` までは実行済みの環境があった場合、残りの `0011_book_price` から `0050_remove_book_price` までを当てる必要があり、この場合にはsquashしたmigrationファイルだけでは対応できないためです。

`replaces` の中に含まれているmigrationファイルすべてが未適用な環境であれば個別のmigrationファイルの代わりにsquashされたmigrationファイルが使用されより高速にmigrateすることができます。

## 自動でsquashできないケース

Djangoでは複数アプリケーションを一つのプロジェクトとして管理できます。
大規模なDjangoアプリケーションではドメインごとにアプリケーションを分けて、数十のアプリケーションを1プロジェクトで持つような形がベストプラクティスの一つとしてあります。

このような場合にはアプリケーションを跨いで外部キー制約をつけることが多々発生し、migrationファイル同士の依存関係もかなり複雑になります。
必ずその依存先のmigrationファイルの後でなければ動かないというわけではないのに、djangoが自動で張った依存関係の影響でスマートにsquashできない場合があります。

文字だと難しいので例を出すと
1. app1.Book 作成: `0001_app1`
2. app2.Category 作成: `0001_app2`
3. app1.Magazine 作成: `0002_app1` (`0001_app1` に依存)
4. app2.Category のカラム変更: `0002_app2` (`0001_app2` に依存)
5. app1.Book から Category へのF-Key追加: `0003_app1` (`0002_app1` と `0002_app2` に依存)

という状況でsquashすることを考えた場合に、`squashmigrations` をすると `0001_app1` と `0002_app1`、`0001_app2` と `0002_app2` がまとめられ、`0003_app1` はそのまま残り合計3ファイルになります。

ただこれは最適解ではなくて、人間が考えれば `0001_app2` と `0002_app2` をまとめて、`0001_app1` と `0002_app2` と `0003_app1` をまとめて、後者が前者に依存することで合計2ファイルにまとめられることがわかります。

そして大規模なアプリケーションになると往々にしてこのような問題が起き、自動で `squashmigrations` するだけではあまりmigrationファイルが減らず、人間が頭を捻りながら微調整をしていく必要が出てきてしまいます。
微量な高速化のために人間がどれだけコストを払うのかはよく考えましょう。

## 最後に

僕は高速化が趣味なのでsquashしてこれぐらい早くなった〜！と速度比較をするのが大好きでsquashしたあとはスカッとします。
ちなみに、最近はスポーツのスカッシュにもハマっていて、来週末には初めて大会に出場することになりました。そこでも勝利してスカッとできるといいなと思っています。