[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

Djangoのモデルであとからユニーク + NOT NULLな列を追加する

Djangoはモデルの内容が変更されても、makemigrationsしてmigrateすればDBにモデルの変更を反映できるので楽でいいですね。しかし、ちょっとユニーク制約をもったカラムを追加しようとしたら簡単にはいかないケースに遭遇したのでメモを残しておきます。

遭遇したケース

こんなモデルが最初にあったとします。

from django.db import models


class Product(models.Model):

    name = models.CharField(verbose_name='商品名', max_length=100)
    price = models.IntegerField(verbose_name='価格')

    def __str__(self):
        return '{}: {}'.format(self.id, self.name)

また、すでにエントリが2件存在しています。

In [6]: Product.objects.all()
Out[6]: <QuerySet [<Product: 1: prod1>, <Product: 2: prod2>]>

これに以下の様にユニークでNOT NULLな属性を追加しようとします。

from django.db import models


class Product(models.Model):

    name = models.CharField(verbose_name='商品名', max_length=100)
    price = models.IntegerField(verbose_name='価格')
    # 新しく追加したい
    product_no = models.IntegerField(verbose_name='商品番号', unique=True)

    def __str__(self):
        return '{}: {}'.format(self.id, self.name)

何が起きるのか

とりあえずmakemigrationsしてみると、当然ですが既存データへに設定されるデフォルト値の指定を求められます。

(django111) denzownoMacBook-Pro:sampledjango denzow$ python manage.py makemigrations app1
You are trying to add a non-nullable field 'product_code' to product 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

とりあえず固定で999とかイレてみます。

(django111) denzownoMacBook-Pro:sampledjango denzow$ python manage.py makemigrations app1
You are trying to add a non-nullable field 'product_code' to product 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
>>> 999
Migrations for 'app1':
  app1/migrations/0003_product_product_code.py
    - Add field product_code to product

とりあえずマイグレーションファイルは作成されました。migrateしてみます。

(django111) denzownoMacBook-Pro:sampledjango denzow$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, app1, auth, contenttypes, sessions
Running migrations:
  Applying app1.0003_product_product_code...Traceback (most recent call last):
  File "/Users/denzow/miniconda3/envs/django111/lib/python3.6/site-packages/django/db/backends/utils.py", line 65, in execute
    return self.cursor.execute(sql, params)
  File "/Users/denzow/miniconda3/envs/django111/lib/python3.6/site-packages/django/db/backends/sqlite3/base.py", line 328, in execute
    return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: UNIQUE constraint failed: app1_product.product_code
:
:

あっけなく死にました。あたりまえですが、すでに存在する2行に対していずれも999を指定するような処理になりますので、2行目に999を設定した時点でユニーク制約に抵触してしまうからです。実際に生成されているマイグレーションファイルも以下の様になっています。

class Migration(migrations.Migration):

    dependencies = [
        ('app1', '0002_auto_20171223_0442'),
    ]

    operations = [
        migrations.AddField(
            model_name='product',
            name='product_code',
            field=models.CharField(default=999, max_length=100, unique=True, verbose_name='商品コード'),
            preserve_default=False,
        ),
    ]

Djangoのmodelのdefaultは関数を指定することもできますので、以下のようにuuidを生成させるようにしてみます。

from __future__ import unicode_literals
import uuid

from django.db import migrations, models


def get_uuid():
    """
    uuidを戻す
    """
    uuid_ = uuid.uuid4()
    print('\n### set default', uuid_)
    return uuid_


class Migration(migrations.Migration):

    dependencies = [
        ('app1', '0002_auto_20171223_0442'),
    ]

    operations = [
        migrations.AddField(
            model_name='product',
            name='product_no',
            # デフォルトをuuidに指定している
            field=models.CharField(default=get_uuid, max_length=100, unique=True, verbose_name='商品コード'),
            preserve_default=False,
        ),
    ]

これなら動きそうなので、migrateしてみます。

(django111) denzownoMacBook-Pro:sampledjango denzow$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, app1, auth, contenttypes, sessions
Running migrations:
  Applying app1.0003_product_product_code...
### set default 4cb51acd-2b6b-41ae-bfb0-084d90ec969e  ★★
  Traceback (most recent call last):
  File "/Users/denzow/miniconda3/envs/django111/lib/python3.6/site-packages/django/db/backends/utils.py", line 65, in execute
    return self.cursor.execute(sql, params)
  File "/Users/denzow/miniconda3/envs/django111/lib/python3.6/site-packages/django/db/backends/sqlite3/base.py", line 328, in execute
    return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: UNIQUE constraint failed: app1_product.product_code

あっけなくUNIQUE constraint failedで死にました。★の箇所をみると、get_uuidが実行されているようですが、2行あるにも関わらず1回しか実行されていません。実はmigrate時はdefaultに指定した関数は一度しか評価されない様になっているのです。そのため、関数を指定しても既存行には同じ値が入るので回避できません。

結局どうしろと

実はDjangoのマニュアルにずばりこのケースの話が書いてあります。

内容を簡単にまとめると以下を実行するマイグレーションファイルを作成するというものです。

  • unique=True ではなく null=Trueを指定して列を追加
  • 既存列に一意な値を設定
  • 追加した列にunique=Trueを追加

実際にやってみましょう。ドキュメントでは空のマイグレーションファイルを複数作り、上述の3ステップごとのマイグレーションファイルを作る流れになっていますが、まとめることもできますのでそちらで対応します。

まずマイグレーションファイルを作ります。

(django111) denzownoMacBook-Pro:sampledjango denzow$ python manage.py makemigrations app1
You are trying to add a non-nullable field 'product_no' to product 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
>>> 999
Migrations for 'app1':
  app1/migrations/0003_product_product_no.py
    - Add field product_no to product

作成されたapp1/migrations/0003_product_product_no.pyを開き編集します。

# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-12-23 05:38
from __future__ import unicode_literals

from django.db import migrations, models

class Migration(migrations.Migration):

    dependencies = [
        ('app1', '0002_auto_20171223_0442'),
    ]

    operations = [
        migrations.AddField(
            model_name='product',
            name='product_code',
            field=models.CharField(default=999, max_length=100, null=True, verbose_name='商品コード'),  # null=Trueに書き換え
            preserve_default=False,
        ),
    ]

これで、null値はそのままになるのでエラーはでません。続いて既存の行に商品コードを追加する処理set_default_codeを追加します。

+def set_default_code(apps, schema_editor):
+    """
+    既存の行に対して商品コードを C-商品名というルールで追加する関数
+    """
+    product_model = apps.get_model('app1', 'Product')
+    for row in product_model.objects.all():
+        row.product_code = 'C-{}'.format(row.name)
+        row.save()


class Migration(migrations.Migration):

    dependencies = [
        ('app1', '0002_auto_20171223_0442'),
    ]

    operations = [
        migrations.AddField(
            model_name='product',
            name='product_code',
            field=models.CharField(max_length=100, null=True, verbose_name='商品コード'),  # null=Trueとして列追加
            preserve_default=False,
        ),
+        migrations.RunPython(set_default_code, reverse_code=migrations.RunPython.noop),   # 既存行に一意なデータ追加
    ]

これで既存の行はユニーク制約に違反しない状態のデータに変更されます。最後はproduct_codeの列にunique=Trueの属性を追加します。

class Migration(migrations.Migration):

    dependencies = [
        ('app1', '0002_auto_20171223_0442'),
    ]

    operations = [
        migrations.AddField(
            model_name='product',
            name='product_code',
            field=models.CharField(max_length=100, null=True, verbose_name='商品コード'),  # null=Trueとして列追加
            preserve_default=False,
        ),
        migrations.RunPython(set_default_code, reverse_code=migrations.RunPython.noop),   # 既存行に一意なデータ追加
+        migrations.AlterField(
+            model_name='product',
+            name='product_code',
+            field=models.CharField(max_length=100, unique=True, null=False, blank=False, verbose_name='商品コード'),  # ユニーク制約を追加
+            preserve_default=False,
+        ),

    ]

では実際に実行してみます。

(django111) denzownoMacBook-Pro:sampledjango denzow$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, app1, auth, contenttypes, sessions
Running migrations:
  Applying app1.0003_product_product_code... OK

無事に実行できたようです。データを確認してみます。

In [2]: for row in Product.objects.all():
   ...:     print(row.name, row.product_code)
   ...:
prod1 C-prod1
prod2 C-prod2

新しい商品にすでに存在するC-prod2を指定してみます。

In [2]: Product(name='prod3', price=300, product_code='C-prod2').save()
---------------------------------------------------------------------------
IntegrityError                            Traceback (most recent call last)
~/miniconda3/envs/django111/lib/python3.6/site-packages/django/db/backends/utils.py in execute(self, sql, params)
     64             else:
---> 65                 return self.cursor.execute(sql, params)
     66

~/miniconda3/envs/django111/lib/python3.6/site-packages/django/db/backends/sqlite3/base.py in execute(self, query, params)
    327         query = self.convert_query(query)
--> 328         return Database.Cursor.execute(self, query, params)
    329

IntegrityError: UNIQUE constraint failed: app1_product.product_code

ちゃんとユニーク制約で弾いてくれました。これで無事に作業が完了です。