2018-12-06

Django マイグレーション完全に理解した (ケーススタディ編) 🍎

今回は 基礎編 に引き続き、ケーススタディ編をお送りします。

この記事は BeProud Advent Calendar 2018 7日目の記事ということにします。 https://adventar.org/calendars/3338

公開日を分けるのがめんどくさいので 12/6 の基礎編に合わせて公開してます。事故ではありません 🙆‍

この 記事では 軽い応用から 基礎編だけではカバーしきれなかった少し特殊なケースを取り上げます。

少しだけ長いので、必要な部分だけ参照すれば良いと思います 👻

サンプルコードを以下においたので見てくれやで。zipはもうない。(Next移行で死にました) https://github.com/righ/django-migration-case-studies

🤔 既存テーブルがある状態で初期マイグレーションをする

  • 📁django-migration-case-studies
  • 📁apps-existing-table
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒db.sqlite3
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
  • 📁sales
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
0001_initial.py
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
            ],
        ),
        migrations.CreateModel(
            name='Price',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('price', models.IntegerField()),
                ('effective_date_start', models.DateTimeField(null=True)),
                ('effective_date_end', models.DateTimeField(null=True)),
            ],
        ),
        migrations.CreateModel(
            name='Product',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('deleted_at', models.DateTimeField(default=django.utils.timezone.now, null=True)),
                ('category', models.ForeignKey(db_column='category_id', on_delete=django.db.models.deletion.CASCADE, to='products.Category')),
            ],
        ),
        migrations.AddField(
            model_name='price',
            name='product',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.Product'),
        ),
    ]

  • 1.7 未満の Django から アップグレードした
  • Django 以外のフレームワークから移行してきた

など様々な要因でテーブルの有無とマイグレーションの不整合は発生します。

ここでは、Django 1.6 (マイグレーションがないバージョン) 以前から 2.1 へ移行してきたという気持ちでやってみたいと思います。

  • 1.6で初期化
  • スキーマ
  • $ ./manage.py syncdb Creating tables ... Creating table django_admin_log Creating table auth_permission Creating table auth_group_permissions Creating table auth_group Creating table auth_user_groups Creating table auth_user_user_permissions Creating table auth_user Creating table django_content_type Creating table django_session Creating table products_category Creating table products_product Creating table products_price Creating table sales_sales Creating table sales_summary You just installed Django's auth system, which means you don't have any superusers defined. Would you like to create one now? (yes/no): no Installing custom SQL ... Installing indexes ... Installed 0 object(s) from 0 fixture(s)
  • sqlite> .schema CREATE TABLE IF NOT EXISTS "django_admin_log" ( "id" integer NOT NULL PRIMARY KEY, "action_time" datetime NOT NULL, "user_id" integer NOT NULL, "content_type_id" integer, "object_id" text, "object_repr" varchar(200) NOT NULL, "action_flag" smallint unsigned NOT NULL, "change_message" text NOT NULL ); CREATE TABLE IF NOT EXISTS "auth_permission" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(50) NOT NULL, "content_type_id" integer NOT NULL, "codename" varchar(100) NOT NULL, UNIQUE ("content_type_id", "codename") ); CREATE TABLE IF NOT EXISTS "auth_group_permissions" ( "id" integer NOT NULL PRIMARY KEY, "group_id" integer NOT NULL, "permission_id" integer NOT NULL REFERENCES "auth_permission" ("id"), UNIQUE ("group_id", "permission_id") ); CREATE TABLE IF NOT EXISTS "auth_group" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(80) NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS "auth_user_groups" ( "id" integer NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "group_id" integer NOT NULL REFERENCES "auth_group" ("id"), UNIQUE ("user_id", "group_id") ); CREATE TABLE IF NOT EXISTS "auth_user_user_permissions" ( "id" integer NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "permission_id" integer NOT NULL REFERENCES "auth_permission" ("id"), UNIQUE ("user_id", "permission_id") ); CREATE TABLE IF NOT EXISTS "auth_user" ( "id" integer NOT NULL PRIMARY KEY, "password" varchar(128) NOT NULL, "last_login" datetime NOT NULL, "is_superuser" bool NOT NULL, "username" varchar(30) NOT NULL UNIQUE, "first_name" varchar(30) NOT NULL, "last_name" varchar(30) NOT NULL, "email" varchar(75) NOT NULL, "is_staff" bool NOT NULL, "is_active" bool NOT NULL, "date_joined" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "django_content_type" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(100) NOT NULL, "app_label" varchar(100) NOT NULL, "model" varchar(100) NOT NULL, UNIQUE ("app_label", "model") ); CREATE TABLE IF NOT EXISTS "django_session" ( "session_key" varchar(40) NOT NULL PRIMARY KEY, "session_data" text NOT NULL, "expire_date" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "products_category" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(30) NOT NULL, "created_at" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "products_product" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL, "category_id" integer NOT NULL REFERENCES "products_category" ("id"), "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "deleted_at" datetime ); CREATE TABLE IF NOT EXISTS "products_price" ( "id" integer NOT NULL PRIMARY KEY, "price" integer NOT NULL, "product_id" integer NOT NULL REFERENCES "products_product" ("id"), "effective_date_start" datetime, "effective_date_end" datetime ); CREATE TABLE IF NOT EXISTS "sales_sales" ( "id" integer NOT NULL PRIMARY KEY, "product_id" integer NOT NULL REFERENCES "products_product" ("id"), "sold_at" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "sales_summary" ( "id" integer NOT NULL PRIMARY KEY, "date" date NOT NULL, "total_sales" integer NOT NULL, "total_price" integer NOT NULL, "unique_user" integer NOT NULL ); CREATE INDEX "django_admin_log_6340c63c" ON "django_admin_log" ("user_id"); CREATE INDEX "django_admin_log_37ef4eb4" ON "django_admin_log" ("content_type_id"); CREATE INDEX "auth_permission_37ef4eb4" ON "auth_permission" ("content_type_id"); CREATE INDEX "auth_group_permissions_5f412f9a" ON "auth_group_permissions" ("group_id"); CREATE INDEX "auth_group_permissions_83d7f98b" ON "auth_group_permissions" ("permission_id"); CREATE INDEX "auth_user_groups_6340c63c" ON "auth_user_groups" ("user_id"); CREATE INDEX "auth_user_groups_5f412f9a" ON "auth_user_groups" ("group_id"); CREATE INDEX "auth_user_user_permissions_6340c63c" ON "auth_user_user_permissions" ("user_id"); CREATE INDEX "auth_user_user_permissions_83d7f98b" ON "auth_user_user_permissions" ("permission_id"); CREATE INDEX "django_session_b7b81f0c" ON "django_session" ("expire_date"); CREATE INDEX "products_product_6f33f001" ON "products_product" ("category_id"); CREATE INDEX "products_price_7f1b40ad" ON "products_price" ("product_id"); CREATE INDEX "sales_sales_7f1b40ad" ON "sales_sales" ("product_id");

2.1 に移行してきたのでマイグレーションを作って適用してみます。

  • ./manage.py makemigrations products sales
  • ./manage.py migrate
  • $ ./manage.py makemigrations products sales Migrations for 'products': products/migrations/0001_initial.py - Create model Category - Create model Price - Create model Product - Add field product to price Migrations for 'sales': sales/migrations/0001_initial.py - Create model Sales - Create model Summary
  • $ ./manage.py migrate Operations to perform: Apply all migrations: admin, auth, contenttypes, products, sales, sessions Running migrations: Applying contenttypes.0001_initial...Traceback (most recent call last): File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 83, in _execute return self.cursor.execute(sql) File "/venv/lib/python3.7/site-packages/django/db/backends/sqlite3/base.py", line 294, in execute return Database.Cursor.execute(self, query) sqlite3.OperationalError: table "django_content_type" already exists The above exception was the direct cause of the following exception: Traceback (most recent call last): File "./manage.py", line 10, in <module> execute_from_command_line(sys.argv) File "/venv/lib/python3.7/site-packages/django/core/management/__init__.py", line 381, in execute_from_command_line utility.execute() File "/venv/lib/python3.7/site-packages/django/core/management/__init__.py", line 375, in execute self.fetch_command(subcommand).run_from_argv(self.argv) File "/venv/lib/python3.7/site-packages/django/core/management/base.py", line 316, in run_from_argv self.execute(*args, **cmd_options) File "/venv/lib/python3.7/site-packages/django/core/management/base.py", line 353, in execute output = self.handle(*args, **options) File "/venv/lib/python3.7/site-packages/django/core/management/base.py", line 83, in wrapped res = handle_func(*args, **kwargs) File "/venv/lib/python3.7/site-packages/django/core/management/commands/migrate.py", line 203, in handle fake_initial=fake_initial, File "/venv/lib/python3.7/site-packages/django/db/migrations/executor.py", line 117, in migrate state = self._migrate_all_forwards(state, plan, full_plan, fake=fake, fake_initial=fake_initial) File "/venv/lib/python3.7/site-packages/django/db/migrations/executor.py", line 147, in _migrate_all_forwards state = self.apply_migration(state, migration, fake=fake, fake_initial=fake_initial) File "/venv/lib/python3.7/site-packages/django/db/migrations/executor.py", line 244, in apply_migration state = migration.apply(state, schema_editor) File "/venv/lib/python3.7/site-packages/django/db/migrations/migration.py", line 124, in apply operation.database_forwards(self.app_label, schema_editor, old_state, project_state) File "/venv/lib/python3.7/site-packages/django/db/migrations/operations/models.py", line 91, in database_forwards schema_editor.create_model(model) File "/venv/lib/python3.7/site-packages/django/db/backends/base/schema.py", line 312, in create_model self.execute(sql, params or None) File "/venv/lib/python3.7/site-packages/django/db/backends/base/schema.py", line 133, in execute cursor.execute(sql, params) File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 100, in execute return super().execute(sql, params) File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 68, in execute return self._execute_with_wrappers(sql, params, many=False, executor=self._execute) File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 77, in _execute_with_wrappers return executor(sql, params, many, context) File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 85, in _execute return self.cursor.execute(sql, params) File "/venv/lib/python3.7/site-packages/django/db/utils.py", line 89, in __exit__ raise dj_exc_value.with_traceback(traceback) from exc_value File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 83, in _execute return self.cursor.execute(sql) File "/venv/lib/python3.7/site-packages/django/db/backends/sqlite3/base.py", line 294, in execute return Database.Cursor.execute(self, query) django.db.utils.OperationalError: table "django_content_type" already exists

続いて --fake-initial を使って適用してみます

  • --fake-initial の適用
  • スキーマの状態
  • $ ./manage.py migrate --fake-initial Operations to perform: Apply all migrations: admin, auth, contenttypes, products, sales, sessions Running migrations: Applying contenttypes.0001_initial... FAKED Applying auth.0001_initial... FAKED Applying admin.0001_initial... FAKED Applying admin.0002_logentry_remove_auto_add... OK Applying admin.0003_logentry_add_action_flag_choices... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying products.0001_initial... FAKED Applying sales.0001_initial... FAKED Applying sessions.0001_initial... FAKED
  • sqlite> .schema CREATE TABLE IF NOT EXISTS "auth_group_permissions" ( "id" integer NOT NULL PRIMARY KEY, "group_id" integer NOT NULL, "permission_id" integer NOT NULL REFERENCES "auth_permission__old" ("id"), UNIQUE ("group_id", "permission_id") ); CREATE TABLE IF NOT EXISTS "auth_group" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(80) NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS "auth_user_groups" ( "id" integer NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "group_id" integer NOT NULL REFERENCES "auth_group" ("id"), UNIQUE ("user_id", "group_id") ); CREATE TABLE IF NOT EXISTS "auth_user_user_permissions" ( "id" integer NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "permission_id" integer NOT NULL REFERENCES "auth_permission__old" ("id"), UNIQUE ("user_id", "permission_id") ); CREATE TABLE IF NOT EXISTS "django_session" ( "session_key" varchar(40) NOT NULL PRIMARY KEY, "session_data" text NOT NULL, "expire_date" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "products_category" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(30) NOT NULL, "created_at" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "products_product" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL, "category_id" integer NOT NULL REFERENCES "products_category" ("id"), "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "deleted_at" datetime ); CREATE TABLE IF NOT EXISTS "products_price" ( "id" integer NOT NULL PRIMARY KEY, "price" integer NOT NULL, "product_id" integer NOT NULL REFERENCES "products_product" ("id"), "effective_date_start" datetime, "effective_date_end" datetime ); CREATE TABLE IF NOT EXISTS "sales_sales" ( "id" integer NOT NULL PRIMARY KEY, "product_id" integer NOT NULL REFERENCES "products_product" ("id"), "sold_at" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "sales_summary" ( "id" integer NOT NULL PRIMARY KEY, "date" date NOT NULL, "total_sales" integer NOT NULL, "total_price" integer NOT NULL, "unique_user" integer NOT NULL ); CREATE INDEX "auth_group_permissions_5f412f9a" ON "auth_group_permissions" ("group_id"); CREATE INDEX "auth_group_permissions_83d7f98b" ON "auth_group_permissions" ("permission_id"); CREATE INDEX "auth_user_groups_6340c63c" ON "auth_user_groups" ("user_id"); CREATE INDEX "auth_user_groups_5f412f9a" ON "auth_user_groups" ("group_id"); CREATE INDEX "auth_user_user_permissions_6340c63c" ON "auth_user_user_permissions" ("user_id"); CREATE INDEX "auth_user_user_permissions_83d7f98b" ON "auth_user_user_permissions" ("permission_id"); CREATE INDEX "django_session_b7b81f0c" ON "django_session" ("expire_date"); CREATE INDEX "products_product_6f33f001" ON "products_product" ("category_id"); CREATE INDEX "products_price_7f1b40ad" ON "products_price" ("product_id"); CREATE INDEX "sales_sales_7f1b40ad" ON "sales_sales" ("product_id"); CREATE TABLE IF NOT EXISTS "django_migrations" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "app" varchar(255) NOT NULL, "name" varchar(255) NOT NULL, "applied" datetime NOT NULL); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE IF NOT EXISTS "django_admin_log" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "action_time" datetime NOT NULL, "object_id" text NULL, "object_repr" varchar(200) NOT NULL, "change_message" text NOT NULL, "content_type_id" integer NULL REFERENCES "django_content_type__old" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" integer NOT NULL REFERENCES "auth_user__old" ("id") DEFERRABLE INITIALLY DEFERRED, "action_flag" smallint unsigned NOT NULL); CREATE INDEX "django_admin_log_content_type_id_c4bce8eb" ON "django_admin_log" ("content_type_id"); CREATE INDEX "django_admin_log_user_id_c564eba6" ON "django_admin_log" ("user_id"); CREATE TABLE IF NOT EXISTS "django_content_type" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "app_label" varchar(100) NOT NULL, "model" varchar(100) NOT NULL); CREATE UNIQUE INDEX "django_content_type_app_label_model_76bd3d3b_uniq" ON "django_content_type" ("app_label", "model"); CREATE TABLE IF NOT EXISTS "auth_permission" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "content_type_id" integer NOT NULL REFERENCES "django_content_type" ("id") DEFERRABLE INITIALLY DEFERRED, "codename" varchar(100) NOT NULL, "name" varchar(255) NOT NULL); CREATE UNIQUE INDEX "auth_permission_content_type_id_codename_01ab375a_uniq" ON "auth_permission" ("content_type_id", "codename"); CREATE INDEX "auth_permission_content_type_id_2f476e4b" ON "auth_permission" ("content_type_id"); CREATE TABLE IF NOT EXISTS "auth_user" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "password" varchar(128) NOT NULL, "last_login" datetime NULL, "is_superuser" bool NOT NULL, "username" varchar(150) NOT NULL UNIQUE, "first_name" varchar(30) NOT NULL, "email" varchar(254) NOT NULL, "is_staff" bool NOT NULL, "is_active" bool NOT NULL, "date_joined" datetime NOT NULL, "last_name" varchar(150) NOT NULL);

