【Django】モデルフィールドを追加・削除した際のmigrate(エラー&成功例)


投稿日 2019年12月24日 >> 更新日 2023年3月2日

今回は既に作成済みのブログアプリを例に、既存のモデルからフィールドの追加や削除といった流れをストレス無くスムーズに行っていきたいと思います。

ブログであれば記事データを維持したまま不要になったモデル(テーブル)やモデル内のフィールドをやり直すという流れです。

モデルを編集した後は決まって「makemigrations」から「migrate」を実行しますが、失敗を恐れて中々手を付けない方は少なくないと思われます。

特にDjangoの性質上データベースを自動で操作してくれるので、便利な反面疎くなりがちです。

なのでそんな恐れが軽減されるように「makemigrations」と「migrate」に的を絞りつつブログアプリのモデルを編集していきたいと思います。

データベースには余り触れませんが、「SQLite」と「MySQL」共に同じ流れで自動変更されます。

実行環境&使用ライブラリ

実行環境
Windows Subsystem for Linux
Python 3.6.9
pip 9.0.1
使用ライブラリ ライセンス
Django==2.2.6 BSD

ブログモデルの概要

ブログアプリのモデルは、「タイトル」「テキスト」フィールドを持つ1つのテーブルで、既に記事が数枚保存されている状態です。

# project/blog/models.py

from django.db import models


class Blog(models.Model):
    title = models.CharField('タイトル', max_length=50)
    text = models.TextField('テキスト')

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = 'ブログ'
        verbose_name_plural = 'ブログ'

上図の状態から、記事データを維持しつつフィールドの追加や削除、わざとエラーなどを引き起こして対処する方法などを見ていきたいと思います。

makemigrationsとmigrateの考え方

まずは「makemigrations」と「migrate」について簡単に説明してみたいと思います。

この二つの仕組みを何となくでも分かっていれば、migrate時でのエラーにも対応することができます。

マイグレーションのオプションや詳しい内容には触れないので、調べたいという方はこちらの方の記事「Django マイグレーション まとめ」がおススメです。

Djangoの公式ドキュメントも添えておきます。

モデル情報がデータベースに適用される時

Djangoフレームワークで、データベースのテーブルが作成される流れは

「models.py」に定義

ターミナルにてモデルの移行情報を作成(makemigrations)

移行情報を元にデータベースへ適用する(migrate)

となります。

なので今回のブログアプリでは、アプリディレクトリ内にある「migrations」ディレクトリ内の移行情報(移行ファイル)を元に出来上がっています。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py    ←初期移行ファイル

モデルを編集し「mekemigrations」を実行する度にmigrationsディレクトリ内に移行ファイルが作成され追加していきます。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_移行.py

1回の移行につき1つの移行ファイルが作成されるので、migrateでデータベースに適用させる際のエラーが発生した場合は最後に作成された移行ファイルに直目すればいいということになります。

もしmigrateでのデータベース適用に失敗した際は、一度データベース内のテーブルを確認して、データベース内の変化を伺ってみるのもいいかもしれません。

SQLiteでテーブルを確認する


$ python3 manage.py dbshell
......
sqlite> .tables
auth_group                    blog_blog
auth_group_permissions        django_admin_log
auth_permission               django_content_type
auth_user                     django_migrations
auth_user_groups              django_session
auth_user_user_permissions

MySQLでテーブルを確認する


$ python3 manage.py dbshell
......
mysql> show tables;
+----------------------------+
| Tables_in_testdb           |
+----------------------------+
| auth_group                 |
| auth_group_permissions     |
| auth_permission            |
| auth_user                  |
| auth_user_groups           |
| auth_user_user_permissions |
| blog_blog                  |
| django_admin_log           |
| django_content_type        |
| django_migrations          |
| django_session             |
+----------------------------+
11 rows in set (0.00 sec)

ここで理解しておくべきことは、「makemigrations」ではデータベースに適用させる為の移行ファイルを作成するだけなので、データベースには何も変更が加えられるわけではありません。

そしてスムーズにモデルを編集していくには、移行ファイルとデータベースのテーブルをしっかり把握することで問題にぶつかっても対応することができます。

それでは実際にブログアプリのモデルフィールドをいくつか追加し、注意すべきところを見ていきたいと思います。

フィールドをいくつか追加(エラー&成功例)

既存のモデルに「作成日」「更新日」「公開/非公開」用のフィールドを新たに3つ追加し、migrate時に失敗してもう一度やり直す方法から実装していきたいと思います。

「models.py」にフィールドを新たに作成します。

# project/blog/models.py

from django.db import models


