Python初心者がDjangoでハマったこと

# はじめに

~~「Djangoだったら社内に有識者がたくさんいる」って言ってたじゃないですかっー!!!!~~

[フューチャー Advent Calendar 2019](https://qiita.com/advent-calendar/2019/future) 10日目の記事です。
今年のアドベントカレンダーはガチネタ多すぎて怖いわ...

今回は、golangとかrailsとか書いてた私が、
初Pythonで~~突貫で~~システム構築することになり、ハマったことを公開していきます。

※なお、本記事で出てくるsqlは一部formatしてます

## 1行で

Djangoのmodelはdefault等の設定をDBに反映してくれない！

## 前提環境

環境は、[Python Django入門 (1)](https://qiita.com/kaki_k/items/511611cadac1d0c69c54)を(6)の記事まで順にこなした前提とします。
公式リファレンスじゃないけど良記事でした。
出来上がったものがhttps://github.com/kakky/mybook20 においてあるのも嬉しいですね。
(以下検証はこのコミットで実施してます。https://github.com/kakky/mybook20/commit/82e741652bfd7f82f5c0bc601e04b7585632d266)

上記をcloneしてきた場合は、事前準備として、以下を実行しておきましょう。

```
$ python manage.py migrate
$ python manage.py createsuperuser
$ python manage.py runserver
```

ここで躓いた場合は、前提となっている記事をご参照ください。
上記コマンドが書いてあるのはこの記事です。
[Python Django入門 (3)](https://qiita.com/kaki_k/items/7b178ad39394a031b50d)

# 本題

## Djangoのmodelはdefault等の設定をDBに反映してくれない

### 準備

`cms/models.py` の`class Book`を以下のように変更しましょう。

```cms/models.py
class Book(models.Model):
    """書籍"""
    name = models.CharField('書籍名', max_length=255, unique=True)
    publisher = models.CharField('出版社', max_length=255)
    page = models.IntegerField('ページ数', blank=True, default=0)
    on_sale = models.BooleanField('販売中', default=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.name
```

`created_at`と`updated_at`を追加し、insert, update時にそれぞれ現在時刻を埋め、`on_sale`を追加しデフォルト値をTrueにしておきます。

さらに、`name`に `unique=True`を付け足しています。
なお、全てのカラムはデフォルトで、`null=False` となっており、not null制約の挙動となります。

modelを変更したので、以下のようにmigrationファイルを作成します。
初期値を埋めろと怒られるので、適当に`timezone.now`としておきます。

```
$ python manage.py makemigrations
You are trying to add the field 'created_at' with 'auto_now_add=True' to book 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
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
```

migrationをかけます。

```
python manage.py migrate
```

### 動作確認

http://127.0.0.1:8000/admin/ に移動し、Bookを追加します。(詳細省略)

![success.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68104/bc61e106-2bcf-de3b-7da7-fda16158ca22.png)

DBの中身も見てみましょう。

```
$ sqlite> select * from cms_book;
id|name|page|created_at|on_sale|updated_at|publisher
1|悪魔の寵児|100|2019-12-09 13:03:29.102973|1|2019-12-09 13:03:29.132151|角川文庫
```

`created_at` updated_at入っており、良さそうです。※`on_sale`は1でTrueを表します。
ではここで、DBに直接insertしてみましょう。

```
sqlite> insert into cms_book(name, publisher, page) values('悪魔の手毬唄', '集英社', 200);
Error: NOT NULL constraint failed: cms_book.created_at
```

なぜか、現在時刻が自動的に入るはずの`created_at`がnot null制約に違反していると怒られます。
そこで、`created_at`, `updated_at`をCURRENT_TIMESTAMPで埋めてみます。

```
sqlite> insert into cms_book(
    name,
    publisher,
    page,
    created_at,
    updated_at
)
values(
    '悪魔の手毬唄',
    '集英社',
    200,
    CURRENT_TIMESTAMP,
    CURRENT_TIMESTAMP
)
;
Error: NOT NULL constraint failed: cms_book.on_sale
```

今度は、default値を指定したはずのon_saleがnullだと怒られます。
仕方ないので、`on_sale`も値を明示的に指定してやります。

```
sqlite> insert into cms_book(
    name,
    publisher,
    page,
    on_sale,
    created_at,
    updated_at
)
values(
    '悪魔の手毬唄',
    '集英社',
    200,
    0,
    CURRENT_TIMESTAMP,
    CURRENT_TIMESTAMP
)
;
sqlite> select * from cms_book;
id|name|page|created_at|on_sale|updated_at|publisher
1|悪魔の寵児|100|2019-12-09 13:03:29.102973|1|2019-12-09 13:03:29.132151|角川文庫
2|悪魔の手毬唄|200|2019-12-09 13:12:17|0|2019-12-09 13:12:17|集英社
```

今度はうまくinsert出来ました。
__なぜ、`default`や、`auto_now_add`, `auto_now`を設定したカラムで`NOT NULL constraint`が発生したのでしょうか？__
順に見ていきます。

```
sqlite> .schema cms_book
CREATE TABLE IF NOT EXISTS "cms_book"(
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "name" varchar(255) NOT NULL UNIQUE,
    "page" integer NOT NULL,
    "created_at" datetime NOT NULL,
    "on_sale" bool NOT NULL,
    "updated_at" datetime NOT NULL,
    "publisher" varchar(255) NOT NULL
)
;
```

テーブル定義の時点では、既に、`default`と`auto_now`系は落ちてしまっています。
migrationファイルと生成されたsqlを確認します。

```cms/migrations/0002_auto_20191209_2202.py
    operations = [
        migrations.AddField(
            model_name='book',
            name='created_at',
            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
            preserve_default=False,
        ),
        migrations.AddField(
            model_name='book',
            name='on_sale',
            field=models.BooleanField(default=True, verbose_name='販売中'),
        ),
        migrations.AddField(
            model_name='book',
            name='updated_at',
            field=models.DateTimeField(auto_now=True),
        ),
        migrations.AlterField(
            model_name='book',
            name='name',
            field=models.CharField(max_length=255, unique=True, verbose_name='書籍名'),
        ),
        migrations.AlterField(
            model_name='book',
            name='publisher',
            field=models.CharField(max_length=255, verbose_name='出版社'),
        ),
    ]
```

```
$ python manage.py showmigrations
admin
 [X] 0001_initial
(省略)
cms
 [X] 0001_initial
 [X] 0002_auto_20191209_2202
(省略)
$ python manage.py sqlmigrate cms 0002_auto_20191209_2202
BEGIN;
(省略)
--
-- Alter field publisher on book
--
CREATE TABLE "new__cms_book"(
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "name" varchar(255) NOT NULL UNIQUE,
    "page" integer NOT NULL,
    "created_at" datetime NOT NULL,
    "on_sale" bool NOT NULL,
    "updated_at" datetime NOT NULL,
    "publisher" varchar(255) NOT NULL
)
;
INSERT INTO "new__cms_book"(
    "id",
    "name",
    "page",
    "created_at",
    "on_sale",
    "updated_at",
    "publisher"
)
SELECT
    "id",
    "name",
    "page",
    "created_at",
    "on_sale",
    "updated_at",
    "publisher"
FROM
    "cms_book"
;
DROP TABLE "cms_book"
;
ALTER TABLE "new__cms_book" RENAME TO "cms_book"
;
COMMIT
;
```

migrationファイルの方では、`default=True`のように表記が残っているのが確認出来ます.
生成されるsqlの方は長いので省略しましたが、(あとなんでわざわざcreate, drop, alterしてるのかは知らんが)
注目いただきたいのは以下の部分です。

```sql
CREATE TABLE "new__cms_book"(
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "name" varchar(255) NOT NULL UNIQUE,
    "page" integer NOT NULL,
    "created_at" datetime NOT NULL,
    "on_sale" bool NOT NULL,
    "updated_at" datetime NOT NULL,
    "publisher" varchar(255) NOT NULL
)
```

見事に、`default`値や`auto_now`が落ちてしまっています。
ちなみに、この挙動はsqliteだけでなくpostgresql等でも同様の挙動でした。

### 解決策

当初、ググってもなかなか見つからなかったのですが、この現象についてはこちらのstackoverflowで言及されていました。
https://stackoverflow.com/questions/53706125/django-default-at-database

意訳すると、「modelとDBスキーマは別モン。not nullとuniqueは適用してやるけどdefaultは無理やわ！」...ってことらしいです。

1つのDBを複数のアプリケーションから参照・更新したり、手パッチ当てたりする場合は少なくないと思うのですが、Djangoユーザは困らないんでしょうかね...
回避策としては、modelから生成されたDDLではなく、地道にERDからDDL(生SQL)を起こすのが良さそうです。

# 最後に

これだけ普及しているFWでこんなに実用性に問題のある挙動が許されているのか...と驚愕しました。
トラブル解決には、生のSQLが読めるなど、低レイヤーな部分も抑えておくことがより大事だと再認しました。