各アプリの初期(0001_initial.py) 以外のマイグレーションも無事適用されました。

現時点で auth/migrations/ には 10個のマイグレーションがありますが反映されているようです。 例えば auth_user.last_name の varchar length が 30 -> 150 になっています。

逆に --fake で適用してしまうと..

  • --fake の適用
  • スキーマの状態
  • $ ./manage.py migrate --fake Operations to perform: Apply all migrations: admin, auth, contenttypes, products, sales, sessions Running migrations: Applying contenttypes.0001_initial... FAKED Applying auth.0001_initial... FAKED Applying admin.0001_initial... FAKED Applying admin.0002_logentry_remove_auto_add... FAKED Applying admin.0003_logentry_add_action_flag_choices... FAKED Applying contenttypes.0002_remove_content_type_name... FAKED Applying auth.0002_alter_permission_name_max_length... FAKED Applying auth.0003_alter_user_email_max_length... FAKED Applying auth.0004_alter_user_username_opts... FAKED Applying auth.0005_alter_user_last_login_null... FAKED Applying auth.0006_require_contenttypes_0002... FAKED Applying auth.0007_alter_validators_add_error_messages... FAKED Applying auth.0008_alter_user_username_max_length... FAKED Applying auth.0009_alter_user_last_name_max_length... FAKED Applying products.0001_initial... FAKED Applying sales.0001_initial... FAKED Applying sessions.0001_initial... FAKED
  • sqlite> .schema CREATE TABLE IF NOT EXISTS "django_admin_log" ( "id" integer NOT NULL PRIMARY KEY, "action_time" datetime NOT NULL, "user_id" integer NOT NULL, "content_type_id" integer, "object_id" text, "object_repr" varchar(200) NOT NULL, "action_flag" smallint unsigned NOT NULL, "change_message" text NOT NULL ); CREATE TABLE IF NOT EXISTS "auth_permission" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(50) NOT NULL, "content_type_id" integer NOT NULL, "codename" varchar(100) NOT NULL, UNIQUE ("content_type_id", "codename") ); CREATE TABLE IF NOT EXISTS "auth_group_permissions" ( "id" integer NOT NULL PRIMARY KEY, "group_id" integer NOT NULL, "permission_id" integer NOT NULL REFERENCES "auth_permission" ("id"), UNIQUE ("group_id", "permission_id") ); CREATE TABLE IF NOT EXISTS "auth_group" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(80) NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS "auth_user_groups" ( "id" integer NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "group_id" integer NOT NULL REFERENCES "auth_group" ("id"), UNIQUE ("user_id", "group_id") ); CREATE TABLE IF NOT EXISTS "auth_user_user_permissions" ( "id" integer NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "permission_id" integer NOT NULL REFERENCES "auth_permission" ("id"), UNIQUE ("user_id", "permission_id") ); CREATE TABLE IF NOT EXISTS "auth_user" ( "id" integer NOT NULL PRIMARY KEY, "password" varchar(128) NOT NULL, "last_login" datetime NOT NULL, "is_superuser" bool NOT NULL, "username" varchar(30) NOT NULL UNIQUE, "first_name" varchar(30) NOT NULL, "last_name" varchar(30) NOT NULL, "email" varchar(75) NOT NULL, "is_staff" bool NOT NULL, "is_active" bool NOT NULL, "date_joined" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "django_content_type" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(100) NOT NULL, "app_label" varchar(100) NOT NULL, "model" varchar(100) NOT NULL, UNIQUE ("app_label", "model") ); CREATE TABLE IF NOT EXISTS "django_session" ( "session_key" varchar(40) NOT NULL PRIMARY KEY, "session_data" text NOT NULL, "expire_date" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "products_category" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(30) NOT NULL, "created_at" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "products_product" ( "id" integer NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL, "category_id" integer NOT NULL REFERENCES "products_category" ("id"), "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "deleted_at" datetime ); CREATE TABLE IF NOT EXISTS "products_price" ( "id" integer NOT NULL PRIMARY KEY, "price" integer NOT NULL, "product_id" integer NOT NULL REFERENCES "products_product" ("id"), "effective_date_start" datetime, "effective_date_end" datetime ); CREATE TABLE IF NOT EXISTS "sales_sales" ( "id" integer NOT NULL PRIMARY KEY, "product_id" integer NOT NULL REFERENCES "products_product" ("id"), "sold_at" datetime NOT NULL ); CREATE TABLE IF NOT EXISTS "sales_summary" ( "id" integer NOT NULL PRIMARY KEY, "date" date NOT NULL, "total_sales" integer NOT NULL, "total_price" integer NOT NULL, "unique_user" integer NOT NULL ); CREATE INDEX "django_admin_log_6340c63c" ON "django_admin_log" ("user_id"); CREATE INDEX "django_admin_log_37ef4eb4" ON "django_admin_log" ("content_type_id"); CREATE INDEX "auth_permission_37ef4eb4" ON "auth_permission" ("content_type_id"); CREATE INDEX "auth_group_permissions_5f412f9a" ON "auth_group_permissions" ("group_id"); CREATE INDEX "auth_group_permissions_83d7f98b" ON "auth_group_permissions" ("permission_id"); CREATE INDEX "auth_user_groups_6340c63c" ON "auth_user_groups" ("user_id"); CREATE INDEX "auth_user_groups_5f412f9a" ON "auth_user_groups" ("group_id"); CREATE INDEX "auth_user_user_permissions_6340c63c" ON "auth_user_user_permissions" ("user_id"); CREATE INDEX "auth_user_user_permissions_83d7f98b" ON "auth_user_user_permissions" ("permission_id"); CREATE INDEX "django_session_b7b81f0c" ON "django_session" ("expire_date"); CREATE INDEX "products_product_6f33f001" ON "products_product" ("category_id"); CREATE INDEX "products_price_7f1b40ad" ON "products_price" ("product_id"); CREATE INDEX "sales_sales_7f1b40ad" ON "sales_sales" ("product_id"); CREATE TABLE IF NOT EXISTS "django_migrations" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "app" varchar(255) NOT NULL, "name" varchar(255) NOT NULL, "applied" datetime NOT NULL); CREATE TABLE sqlite_sequence(name,seq);

適用されるべきマイグレーションまで フェイク適用してしまったようです。

--fake-initial--fake の使い分けには注意しましょうね。

info
  • 今回はシステムで使っているテーブルだけに恩恵がありましたが 今後 products, sales アプリにもマイグレーションが追加され、 自分以外のチームメンバーがそのマイグレーションを適用するときも、 --fake-initial は役に立ちます。

😇 NULLが許可されていないフィールドを追加

  • 📁django-migration-case-studies
  • 📁apps-non-nullable-field
  • 🗒.gitignore
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_category_created_at.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30)),
            ],
        ),
    ]