class Blog(models.Model):
    title = models.CharField('タイトル', max_length=50)
    text = models.TextField('テキスト')

    """ 追加 """
    created_at = models.DateField('作成日', auto_now_add=True)
    updated_at = models.DateField('更新日', auto_now=True)
    is_publick = models.BooleanField('公開する', default=False, help_text='公開する場合はチェックを入れてください')

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = 'ブログ'
        verbose_name_plural = 'ブログ'

「作成日」「更新日」のフィールドではデフォルト引数を指定していないので、このままモデルをmakemigrationsにて移行しようとすると以下のように設定するよう促されます。


$ python3 manage.py makemigrations
You are trying to add the field 'created_at' with 'auto_now_add=True' to blog without a default; the database needs something to populate existing rows.

 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option:

これは既存の記事に対してデータを入力するよう促されているので、「1」であればその場で一度だけ設定し、「2」であればもう一度「models.py」を編集するという選択になります。

ここでは「1」を選択し、わざと間違った初期値を入力してみたいと思います。

$ python3 manage.py makemigrations
You are trying to add the field 'created_at' with 'auto_now_add=True' to blog without a default; the database needs something to populate existing rows.

 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option:1   ←1を選択

Please enter the default value now, as valid Python
You can accept the default 'timezone.now' by pressing 'Enter' or you can provide another value.
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
[default: timezone.now] >>> 0    ←間違った値(本来であればタイムゾーンを入力)
Migrations for 'blog':
  blog/migrations/0002_auto_20191224_1142.py
    - Add field created_at to blog
    - Add field is_publick to blog
    - Add field updated_at to blog

上記ように、間違った値で「blog/migrations/0002_auto_20191224_1142.py」という移行ファイルが作成されます。

#project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_auto_20191224_1142.py   ← 新規作成

分かり切っている事ですが、このままデータベースへ適用させようとすると「TypeError: expected string or bytes-like object」となり適用できなくなります。


$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0002_auto_20191224_1142...Traceback (most recent call last):
..........
TypeError: expected string or bytes-like object

もう一度フィールドのデフォルト初期値を再設定したい場合は、先ほど作成された移行ファイルを削除する必要があります。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_auto_20191224_1142.py   ← 削除

削除したら再び「makemigrations」を実行し、「DateTimeField」に相応しい初期値を与えデータベースへ適用させます。


$ python3 manage.py makemigrations
You are trying to add the field 'created_at' with 'auto_now_add=True' to blog without a default; the database needs something to populate existing rows.

 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option:1   ←1を選択

Please enter the default value now, as valid Python
You can accept the default 'timezone.now' by pressing 'Enter' or you can provide another value.
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
[default: timezone.now] >>> timezone.now    ←タイムゾーンを入力
Migrations for 'blog':
  blog/migrations/0002_auto_20191224_1143.py
    - Add field created_at to blog
    - Add field is_publick to blog
    - Add field updated_at to blog

$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0002_auto_20191224_1143... OK

作成された移行ファイルの内容が正確だったので、データベースへの適用に成功しました。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_auto_20191224_1143.py    ← 正しい移行ファイル

※「作成日」「更新日」ではフィールド引数(auto_add)により記事作成時に自動で挿入されるので、フォームは表示されません。

リレーションの追加

次にリレーション用のモデルを新しく作成し、「ManyToMany」「ForeignKey」フィールドをそれぞれ追加していきたいと思います。

ManyToManyフィールドの追加

多対多の関係性を持てるManyToManyフィールドでは、記事にタグ付けをする為のタグフィールドと関連付けを持つ関連フィールドを追加します。

そのためにタグフィールドに関連付ける為のモデルも作成するので、以下のような内容になります。

# project/blog/models.py

from django.db import models

""" 追加 """
class Tag(models.Model):
    name = models.CharField('タグ', max_length=50)

    def __str__(self):
        return self.name


class Blog(models.Model):
    title = models.CharField('タイトル', max_length=50)
    text = models.TextField('テキスト')
    created_at = models.DateField('作成日', auto_now_add=True)
    updated_at = models.DateField('更新日', auto_now=True)
    is_publick = models.BooleanField('公開する', default=False, help_text='公開する場合はチェックを入れてください')

    """ 追加 """
    tag = models.ManyToManyField(Tag, verbose_name='タグ')
    relation = models.ManyToManyField('self', verbose_name='関連', null=True, blank=True)

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = 'ブログ'
        verbose_name_plural = 'ブログ'

ManyToManyフィールドにおいては初期値を設定せずにモデルの移行・適用をすることができます。