nullが許可されていないフィールドを追加しようとすると以下のような入力を求められることがあります。

You are trying to add a non-nullable field 'code' to category without a default; we can't do that (the database needs something to populate existing rows). Please select a fix: 1) Provide a one-off default now (will be set on all existing rows with a null value for this column) 2) Quit, and let me add a default in models.py Select an option:

これは null が許可されていない上、デフォルト値も指定されていないため、すでにレコード (rows) がある場合に 補完する値を Django が判断できないので指示を求められています。

それぞれ

    1. 既存レコードに割り当てるデフォルト値を対話的に指定する
    1. マイグレーションファイルを作るのを中断する (models.py に直接デフォルト値を指定してやりなおしてね)

と言った感じですね。

1 を選ぶ場合、 フィールドの方に沿った値で適切なものを指定すればよいです。

また、値としてコーラブルなオブジェクト (例えば django.utils.timezone) を指定すると、 実行時の返却値がデフォルト値となります。

warning
  • これらのデフォルト値はレコードごとに設定することはできず、画一的に同じ値が設定されます。
  • 別カラムの値を元にフィールドの値を設定したい場合は 一度 nullable なフィールドとして定義した後、データマイグレーション(後述) を行い、 nullable (null=False) を指定すればよいでしょう。

では、実際に以下のようなカテゴリレコードがあるという状況でやってみます。

insert into products_category(id, name) values (1, 'alpaca'), (2, 'dog') ; select * from products_category; 1|alpaca 2|dog

ではマイグレーションを作って適用してみます。 デフォルト値は timezone.now にします。

$ ./manage.py makemigrations products You are trying to add a non-nullable field 'created_at' to category without a default; we can't do that (the database needs something to populate existing rows). Please select a fix: 1) Provide a one-off default now (will be set on all existing rows with a null value for this column) 2) Quit, and let me add a default in models.py Select an option: 1 Please enter the default value now, as valid Python The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now Type 'exit' to exit this prompt >>> timezone.now Migrations for 'products': products/migrations/0002_category_created_at.py - Add field created_at to category $ ./manage.py migrate products 0002 Operations to perform: Target specific migration: 0002_category_created_at, from products Running migrations: Applying products.0002_category_created_at... OK フィールドが追加され、デフォルト値も格納されたようです。
sqlite> .schema products_category CREATE TABLE "products_category" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(30) NOT NULL, "created_at" datetime NOT NULL ); sqlite> select * from products_category ; 1|alpaca|2018-12-05 09:47:37.577821 2|dog|2018-12-05 09:47:37.577821

😩 マイグレーションを取り消す

  • 📁django-migration-case-studies
  • 📁apps-revert
  • 🗒.gitignore
  • 📁accounts
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_dummy.py
  • 🗒0003_dummy.py
  • 🗒0004_dummy.py
  • 🗒0005_dummy.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_product_deleted_at.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
  • 📁sales
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_summary.py
  • 🗒0003_renamed_and_added.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
0001_initial.py
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
            ],
        ),
        migrations.CreateModel(
            name='Price',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('price', models.IntegerField()),
                ('effective_date_start', models.DateTimeField(null=True)),
                ('effective_date_end', models.DateTimeField(null=True)),
            ],
        ),
        migrations.CreateModel(
            name='Product',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('category', models.ForeignKey(db_column='category_id', on_delete=django.db.models.deletion.CASCADE, to='products.Category')),
            ],
        ),
        migrations.AddField(
            model_name='price',
            name='product',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.Product'),
        ),
    ]

基礎編#migrate でも説明しましたが、 適用済マイグレーションを指定するとそれより一つ後のマイグレーションまで逆適用 つまり、取り消しが行われます。

0001 (先頭) まで取り消したい場合は zero を指定します。

--fake と併用すると履歴だけを消せます。

products/migrations/ 配下のマイグレーションをすべて適用した状態で

sqlite> select id, app, name from django_migrations; 1|products|0001_initial 2|products|0002_product_deleted_at sqlite> .table django_migrations products_category products_price products_product

逆適用した結果を見てみましょう

  • ./manage.py migrate products zero
  • ./manage.py migrate products zero --fake
  • Operations to perform: Unapply all migrations: products Running migrations: Rendering model states... DONE Unapplying products.0002_product_deleted_at... OK Unapplying products.0001_initial... OK
  • Operations to perform: Unapply all migrations: products Running migrations: Rendering model states... DONE Unapplying products.0002_product_deleted_at... FAKED Unapplying products.0001_initial... FAKED
  • sqlite> select id, app, name from django_migrations; -- products のテーブルは全部消えてる sqlite> .table django_migrations
  • sqlite> select id, app, name from django_migrations; -- テーブルは消えない! sqlite> .table django_migrations products_category products_price products_product

😎 データマイグレーション

  • 📁django-migration-case-studies
  • 📁apps-data-migration
  • 🗒.gitignore
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_manual.py
  • 🗒0003_manual.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30)),
            ],
        ),
        migrations.CreateModel(
            name='Price',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('price', models.IntegerField()),
                ('effective_date_start', models.DateTimeField(null=True)),
                ('effective_date_end', models.DateTimeField(null=True)),
            ],
        ),
        migrations.CreateModel(
            name='Product',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('category', models.ForeignKey(db_column='category_id', on_delete=django.db.models.deletion.CASCADE, to='products.Category')),
            ],
        ),
        migrations.AddField(
            model_name='price',
            name='product',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.Product'),
        ),
    ]

既存レコードの整理、初期レコードの追加、フィールドの変更によって スキーマではなくレコードを操作したくなることもあります。

これを行うのが前述した RunSQLRunPython です。

RunSQL

RunSQL は単純に 第一引数で与えた 生SQL (DML, 例えば INSERT, UPDATE, DELETE 文 ) を実行するだけです。

第二引数は逆適用の SQLを指定します。省略できます。 形式は 第一引数と同じです。

まず、空のマイグレーションファイルを用意します.

$ ./manage.py makemigrations products --empty --name='manual' Migrations for 'products': products/migrations/0002_manual.py

マイグレーションファイルはこんな感じになっております

0002_manual.py
from django.db import migrations

# カテゴリ追加
INSERT_CATEGORIES_SQL = '''
INSERT INTO products_category (name)
VALUES
  (%s),
  (%s),
  (%s)
;
'''

# すべての値を削除
DELETE_CATEGORIES_SQL = '''
DELETE FROM products_category;
'''


class Migration(migrations.Migration):

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

    operations = [
        migrations.RunSQL(
            [
                (INSERT_CATEGORIES_SQL, ('a', 'b', 'c',)),
                (INSERT_CATEGORIES_SQL, ('d', 'e', 'f',)),
            ], 
            DELETE_CATEGORIES_SQL
        ),
    ]

第一引数 と 第二引数 の形式は同じと言いましたが、いきなり違っていますね..

実は RunSQL は 引数として文字列を受け取るとそのまま解釈して実行し、 タプルのリストとして受け取ると、タプルの中をパラメータとして SQL を組み立て、リストの要素数回実行します。

では順適用と逆適用をしてみましょう。

  • ./manage.py migrate products 0002
  • 結果
  • $ ./manage.py migrate products 0002 Operations to perform: Apply all migrations: products Running migrations: Applying products.0002_manual... OK
  • sqlite> select * from products_category ; 1|a 2|b 3|c 4|d 5|e 6|f
  • ./manage.py migrate products 0001
  • 結果
  • $ ./manage.py migrate products 0001 Operations to perform: Target specific migration: 0001_initial, from products Running migrations: Rendering model states... DONE Unapplying products.0002_manual... OK
  • sqlite> select * from products_category ; -- なし

逆適用もできました。

info
  • django.core.exceptions.ImproperlyConfigured: The sqlparse package is required if you don't split your SQL statements manually. と出た場合は sqlparse をインストールしましょう Running a .sql file after migrations in django - Stack Overflow
  • 第二引数 (reverse_sql) を指定せずに逆適用をしようとすると django.db.migrations.exceptions.IrreversibleError が発生します。

RunSQL 中で DDL (CREATE TABLE, ALTER 文など) を実行することはあまりおすすめできません。

これらの 操作内でスキーママイグレーションが行われても Django は認識できないのです。

どうしても手動で DDL を実行する場合、 state_operations 引数に Django に認識してほしい Operation をリスト形式 (operations 属性と同じ) で指定します。 state_operations 属性は、実際にDBには適用しないけどDjangoには適用したことにするというものです。

少しわかりにくいかもしれないので不整合が起こる例をあげます。

  1. RunSQL で DROP TABLE products_price を指定したマイグレーションを実行
    • products_price テーブルが消える
    • Django は products_price テーブルが消えたことを知らない
  2. models.py からも Price モデルを削除し makemigrations する
    • Django は Price モデルを削除するための Operation (DeleteModel) を含むマイグレーションが自動生成される
  3. 2 で作成したマイグレーションを実行
    • products_price を削除しようとするが 既に削除されているためエラーが発生する (不整合)

上記の場合は 下記のように指定すればOKです。

operations = [ migrations.RunSQL("DROP TABLE products_price", state_operations=[ migrations.DeleteModel(name='Price'), ]), ]

既におわかりかもしれませんが DROP TABLE という生SQLを実行されたとき、 Djangoマイグレーションにおいて同等の DeleteModel が実行されたことにするという意味ですね。

しかしこんなことをやるのは冗長です。 「どうしても」というときを除きできるだけ Django に任せましょう

RunPython

先ほどと同様に 空のマイグレーションファイルを作成します。

$ ./manage.py makemigrations products --empty --name='manual' Migrations for 'products': products/migrations/0003_manual.py

今回はカテゴリを全部大文字にするようなものを考えてみました。 (ただしquerysetでやるようなメリットはない..)

from django.db import migrations from django.db.models import Func, F def set_categories_uppercase(apps, schema_editor): Category = apps.get_model("products", "Category") Category.objects.all().update( name=Func(F('name'), function='UPPER') ) def set_categories_lowercase(apps, schema_editor): Category = apps.get_model("products", "Category") Category.objects.all().update( name=Func(F('name'), function='LOWER') ) class Migration(migrations.Migration): dependencies = [ ('products', '0002_manual'), ] operations = [ migrations.RunPython(set_categories_uppercase, set_categories_lowercase) ]
  • ./manage.py migrate products 0003
  • 結果
  • $ ./manage.py migrate products 0003 Operations to perform: Apply all migrations: products Running migrations: Applying products.0003_manual... OK
  • sqlite> select * from products_category ; 7|A 8|B 9|C 10|D 11|E 12|F
  • ./manage.py migrate products 0002
  • 結果
  • $ ./manage.py migrate products 0002 Operations to perform: Target specific migration: 0002_manual, from products Running migrations: Rendering model states... DONE Unapplying products.0003_manual... OK
  • sqlite> select * from products_category ; 7|a 8|b 9|c 10|d 11|e 12|f
warning
  • これはメリットでもあり、デメリットでもあるんですが、 migrations.RunPython 関数内でレコードに変更が生じても 登録されているシグナルは発火しません。
  • 後処理などをシグナルで行っている場合、マイグレーションで同様の処理を実施してあげる必要があります。
info
  • Django では 初期データの投入方法として fixture が推奨されてきましたが、 Django 1.8 で データマイグレーションが推奨されるようになりました。
  • ただ、マイグレーションによって自動投入されてほしくないようなデータは fixture を使う想定なんだと思います。
  • とはいえ、JSON で表すのに適していない大量データや、 別のデータに依存しているデータ、 規則性のあるデータはマイグレーションで作ったほうが良いかもしれません。 状況に応じて柔軟に使い分けていきましょう。

😯 Operation クラス をカスタマイズする

  • 📁django-migration-case-studies
  • 📁apps-custom-operation
  • 🗒.gitignore
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_manual.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30)),
            ],
        ),
        migrations.CreateModel(
            name='Price',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('price', models.IntegerField()),
                ('effective_date_start', models.DateTimeField(null=True)),
                ('effective_date_end', models.DateTimeField(null=True)),
            ],
        ),
        migrations.CreateModel(
            name='Product',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('category', models.ForeignKey(db_column='category_id', on_delete=django.db.models.deletion.CASCADE, to='products.Category')),
            ],
        ),
        migrations.AddField(
            model_name='price',
            name='product',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.Product'),
        ),
    ]

Operation クラスを継承してカスタムした操作を作成できます。

私の想像力が乏しいため、カスタマイズしないとできないような例が思い浮かばなかったので、 カテゴリを追加するような操作を自分で作ってみましょう。 (RunSQL や RunPython でもできます)

前回と同様、空のマイグレーションファイルを用意します.

$ ./manage.py makemigrations products --empty --name='manual' Migrations for 'products': products/migrations/0002_manual.py
from django.db import migrations from django.db.migrations.operations.base import Operation class CreateCategory(Operation): reversible = True def __init__(self, name): self.name = name def state_forwards(self, app_label, state): pass def database_forwards(self, app_label, schema_editor, from_state, to_state): schema_editor.execute("INSERT INTO products_category (name) VALUES (%s);", (self.name,)) def database_backwards(self, app_label, schema_editor, from_state, to_state): schema_editor.execute("DELETE FROM products_category WHERE name=%s;", (self.name,)) def describe(self): return "Creates category %s" % self.name class Migration(migrations.Migration): dependencies = [ ('products', '0001_initial'), ] operations = [ CreateCategory('alpaca'), CreateCategory('dog'), ]

重要なのは以下のメソッドです。

  • database_forwards: 実行する操作を記述
  • database_backwards: 実行した操作を打ち消すための操作を記述
  • reversible: True であれば backwards 可能

今回の カスタムオペレーションを使うためには operations 属性に指定するだけです。

では適用してみます。

  • ./manage.py migrate products 0002
  • 結果
  • $ ./manage.py migrate products Operations to perform: Apply all migrations: products Running migrations: Applying products.0001_initial... OK Applying products.0002_manual... OK
  • sqlite> select * from products_category ; 1|alpaca 2|dog

つづいて逆適用。

  • ./manage.py migrate products 0001
  • 結果
  • $ ./manage.py migrate products 0001 Operations to perform: Target specific migration: 0001_initial, from products Running migrations: Rendering model states... DONE Unapplying products.0002_manual... OK
  • sqlite> select * from products_category ; -- なし

うまく動いたみたいです。

😠 既存データに合わないスキーママイグレーション

  • 📁django-migration-case-studies
  • 📁apps-unmatch
  • 🗒.gitignore
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_id2.py
  • 🗒0003_put_serial_number.py
  • 🗒0004_category_id2.py
  • 🗒0005_put_new_pk.py
  • 🗒0006_del_category_id.py
  • 🗒0007_id2_primary_key.py
  • 🗒0008_id2_to_id.py
  • 🗒0009_category_id2_to_category_id.py
  • 🗒0010_category_id_to_foreignkey.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
                ('name', models.CharField(max_length=30)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
            ],
        ),
        migrations.CreateModel(
            name='Price',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('price', models.IntegerField()),
                ('effective_date_start', models.DateTimeField(null=True)),
                ('effective_date_end', models.DateTimeField(null=True)),
            ],
        ),
        migrations.CreateModel(
            name='Product',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('category', models.ForeignKey(db_column='category_id', on_delete=django.db.models.deletion.CASCADE, to='products.Category')),
            ],
        ),
        migrations.AddField(
            model_name='price',
            name='product',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.Product'),
        ),
    ]

makemigrations コマンドは DBの状態を考慮せずに マイグレーションファイルを作ると言いました。

そのため、スキーマ的にはマイグレーション可能でも実際に適用してみると、 既存データが変更後の 制約 に合わずにマイグレーションが失敗することがあります。

色んなパターンがあるので網羅は難しいんですが、今回は型の変更について説明します。

以下のシナリオでやってみます。

  • Category モデルの id列 を UUIDField から Autofield (integer) フィールドに変更する

    • id 列は 主キー
    • キャスト不可能
  • created の順に連番を振りなおす

  • Product.category は Category.id を外部参照している

    • レコードは 1件以上存在する
  • 既にテーブルは作成されている

    • 作成
    • 適用
    • ./manage.py makemigrations products Migrations for 'products': products/migrations/0001_initial.py - Create model Category - Create model Price - Create model Product - Add field product to price
    • $ ./manage.py migrate products 0001 Operations to perform: Apply all migrations: products Running migrations: Applying products.0001_initial... OK
  • 既に 次のようなレコードが入っている

    insert into products_category(id, name, created_at) values ('5fe87b34-1d84-4dd2-bfdd-4c9d275bc5a2', 'alpaca', '2018-01-01'), ('c5a63139-3470-4e07-9668-d2c9edc478bd', 'dog', '2018-01-02'), ('4ca1bcbd-64b2-4f13-a445-6b096ee655aa', 'cat', '2018-01-03'), ('e12db291-875e-4a2b-aedd-5423fd8eb18c', 'capibara', '2018-01-01') ; insert into products_product(name, category_id, created_at, updated_at) values ('アルパカの置物', '5fe87b34-1d84-4dd2-bfdd-4c9d275bc5a2', '2018-02-01', '2018-02-01') ;