$ python3 manage.py makemigrations
Migrations for 'blog':
  blog/migrations/0003_auto_20191224_1343.py
    - Create model Tag
    - Add field relation to blog
    - Add field tag to blog

$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0003_auto_20191224_1343... OK

移行ファイルも新たに追加されています。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_auto_20191224_1143.py
0003_auto_20191224_1343.py    ← タグ関連の移行ファイル

データベースにもしっかり反映されています。


$ python3 manage.py dbshell
.......
sqlite> .tables
auth_group                    blog_blog_tag   # 追加
auth_group_permissions        blog_tag   # 追加
auth_permission               django_admin_log
auth_user                     django_content_type
auth_user_groups              django_migrations
auth_user_user_permissions    django_session
blog_blog                     
blog_blog_relation   # 追加

※MySQLでは「show tables;」で取得できます。

ForeignKeyフィールドの追加

ForeignKeyフィールドでは多対一の関係性を持つことができるので、カテゴリーとして新しくカテゴリーモデルを作成したいと思います。

このフィールドではDatetimeフィールドと同様にデフォルト初期値が必要になってくるので注意が必要です。

最初は失敗例から実装し、やり直しながら完成させたいと思います。

まず「models.py」を編集します。

# project/blog/models.py

from django.db import models

""" 追加 """
class Category(models.Model):
    name = models.CharField('カテゴリー', max_length=50)

    def __str__(self):
        return self.name


class Tag(models.Model):
    ........


class Blog(models.Model):
    title = models.CharField('タイトル', max_length=50)
    text = models.TextField('テキスト')
    created_at = models.DateField('作成日', auto_now_add=True)
    updated_at = models.DateField('更新日', auto_now=True)
    is_publick = models.BooleanField('公開する', default=False, help_text='公開する場合はチェックを入れてください')

    """ 追加 """
    category = models.ForeignKey(
                    Category, verbose_name='カテゴリー',
                    on_delete=models.PROTECT
               )
    tag = models.ManyToManyField(Tag, verbose_name='タグ')
    relation = models.ManyToManyField('self', verbose_name='関連', null=True, blank=True)

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = 'ブログ'
        verbose_name_plural = 'ブログ'

上記は失敗例です。

Categoryモデルには1つもデータが存在しないので、Blogモデル内のcategoryフィールドにデフォルト初期値を設定したところで一致するデータがありません。

試しに実行してみます。


$ python3 manage.py makemigrations
You are trying to add a non-nullable field 'category' to blog 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   ←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
>>> 1     ←Categoryモデルに存在しないIDを初期値に与える
Migrations for 'blog':
  blog/migrations/0004_auto_20191224_1432.py
    - Create model Category
    - Add field category to blog

$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0004_auto_20191224_1432...Traceback (most recent call last):
.........
django.db.utils.IntegrityError: The row in table 'blog_blog' with primary key '3' has an invalid foreign key: blog_blog.category_id contains a value '0' that does not have a corresponding value in blog_category.id.

「django.db.utils.IntegrityError:」では対応する値がありませんと吐かれます。

現時点ではまだデータベースにCategoryのテーブルは作成されいません。

なので、初期値となるカテゴリーのデータを先に保存しておく必要があるのでCategoryモデルをだけを作成するため、Blogモデル内のcategoryフィールドはコメントアウトしておきます。

# project/blog/models.py

from django.db import models

""" 先にデータベースへ適用させる """
class Category(models.Model):
    name = models.CharField('カテゴリー', max_length=50)

    def __str__(self):
        return self.name


class Tag(models.Model):
    ........


class Blog(models.Model):
    title = models.CharField('タイトル', max_length=50)
    text = models.TextField('テキスト')
    created_at = models.DateField('作成日', auto_now_add=True)
    updated_at = models.DateField('更新日', auto_now=True)
    is_publick = models.BooleanField('公開する', default=False, help_text='公開する場合はチェックを入れてください')

    """ コメントアウト
    category = models.ForeignKey(
                    Category, verbose_name='カテゴリー',
                    on_delete=models.PROTECT
               )
    """

    tag = models.ManyToManyField(Tag, verbose_name='タグ')
    relation = models.ManyToManyField('self', verbose_name='関連', null=True, blank=True)

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = 'ブログ'
        verbose_name_plural = 'ブログ'

先ほど作成されてしまった移行ファイルを削除します。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_auto_20191224_1143.py
0003_auto_20191224_1343.py
0004_auto_20191224_1432.py    ← 削除

再びモデルを移行し適用させます。