この状態でマイグレーションを普通に適用しようとすると次のようなエラーになります。

$ ./manage.py migrate Operations to perform: Apply all migrations: accounts, admin, auth, contenttypes, products, sessions Running migrations: Applying products.0003_uuid_to_integer...Traceback (most recent call last): File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 85, in _execute return self.cursor.execute(sql, params) File "/venv/lib/python3.7/site-packages/django/db/backends/sqlite3/base.py", line 296, in execute return Database.Cursor.execute(self, query, params) sqlite3.IntegrityError: datatype mismatch

では、情報が失われないように気をつけながら データとスキーマを変更していきます。

Category モデルに入れ替え用のフィールドを定義
  • id2 = models.IntegerField(default=0)
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --name="id2" Migrations for 'products': products/migrations/0002_id2.py - Add field id2 to category
    • 0002_id2.py
      from django.db import migrations, models
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0001_initial'),
          ]
      
          operations = [
              migrations.AddField(
                  model_name='category',
                  name='id2',
                  field=models.IntegerField(default=0),
              ),
          ]
      
      
入れ替え要のフィールド に 一意な連番を振る
  • 空のマイグレーションファイルを作る
  • ./manage.py makemigrations products --empty --name="put_serial_number"
  • 連番で更新するような Operation (RunSQL) をマイグレーションに追加する
  • 今使ってる SQLite3 のバージョンではWindow関数が使えないのでこんな感じで..
    • 作成
    • マイグレーションファイル
    • ./manage.py makemigrations products --empty --name="put_serial_number" Migrations for 'products': products/migrations/0003_put_serial_number.py
    • 0003_put_serial_number.py
      from django.db import migrations
      
      SQL = """
      UPDATE products_category AS a
      SET id2 = (
        SELECT COUNT(1) + 1
        FROM products_category AS b
        WHERE
          -- 登録日時が同じ場合に重複してしまうので name でソート
          (a.created_at = b.created_at AND a.name > b.name)
          -- それ以外の場合は 登録日時でソート
          OR a.created_at > b.created_at
      );
      """
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0002_id2'),
          ]
      
          operations = [
              migrations.RunSQL(SQL)
          ]
      
      
Category を外部参照しているモデルに 入れ替え用のフィールドを追加
  • 今回は Product モデルのみ
    • 変更するのが主キーでなければこの操作は不要
    • 外部参照しているモデルが他にある場合、その数だけ繰り返す
    • category_id2 = models.IntegerField(default=0)
      • 追加するフィールドは Category.id2 の型と一致すること
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --name="category_id2" Migrations for 'products': products/migrations/0004_category_id2.py - Add field category_id2 to product
    • 0004_category_id2.py
      from django.db import migrations, models
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0003_put_serial_number'),
          ]
      
          operations = [
              migrations.AddField(
                  model_name='product',
                  name='category_id2',
                  field=models.IntegerField(default=0),
              ),
          ]
      
      
Category を外部参照しているモデルの入れ替え用フィールドに 現在の主キーに紐づく連番を格納する
  • 今回は Product モデルのみ
    • 変更するのが主キーでなければこの操作は不要
    • 外部参照しているモデルが他にある場合、その数だけ繰り返す
    • 空のマイグレーションファイルを作る
    • Product.category_id に紐づく Category.id2 を Product.category_id2 に入れるような UPDATE 文を実行する Operation (RunSQL) を空マイグレーションに追加する
    • 変更するのが主キーでない場合は不要
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --empty --name="put_new_pk" Migrations for 'products': products/migrations/0005_put_new_pk.py
    • 0005_put_new_pk.py
      from django.db import migrations
      
      SQL = """
      UPDATE products_product AS p
      SET category_id2 = (
        SELECT id2
        FROM products_category AS c
        WHERE p.category_id = c.id
      )
      ;
      """
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0004_category_id2'),
          ]
      
          operations = [
              migrations.RunSQL(SQL),
          ]
      
      
Category を外部参照しているフィールドを削除する
  • 今回は Product モデルのみ
    • 変更するのが主キーでなければこの操作は不要
    • 外部参照しているモデルが他にある場合、その数だけ繰り返す
    • # category = models.ForeignKey(Category, on_delete=models.CASCADE)
      • あとから復活させるためコメントアウトに留める
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --name="del_category_id" Migrations for 'products': products/migrations/0006_del_category_id.py - Remove field category from product
    • 0006_del_category_id.py
      from django.db import migrations
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0005_put_new_pk'),
          ]
      
          operations = [
              migrations.RemoveField(
                  model_name='product',
                  name='category',
              ),
          ]
      
      
Category の入れ替え用フィールドを主キーにする
  • id2 フィールドを AutoField に変更する (主キーの指定も必要)
    • id2 = models.AutoField(primary_key=True)
    • id フィールドを削除する
      • # id = models.UUIDField(primary_key=True, default=uuid.uuid4)
        • 今回は差分をわかりやすくするためにコメントアウト
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --name="id2_primary_key" Migrations for 'products': products/migrations/0007_id2_primary_key.py - Remove field id from category - Alter field id2 on category
    • 0007_id2_primary_key.py
      from django.db import migrations, models
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0006_del_category_id'),
          ]
      
          operations = [
              migrations.RemoveField(
                  model_name='category',
                  name='id',
              ),
              migrations.AlterField(
                  model_name='category',
                  name='id2',
                  field=models.AutoField(primary_key=True, serialize=False),
              ),
          ]
      
      
Category の入れ替え用フィールドを id にする
  • id2 のフィールド名を id に変更
    • id = models.AutoField(primary_key=True)
    • (フィールドを消してしまうと 削除して追加 になるので注意)
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --name="id2_to_id" Did you rename category.id2 to category.id (a AutoField)? [y/N] y Migrations for 'products': products/migrations/0008_id2_to_id.py - Rename field id2 on category to id
    • 0008_id2_to_id.py
      from django.db import migrations
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0007_id2_primary_key'),
          ]
      
          operations = [
              migrations.RenameField(
                  model_name='category',
                  old_name='id2',
                  new_name='id',
              ),
          ]
      
      
Product.category_id2 から Product.category_id に変更
  • Category を外部参照しているモデルの入れ替え用フィールドを リネームする
    • Product.category_id2 のフィールド名を category_id に変更する
      • category_id2 = models.IntegerField(default=0)
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --name="category_id2_to_category_id" Did you rename product.category_id2 to product.category_id (a IntegerField)? [y/N] y Migrations for 'products': products/migrations/0009_category_id2_to_category_id.py - Rename field category_id2 on product to category_id
    • 0009_category_id2_to_category_id.py
      from django.db import migrations
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0008_id2_to_id'),
          ]
      
          operations = [
              migrations.RenameField(
                  model_name='product',
                  old_name='category_id2',
                  new_name='category_id',
              ),
          ]
      
      
Product.category_id を ForeignKey に変更する
  • Product.category_id のフィールド を削除
    • # category_id = models.IntegerField(default=0)
  • Product.category を ForeignKey で定義する
    • category = models.ForeignKey(Category, on_delete=models.CASCADE)
      • 先程コメントアウトしたものを外すだけでよい
  • 自動で ForeignKey にするのは無理なので、手動で空のマイグレーションファイルを作る
    • ./manage.py makemigrations products --empty --name="category_id_to_foreignkey"
    • migrations.AlterField を使ってフィールドを変更
    • 作成
    • マイグレーションファイル
    • $ ./manage.py makemigrations products --empty --name="category_id_to_foreignkey" Migrations for 'products': products/migrations/0010_category_id_to_foreignkey.py
    • 0010_category_id_to_foreignkey.py
      from django.db import migrations, models
      
      
      class Migration(migrations.Migration):
      
          dependencies = [
              ('products', '0009_category_id2_to_category_id'),
          ]
      
          operations = [
              migrations.AlterField(
                  model_name='product',
                  name='category_id',
                  field=models.ForeignKey(on_delete=models.deletion.CASCADE, to='products.Category'),
              ),
          ]
      
      
実行する
  • これを実行すると
    $ ./manage.py migrate products Operations to perform: Apply all migrations: products Running migrations: Applying products.0002_id2... OK Applying products.0003_put_serial_number... OK Applying products.0004_category_id2... OK Applying products.0005_put_new_pk... OK Applying products.0006_del_category_id... OK Applying products.0007_id2_primary_key... OK Applying products.0008_id2_to_id... OK Applying products.0009_category_id2_to_category_id... OK Applying products.0010_category_id_to_foreignkey... OK
    migrate は無事終わったようですが、 さて、どうなったかな。
    • Before
    • After
    • sqlite> select * from products_category; 5fe87b34-1d84-4dd2-bfdd-4c9d275bc5a2|alpaca|2018-01-01 c5a63139-3470-4e07-9668-d2c9edc478bd|dog|2018-01-02 4ca1bcbd-64b2-4f13-a445-6b096ee655aa|cat|2018-01-03 e12db291-875e-4a2b-aedd-5423fd8eb18c|capibara|2018-01-01 sqlite> select * from products_product; 1|アルパカの置物|2018-02-01|2018-02-01||5fe87b34-1d84-4dd2-bfdd-4c9d275bc5a2
    • sqlite> select * from products_category; alpaca|2018-01-01|1 capibara|2018-01-01|2 dog|2018-01-02|3 cat|2018-01-03|4 sqlite> select * from products_product; 1|アルパカの置物|2018-02-01|2018-02-01|1

無事にデータ移行できたようです。

warning
  • 別の口から処理を受け付けていると連番がユニークにならずマイグレーションが失敗する可能性があります。
  • マイグレーションを実行するときは、 できるだけメンテナンス時間などを設けて外部と遮断した上で適用することをおすすめします。
note
  • キャスト可能な型の場合はそのまま格納可能です。
  • AutoField (integer) から UUIDField に変えた場合、 SQLite3では UUID は char 型 なので 単純に文字列キャストされて格納されます。
  • sqlite> .schema products_category CREATE TABLE IF NOT EXISTS "products_category" ("name" varchar(30) NOT NULL, "id" char(32) NOT NULL PRIMARY KEY); sqlite> select * from products_category ; test|1
  • PostgreSQL のように異なる型として認識される場合は先程と同様にデータを調整してあげる必要があります。

今更ですが、主キーをキャスト不可能な型に変更するとか 正気の沙汰ではないので普通はやめましょう。

😌 同じ親を持つマイグレーション

  • 📁django-migration-case-studies
  • 📁apps-multiple-heads
  • 🗒.gitignore
  • 📁accounts
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_dummy.py
  • 🗒0003_dummy.py
  • 🗒0004_dummy.py
  • 🗒0005_dummy.py
  • 🗒0006_merged.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('email', models.EmailField(max_length=255, unique=True)),
                ('nickname', models.CharField(max_length=20)),
                ('age', models.IntegerField(null=True)),
            ],
            options={
                'abstract': False,
            },
        ),
    ]

あなたが一人で開発をしていて、一つのブランチ(Gitなど) で作業している場合、 このようなケースに遭遇することはめったにないと思いますが、 複数人で開発していると同じアプリ内の同じ親を参照するマイグレーションができあがってしまうことが稀によくあります。

Mercurial (VCS) で言うところの いわゆる マルチプルヘッド (双頭) と呼ばれる、先端が二又以上に分かれた状態です。

Django 的にもこの状態は好ましくありません。

実際に試してみましょう。 今回は accounts アプリの 0004, 0005 がいずれも 0003 を参照しているという状態、 つまり末端が 2又に分かれています。

  • 0004_dummy.py
    from django.db import migrations
    
    
    class Migration(migrations.Migration):
    
        dependencies = [
            ('accounts', '0003_dummy'),
        ]
    
        operations = [
        ]
    
    
  • 0005_dummy.py
    from django.db import migrations
    
    
    class Migration(migrations.Migration):
    
        dependencies = [
            ('accounts', '0003_dummy'),
        ]
    
        operations = [
        ]
    
    
info
  • 本来、上記のように連続した番号で 参照する親が重複するということはまず発生しませんが、わかりやすさのためこのようにしています。

この状態でマイグレーションを適用するとエラーが発生します。

error
  • CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph: (0005_dummy, 0004_dummy in accounts). To fix them run 'python manage.py makemigrations --merge'

エラーメッセージでも言っている通り これらのファイルを一つに束ねるのが makemigrations の --merge オプションです。

$ ./manage.py makemigrations accounts --merge --name='merged' Merging accounts Branch 0004_dummy Branch 0005_dummy Merging will only work if the operations printed above do not conflict with each other (working on different fields or models) Do you want to merge these migration branches? [y/N] y Created new merge migration apps-multiple-heads/accounts/migrations/0006_merged.py

これにより 以下のような 0006 が作られ、今回は無事適用できたようです。

  • マイグレーションファイル
  • 適用
  • 0006_merged.py
    from django.db import migrations
    
    
    class Migration(migrations.Migration):
    
        dependencies = [
            ('accounts', '0004_dummy'),
            ('accounts', '0005_dummy'),
        ]
    
        operations = [
        ]
    
    
  • $ ./manage.py migrate accounts Operations to perform: Apply all migrations: accounts Running migrations: Applying accounts.0001_initial... OK Applying accounts.0002_dummy... OK Applying accounts.0003_dummy... OK Applying accounts.0005_dummy... OK Applying accounts.0004_dummy... OK Applying accounts.0006_merged... OK

このことから 先端さえ収束していれば Django 的にはOKということになります。

  • NG
  • OK
  • flowchart TB 0001-->0002; 0002-->0003; 0003-->0004; 0003-->0005;
  • flowchart TB 0001-->0002; 0002-->0003; 0003-->0004; 0003-->0005; 0004-->0006; 0005-->0006;

上記の図でいうと

0004, 0005 までで終わっている場合は アウト ですが、 0006 で束ねている状態は セーフ なのです。

info
  • --merge は先端が分岐してしまったマイグレーションを一つに束ねるだけのオプションであり、 squashmigrations のように複数のマイグレーションを意味のある1つのマイグレーションに 統合する機能では ありません
  • その心はおそらく、実行順の制御のためではなく、 次回マイグレーションを自動生成する場合に終端がわからないと Dependencies に指定するマイグレーションが一意に特定できないからだと思います。
  • 分岐している部分のマイグレーション(0004, 0005)は 番号の順番とは関係なく適用されることがわかります。
  • 重複したマイグレーションの順番を制御したい場合、 マイグレーションファイルの依存関係を自分で調整してあげましょう。 (ローカルDBへ既に適用されていることがほとんどなので、未適用のDBとは順番のズレが生じる可能性があります)

😱 マイグレーションが10000回を突破する

  • 📁django-migration-case-studies
  • 📁apps-exceed
  • 🗒.gitignore
  • 📁accounts
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒10000_dummy.py
  • 🗒10001_dummy.py
  • 🗒9999_dummy.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('email', models.EmailField(max_length=255, unique=True)),
                ('nickname', models.CharField(max_length=20)),
                ('age', models.IntegerField(null=True)),
            ],
            options={
                'abstract': False,
            },
        ),
    ]

番号が 0パディングの 4桁なので 10000 を超えても大丈夫なのかなーと心配する僕みたいなひねくれた方がいるかもしれません。

コードを見てもマイグレーション数を制限している部分はありませんが、 一応検証してみました。

結論から言うと特に限界はなく、 10000超えても作成、適用はできます。

$ ./manage.py makemigrations accounts --empty --name='dummy' Migrations for 'accounts': accounts/migrations/10000_dummy.py $ ./manage.py makemigrations accounts --empty --name='dummy' Migrations for 'accounts': accounts/migrations/10001_dummy.py $ ./manage.py migrate accounts Operations to perform: Apply all migrations: accounts Running migrations: Applying accounts.0001_initial... OK Applying accounts.9999_dummy... OK Applying accounts.10000_dummy... OK Applying accounts.10001_dummy... OK
warning
  • 今回は ファイル名を適当に調整して 9999 以降のマイグレーションを作りましたが、 実際に 9999 件ある状態で試したところ makemigrations だけで 時間(5分くらい)がかかってエラーが出ます。
  • error
    • RuntimeWarning: Maximum recursion depth exceeded while generating migration graph, falling back to iterative approach. If you're experiencing performance issues, consider squashing migrations as described at https://docs.djangoproject.com/en/dev/topics/migrations/#squashing-migrations.
  • ある程度溜まったら squashmigrations するのがおすすめです。

😅 複数のフィールドをリネームする

  • 📁django-migration-case-studies
  • 📁apps-rename-fields
  • 🗒.gitignore
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_rename_fields.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
            ],
        ),
        migrations.CreateModel(
            name='Price',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('price', models.IntegerField()),
                ('effective_date_from', models.DateTimeField(null=True)),
                ('effective_date_to', models.DateTimeField(null=True)),
            ],
        ),
        migrations.CreateModel(
            name='Product',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
                ('deleted_at', models.DateTimeField(default=django.utils.timezone.now, null=True)),
                ('category', models.ForeignKey(db_column='category_id', on_delete=django.db.models.deletion.CASCADE, to='products.Category')),
            ],
        ),
        migrations.AddField(
            model_name='price',
            name='product',
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.Product'),
        ),
    ]

一つのモデルにおいてフィールドの追加と削除を同時に検知した場合、 Django は フィールドがリネームされたと推測し、対話的に紐づけてよいか確認してきます。賢いですね。

一つの場合は y (yes) を選んで終わりですが これが複数あった場合について考えてみましょう。

特に難しいわけではないですが、初学者にとっては戸惑うポイントかも知れないので一応やっておきます。

たとえば、 Price というモデルのフィールドを同時に以下のように変更し makemigrations をします。

  • effective_date_from -> effective_date_start
  • effective_date_to -> effective_date_end

すると すべての組み合わせについてヒモ付が正しいか確認してくるので、 y/N で教えてあげます。

$ ./manage.py makemigrations products --name='rename_fields' Did you rename price.effective_date_from to price.effective_date_end (a DateTimeField)? [y/N] N Did you rename price.effective_date_to to price.effective_date_end (a DateTimeField)? [y/N] y Did you rename price.effective_date_from to price.effective_date_start (a DateTimeField)? [y/N] y Migrations for 'products': products/migrations/0002_rename_fields.py - Rename field effective_date_to on price to effective_date_end - Rename field effective_date_from on price to effective_date_start
info
  • 解説
    • Did you rename price.effective_date_from to price.effective_date_end (a DateTimeField)? [y/N] n
      • effective_date_from から effective_date_end にリネームするけど正しい?と聞いてくるので No
      • No を選択すると このリネームはスキップされます
    • Did you rename price.effective_date_to to price.effective_date_end (a DateTimeField)? [y/N] y
      • effective_date_to から effective_date_end にリネームするけど正しい?と聞いてくるので Yes
    • Did you rename price.effective_date_from to price.effective_date_start (a DateTimeField)? [y/N] y
      • effective_date_from から effective_date_start にリネームするけど正しい?と聞いてくるので Yes

でこんな感じのマイグレーションが作られます。