$ python3 manage.py makemigrations
Migrations for 'blog':
  blog/migrations/0004_category.py
    - Create model Category

$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0004_category... OK

データベースを確認してみます。


$ python3 manage.py dbshell
sqlite> .tables
auth_group                    blog_blog_tag
auth_group_permissions        blog_category   # 追加
auth_permission               blog_tag
auth_user                     django_admin_log
auth_user_groups              django_content_type
auth_user_user_permissions    django_migrations
blog_blog                     django_session
blog_blog_relation            

追加されているのが分かるので、DjangoAPIを使ってカテゴリーのデータを1つ保存します。


$ python3 manage.py shell
>>> from blog.models import Category

>>> Category.objects.create(name='Python')
<Category: Python>

>>> obj = Category.objects.get(name='Python')
>>> obj.id
1

>>> exit()

保存されたカテゴリーのIDが分かったので、Blogモデル内のカテゴリーフィールドのデフォルト値は1に設定します。

では「models.py」にてカテゴリーフィールドのコメントアウトを外し移行ファイルを作成します。

# project/blog/models.py

from django.db import models


class Category(models.Model):
    .......


class Tag(models.Model):
    ........


class Blog(models.Model):
    title = models.CharField('タイトル', max_length=50)
    text = models.TextField('テキスト')
    created_at = models.DateField('作成日', auto_now_add=True)
    updated_at = models.DateField('更新日', auto_now=True)
    is_publick = models.BooleanField('公開する', default=False, help_text='公開する場合はチェックを入れてください')

    """ コメントアウトを外す """
    category = models.ForeignKey(
                    Category, verbose_name='カテゴリー',
                    on_delete=models.PROTECT
               )
    tag = models.ManyToManyField(Tag, verbose_name='タグ')
    relation = models.ManyToManyField('self', verbose_name='関連', null=True, blank=True)

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = 'ブログ'
        verbose_name_plural = 'ブログ'

$ python3 manage.py makemigrations
You are trying to add a non-nullable field 'category' to blog 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    ←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
>>> 1     ←カテゴリーIDの1を指定
Migrations for 'blog':
  blog/migrations/0005_blog_category.py
    - Add field category to blog

$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0005_blog_category... OK

移行ファイルが正しく認識されデータベースに適用されました。

「migrations」ディレクトリ内のファイルは以下のようになりました。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_auto_20191224_1143.py
0003_auto_20191224_1343.py
0004_category.py       ← Categoryモデル作成時の移行ファイル
0005_blog_category.py      ← カテゴリーフィールド作成時の移行ファイル

次にこれまで追加してきたモデルやフィールドを綺麗に削除していきたいと思います。

不要なモデル・フィールドの削除

モデルやフィールドの削除に関しては、特に気を付けることはありませんがブログアプリに関しては記事は残しておきたいと思います。

なので最初の状態へといっきに戻してしまいます。

「models.py」を編集します。

# project/blog/models.py

from django.db import models

""" このモデル以外全て削除 """
class Blog(models.Model):
    title = models.CharField('タイトル', max_length=50)
    text = models.TextField('テキスト')

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = 'ブログ'
        verbose_name_plural = 'ブログ'

いつもの通りマイグレーションコマンドを実行します。


$ python3 manage.py makemigrations
Migrations for 'blog':
  blog/migrations/0006_auto_20191224_1610.py
    - Remove field category from blog
    - Remove field created_at from blog
    - Remove field is_publick from blog
    - Remove field relation from blog
    - Remove field tag from blog
    - Remove field updated_at from blog
    - Delete model Category
    - Delete model Tag

$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0006_auto_20191224_1610... OK

移行ファイルを元にデータベースへ適用されました。

# project/blog/migrations/

__pycache__
__init__.py
0001_initial.py
0002_auto_20191224_1143.py
0003_auto_20191224_1343.py
0004_category.py
0005_blog_category.py
0006_auto_20191224_1610.py    ← 削除された移行ファイル

データベース内も綺麗に元の状態へと変更されました。


$ python3 manage.py dbshell
......
sqlite> .tables
auth_group                    blog_blog
auth_group_permissions        django_admin_log
auth_permission               django_content_type
auth_user                     django_migrations
auth_user_groups              django_session
auth_user_user_permissions    

以上で一通りのマイグレーションは終わりました。

今回は簡単にでしたがマイグレーションファイル(移行ファイル)に重きを置いてエラーの対応方法などを実装してきました。

マイグレーションコマンドのオプションなどを使うことによってさらに柔軟な操作ができるかと思います。

なので興味を持った方はどんどん極めていきましょう。

最後までご覧いただきありがとうございました。