適用してみます。

  • マイグレーションファイル
  • 適用
  • 0002_rename_fields.py
    from django.db import migrations
    
    
    class Migration(migrations.Migration):
    
        dependencies = [
            ('products', '0001_initial'),
        ]
    
        operations = [
            migrations.RenameField(
                model_name='price',
                old_name='effective_date_to',
                new_name='effective_date_end',
            ),
            migrations.RenameField(
                model_name='price',
                old_name='effective_date_from',
                new_name='effective_date_start',
            ),
        ]
    
    
  • $ ./manage.py migrate products Operations to perform: Apply all migrations: products Running migrations: Applying products.0001_initial... OK Applying products.0002_rename_fields... OK

適用後に sales_price のスキーマを比較してみましょう

  • Before
  • After
  • sqlite> .schema products_price CREATE TABLE IF NOT EXISTS "products_price" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "price" integer NOT NULL, "effective_date_from" datetime NULL, "effective_date_to" datetime NULL, "product_id" integer NOT NULL REFERENCES "products_product" ("id") DEFERRABLE INITIALLY DEFERRED); CREATE INDEX "products_price_product_id_8481dedb" ON "products_price" ("product_id");
  • sqlite> .schema products_price CREATE TABLE IF NOT EXISTS "products_price" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "price" integer NOT NULL, "effective_date_end" datetime NULL, "product_id" integer NOT NULL REFERENCES "products_product" ("id") DEFERRABLE INITIALLY DEFERRED, "effective_date_start" datetime NULL); CREATE INDEX "products_price_product_id_8481dedb" ON "products_price" ("product_id");

ちゃんとリネームされているようです。

warning
  • 対話で紐づけ関係が解決できない場合、「フィールドを一旦削除し、新規フィールドを追加する」 という挙動になるので注意してください。
  • 本当にフィールドを削除して別のフィールドを追加したいこともあるでしょうから、 この挙動が一概に誤りとは言えません。
  • 不安な方は「削除のマイグレーション」と「追加のマイグレーション」を分けて作成するというのも一つの手です。

😶 ビューをモデルとして登録する

  • 📁django-migration-case-studies
  • 📁apps-view
  • 🗒.gitignore
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒0002_double_category.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
0001_initial.py
from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30)),
            ],
        ),
    ]

VIEW を モデルとして登録したいことがあるかもしれません。 (Django の Viewではないですよ)

単純に同じテーブル名とフィールドを定義すればよいのですが、 マイグレーション対象として管理したくないので managed = False を指定します。

これを使ったモデルは次のように定義できます。

class DoubleCategory(models.Model): id = models.IntegerField(primary_key=True) name = models.CharField(max_length=255) class Meta: db_table = 'double_category' managed = False

特に良い例が思い浮かばなかったので category を 2 つつなげるだけの単純な VIEWを想定します。

  • 作成
  • 適用
  • $ ./manage.py makemigrations products --name='double_category' Migrations for 'products': products/migrations/0002_double_category.py - Create model DoubleCategory
  • $ ./manage.py migrate products Operations to perform: Apply all migrations: products Running migrations: Applying products.0001_initial... OK Applying products.0002_double_category... OK

期待通り、テーブルはできてませんね

sqlite> .schema double_category -- なし

必要に応じて 適当な VIEW を定義します。 (管理されないのでVIEWはあってもなくてもいいです)

sqlite> CREATE VIEW double_category AS SELECT id, name || ' ' || name AS name FROM products_category; sqlite> INSERT INTO products_category (name) VALUES ('a'), ('b'), ('c'); sqlite> select * from double_category ; 1|a a 2|b b 3|c c

最後にインタラクティブシェルから使ってみます。

>>> from products.models import DoubleCategory >>> DoubleCategory.objects.values() <QuerySet [{'id': 1, 'name': 'a a'}, {'id': 2, 'name': 'b b'}, {'id': 3, 'name': 'c c'}]>

はい、できました。

info
  • 今回は VIEW を使う例として managed = False を定義しましたが、 他には別のシステムで使っているテーブルを Django から参照したい(ただ、管理はしたくない) という場合も 同様に対処できます。

😛 ファクトリで生成した関数をモデルに指定する

  • 📁django-migration-case-studies
  • 📁apps-factory
  • 🗒.gitignore
  • 📁accounts
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 📁apps
  • 🗒__init__.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 🗒requirements.txt
0001_initial.py
import accounts.models
from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='User',
            fields=[
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
                ('email', models.EmailField(max_length=255, unique=True)),
                ('nickname', models.CharField(max_length=20)),
                ('age', models.IntegerField(null=True)),
                ('file', models.FileField(upload_to=accounts.models.make_user_path)),
            ],
            options={
                'abstract': False,
            },
        ),
        migrations.CreateModel(
            name='Group',
            fields=[
                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
                ('name', models.CharField(max_length=30)),
                ('file', models.FileField(upload_to=accounts.models.make_group_path)),
            ],
        ),
    ]

Django の makemigrations は モデルの変更を検知してマイグレーションを作成するわけですが、いくつかのフィールドでは 関数を引数として受け取れます。

具体的には default や FileField の upload_to ですが、 ここにファクトリ関数を指定しようとすると一苦労なのです。

まず、ファクトリ関数(クロージャ機能を用いて関数等のオブジェクトを生成する関数) についてわからない方もいると思うので簡単な例をあげます。 ID (UUID) から 自動的に ファイルの保存パスを決定するような 関数を作成する ファクトリを作ります。

以下のように ユーザレコード の ID(UUID) を4桁ずつのディレクトリに区切ったパスを生成する関数を作ってみます。 (この時点では ファクトリではないです)

import os import unicodedata def make_user_path(instance, filename): prefix = 'user/' path = [prefix.strip('/')] path += [instance.id.hex[i:i+4] for i in range(0, 32, 4)] path += [unicodedata.normalize('NFC', filename)] return os.path.join(*path)

これを実行すると期待したようなパスを得られることがわかります。

>>> from accounts.models import User >>> u = User.objects.create(email='[email protected]', nickname='tester', age=75) >>> make_user_path(u, 'test.txt') >>> 'user/64bb/fa2e/36fc/4faa/aa2b/3367/4153/7c9e/test.txt'

このまま upload_to 引数に指定してもよいのですが、 同じ生成ルールで他テーブルの添付ファイルも配置したいということになりました。 ただ、出力先の ディレクトリ (prefix)は任意のものを指定したいのです。

ここでファクトリ関数の出番です。 やることは割と単純で 先程の関数 を別の関数でラップして返却するイメージです。

prefixは外側の関数から渡してあげれば内側の関数でも参照でき、出力された関数では 常に prefixが固定されているというわけです。変数を閉じ込めていると表現することもあります。

import os import unicodedata def make_path_factory(prefix): def make_path(instance, filename): path = [prefix.strip('/')] path += [instance.id.hex[i:i+4] for i in range(0, 32, 4)] path += [unicodedata.normalize('NFC', filename)] return os.path.join(*path) return make_path

一旦関数を出力してから先ほどと同様に使ってみましょう。今回は avatar に変えてみました。

>>> make_user_path = make_path_factory('avatar') >>> make_user_path(u, 'test.txt') 'avatar/64bb/fa2e/36fc/4faa/aa2b/3367/4153/7c9e/test.txt'

これですべてのモデルの添付ファイルを別々のディレクトリに出力することができる!

と思ってしまいそうですが、そう簡単には物事は運ばないようです。

試しに、このファクトリをモデルに指定してマイグレーションをしてみましょう。

  • モデル
  • 適用
  • import os import unicodedata from django.db import models from django.contrib.auth.models import ( BaseUserManager, AbstractBaseUser ) def make_path_factory(prefix): def make_path(instance, filename): path = [prefix.strip('/')] path += [instance.id.hex[i:i+4] for i in range(0, 32, 4)] path += [unicodedata.normalize('NFC', filename)] return os.path.join(*path) return make_path make_path = make_path_factory('user/') class User(AbstractBaseUser): USERNAME_FIELD = 'email' email = models.EmailField(max_length=255, unique=True) nickname = models.CharField(max_length=20) age = models.IntegerField(null=True) file = models.FileField(upload_to=make_path)
  • ./manage.py makemigrations accounts Migrations for 'accounts': accounts/migrations/0001_initial.py - Create model User Traceback (most recent call last): File "./manage.py", line 15, in <module> execute_from_command_line(sys.argv) File "/venv/lib/python3.7/site-packages/django/core/management/__init__.py", line 381, in execute_from_command_line utility.execute() File "/venv/lib/python3.7/site-packages/django/core/management/__init__.py", line 375, in execute self.fetch_command(subcommand).run_from_argv(self.argv) File "/venv/lib/python3.7/site-packages/django/core/management/base.py", line 316, in run_from_argv self.execute(*args, **cmd_options) File "/venv/lib/python3.7/site-packages/django/core/management/base.py", line 353, in execute output = self.handle(*args, **options) File "/venv/lib/python3.7/site-packages/django/core/management/base.py", line 83, in wrapped res = handle_func(*args, **kwargs) File "/venv/lib/python3.7/site-packages/django/core/management/commands/makemigrations.py", line 184, in handle self.write_migration_files(changes) File "/venv/lib/python3.7/site-packages/django/core/management/commands/makemigrations.py", line 222, in write_migration_files migration_string = writer.as_string() File "/venv/lib/python3.7/site-packages/django/db/migrations/writer.py", line 151, in as_string operation_string, operation_imports = OperationWriter(operation).serialize() File "/venv/lib/python3.7/site-packages/django/db/migrations/writer.py", line 110, in serialize _write(arg_name, arg_value) File "/venv/lib/python3.7/site-packages/django/db/migrations/writer.py", line 62, in _write arg_string, arg_imports = MigrationWriter.serialize(item) File "/venv/lib/python3.7/site-packages/django/db/migrations/writer.py", line 279, in serialize return serializer_factory(value).serialize() File "/venv/lib/python3.7/site-packages/django/db/migrations/serializer.py", line 37, in serialize item_string, item_imports = serializer_factory(item).serialize() File "/venv/lib/python3.7/site-packages/django/db/migrations/serializer.py", line 197, in serialize return self.serialize_deconstructed(path, args, kwargs) File "/venv/lib/python3.7/site-packages/django/db/migrations/serializer.py", line 85, in serialize_deconstructed arg_string, arg_imports = serializer_factory(arg).serialize() File "/venv/lib/python3.7/site-packages/django/db/migrations/serializer.py", line 157, in serialize 'Could not find function %s in %s.\n' % (self.value.__name__, module_name) ValueError: Could not find function make_path in accounts.models.

期待した関数は accounts.models には定義されていないと言われているようです。

関数名を合わせているのになぜでしょうか。 実はファクトリで作られた関数名 (Python3 の場合は __qualname__) は make_path_factory.<locals>.make_path] なので名前が合わないようです。

もちろん、これを手動で合わせてあげればいいんですが、 こういうメタ情報はできれば自分で触りたくありませんね。

Pythonにはこういった関数から返却された関数にメタ情報を引き継ぐための関数があります。 そうです。主にデコレータに使う functools.wraps ですね。

今回は functools.wraps を使ったデコレータとして再定義してあげましょう。 (他にいいやり方がある方は教えてください)

ついでに Group モデルの添付ファイルパスも作れるようにしてみます。

  • モデル
  • 作成&適用
  • accounts/models.py
    import os
    import functools
    import uuid
    import unicodedata
    
    from django.db import models
    from django.contrib.auth.models import (
        BaseUserManager, AbstractBaseUser
    )
    
    
    def make_path_factory(prefix):
        def wrapper(f):
            @functools.wraps(f)
            def unique_path(instance, filename):
                path = [prefix.strip('/')]
                path += [instance.id.hex[i:i+4] for i in range(0, 32, 4)]
                path += [unicodedata.normalize('NFC', filename)]
                return os.path.join(*path)
            return unique_path
        return wrapper
    
    
    @make_path_factory('user')
    def make_user_path():
        """ユーザIDを元に添付ファイルを保存するパスを生成する"""
    
    
    @make_path_factory('group')
    def make_group_path():
        """グループIDを元に添付ファイルを保存するパスを生成する"""
    
    
    class User(AbstractBaseUser):
        USERNAME_FIELD = 'email'
    
        id = models.UUIDField(default=uuid.uuid4, primary_key=True)
        email = models.EmailField(max_length=255, unique=True)
        nickname = models.CharField(max_length=20)
        age = models.IntegerField(null=True)
        file = models.FileField(upload_to=make_user_path)
    
    
    class Group(models.Model):
        id = models.UUIDField(default=uuid.uuid4, primary_key=True)
        name = models.CharField(max_length=30)
        file = models.FileField(upload_to=make_group_path)
    
    
    
  • $ ./manage.py makemigrations accounts Migrations for 'accounts': accounts/migrations/0001_initial.py - Create model User - Create model Group $ ./manage.py migrate accounts Operations to perform: Apply all migrations: accounts Running migrations: Applying accounts.0001_initial... OK

最後にこのモデルを使ってレコードを作って期待通りのパスにファイルが吐かれるか確かめてみます。

  • レコードを作ってみる
  • ファイルが配置される
  • >>> from django.core.files.base import ContentFile >>> from accounts.models import User, Group >>> f = ContentFile(b'file content', 'test.txt') >>> u = User.objects.create(email='[email protected]', nickname='くろのて', age=5, file=f) >>> u.id UUID('a1bc0f6c-fc58-4df5-a840-1f589f0a4d57') >>> g = Group.objects.create(name='test', file=f) >>> g.id UUID('15c6ee18-b3b2-4739-8396-9d8f506a568f')
  • $ ls -R media group user media/group: 15c6 media/group/15c6: ee18 media/group/15c6/ee18: b3b2 media/group/15c6/ee18/b3b2: 4739 media/group/15c6/ee18/b3b2/4739: 8396 media/group/15c6/ee18/b3b2/4739/8396: 9d8f media/group/15c6/ee18/b3b2/4739/8396/9d8f: 506a media/group/15c6/ee18/b3b2/4739/8396/9d8f/506a: 568f media/group/15c6/ee18/b3b2/4739/8396/9d8f/506a/568f: test.txt media/user: a1bc media/user/a1bc: 0f6c media/user/a1bc/0f6c: fc58 media/user/a1bc/0f6c/fc58: 4df5 media/user/a1bc/0f6c/fc58/4df5: a840 media/user/a1bc/0f6c/fc58/4df5/a840: 1f58 media/user/a1bc/0f6c/fc58/4df5/a840/1f58: 9f0a media/user/a1bc/0f6c/fc58/4df5/a840/1f58/9f0a: 4d57 media/user/a1bc/0f6c/fc58/4df5/a840/1f58/9f0a/4d57: test.txt

計画通り!

😴 複数のデータベースに対してマイグレーションを適用する

  • 📁django-migration-case-studies
  • 📁apps-multidb
  • 🗒.gitignore
  • 📁accounts
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 📁apps
  • 🗒__init__.py
  • 🗒router.py
  • 🗒settings.py
  • 🗒urls.py
  • 🗒wsgi.py
  • 🗒manage.py
  • 📁products
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 📁migrations
  • 🗒0001_initial.py
  • 🗒__init__.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
  • 🗒requirements.txt
  • 📁sales
  • 🗒__init__.py
  • 🗒admin.py
  • 🗒apps.py
  • 🗒models.py
  • 🗒tests.py
  • 🗒views.py
0001_initial.py
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('email', models.EmailField(max_length=255, unique=True)),
                ('nickname', models.CharField(max_length=20)),
                ('age', models.IntegerField(null=True)),
            ],
            options={
                'abstract': False,
            },
        ),
    ]

案件によっては複数のデータベースを管理していることがあります。

今回は次のような設定にしました。 (該当部分)

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), }, 'users': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'users.sqlite3'), }, }

migrate コマンドの --database オプションで対象となるデータベースを指定すると マイグレーションは対象DBにだけ適用されます。

  • default
  • users
  • $ ./manage.py migrate Operations to perform: Apply all migrations: accounts, admin, auth, contenttypes, products, sales, sessions Running migrations: Applying accounts.0001_initial... OK Applying contenttypes.0001_initial... OK Applying admin.0001_initial... OK Applying admin.0002_logentry_remove_auto_add... OK Applying admin.0003_logentry_add_action_flag_choices... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0001_initial... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying products.0001_initial... OK Applying sales.0001_initial... OK Applying sessions.0001_initial... OK
  • $ ./manage.py migrate --database=users Operations to perform: Apply all migrations: accounts, admin, auth, contenttypes, products, sales, sessions Running migrations: Applying accounts.0001_initial... OK Applying contenttypes.0001_initial... OK Applying admin.0001_initial... OK Applying admin.0002_logentry_remove_auto_add... OK Applying admin.0003_logentry_add_action_flag_choices... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0001_initial... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying products.0001_initial... OK Applying sales.0001_initial... OK Applying sessions.0001_initial... OK
  • default
  • users
  • sqlite> .table accounts_user django_content_type products_product auth_group django_migrations sales_sales auth_group_permissions django_session sales_summary auth_permission products_category django_admin_log products_price
  • sqlite> .table accounts_user django_content_type products_product auth_group django_migrations sales_sales auth_group_permissions django_session sales_summary auth_permission products_category django_admin_log products_price

同じように適用されていますが、 せっかくDBを分けているのに全部適用されるのはうれしくないです。NGなケースもあるでしょう。

Django には database router という機能があり、これを使うことで適用DBを透過的に振り分けられます。

今回は accounts/models.py の テーブルは users データベース に作られるようにしてみます。

  • DBルーター
  • settings.py (一部)
  • apps/router.py
    
    
    class Router:
        def allow_migrate(self, db, app_label, model_name=None, **hints):
            if db == 'users':
                return app_label == 'accounts'
            else:
                return app_label != 'accounts'
    
    
  • DATABASE_ROUTERS = ['apps.router.Router']
  • マイグレーションを制御するには Router クラスの allow_migrate というメソッドにて、適用 する / しないTrue / False で返却します。
    • DB名, アプリ名, モデル名(小文字) が文字列で参照できます。
    • NoneTrue と判断され、適用されるようです。
      • ドキュメントでは None の動作は明記されていなかったので、 確実な動作を望む場合はもれなく真偽値を返却するようにしましょう。
  • Router を指すモジュールパスを settings の DATABASE_ROUTER という変数にリスト形式で指定します。

DBをリセットしてもう一度適用してみます。

  • default
  • users
  • sqlite> .tables auth_group django_content_type products_price auth_group_permissions django_migrations products_product auth_permission django_session sales_sales django_admin_log products_category sales_summary
  • sqlite> .tables accounts_user django_migrations

期待通り accounts/models.py のテーブルは users DBにのみ存在していますね。

info
  • 今回は扱いませんでしたが、 Database router は他にもメソッドがあります。
  • 気になる方は調べてみてください。

🤗 終わりに

長かったケーススタディ編も以上で終了です。

気になる方は リポジトリをクローンして 手元で確かめてみてください。

もしかしたらこの記事を見ている人の中には 現在マイグレーションのトラブルに見舞われている人もいるかもしれません。

  • どの状態を するか
  • 現在どこまで適用されているか (showmigrations)
  • 何が原因で問題が発生しているのか
    • 何を適用しようとしてどのようなエラーが発生したのか (エラーをよく読む)
    • 適用できた環境と違う点はなにか (差分の比較)
  • 失われたデータはあるか
    • バックアップから復元可能か

これらの分析を行った上で落ち着いて対応計画を立てましょう。 今の私達なら可能なはずです 😼