【Django - パーマリンク変更】unique + NOT NULLなカラムをあとから追加する方法

2020年6月4日

Python
Django
IT
SQL

Djangoでパーマリンク(記事のURL)を<int:pk>から<slug:slug>にあとから変更した時の話

どうも、テック備忘LOGのYuki(@tech_bibo_log)です!!

今回はDjangoで作成したブログのパーマリンクに関する記事です。
当ブログを運営しているうえでSEOの観点から分かりやすいURLにするため、 パーマリンクの変更を行いました。

当初のパーマリンク形式’/blog/post/<int:pk>から、/blog/post/<slug:slug>へ変更していきます。 また今回は、既に公開中の記事が存在する状態での変更となるので、同じ状況の方も安心です。

基本的に途中でパーマリンクを変更するのはSEO的にはおすすめされないそうですが、 運営前段の段階や、運営直後のPVがつかない段階で行うことをお勧めしておきます。

余談ですが、次の記事では当ブログを作成した時の手順について説明しています。

▽▽▽ Djangoでブログを構築した時のお話し ▽▽▽

2020/5/11

Django + Nginx + uWSGIな環境を構築する!「Hello, World」公開まで。


では早速やっていきましょう!!

前提:
1.既に公開中の記事が存在する(公開記事がなくても参考になります)

[ 目次 (開く) ]

Postモデルにunique + NOT NULLなカラムを後から追加して発生したエラー

記事のパーマリンクにスラッグを含ませたいので、<slug:slug>を扱えるようモデルにカラムを追加しました。

/hogehoge/blog(アプリ名)/models.py
...
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=30, unique=True)
content = MDTextField()
description = models.TextField(blank=True)
...


そしてmakemigrationsを実行してみると...既存データに設定されるデフォルト値を指定するよう求められます。

# python3 manage.py makemigrations blog

You are trying to add a non-nullable field 'slug' 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


デフォルト値に123をセットしたところ...
マイグレーションファイルは作成されました。

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
>>> 123
Migrations for 'blog':
  blog/migrations/0010_post_slug.py
    - Add field slug to post

このまま勢いよくmigrateを実行します。
すると既存の記事データすべてに対して同じ"123"というデフォルト値を入れようとしているので、エラーUNIQUE constraint failed:が発生して「重複してますよ」と怒られてしまいます。
今回はこの問題を解決していきます。

# python3 manage.py migrate blog
Operations to perform:
  Apply all migrations: blog
Running migrations:
  Applying blog.0010_post_slug...Traceback (most recent call last):
  ...
  ...
  File "/usr/local/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: blog_post.post_slug

今回の環境:
1. VPS(CentOS8)
2. Nginx(1.16.1)
3. Django(3.0.2)

補足:
分かりやすいように、基本コマンドを叩く際はフルパスで記載します。
※一部を除く

現状のMigrationsを確認

まず最初に、Modelを変更したときのMigrations処理が残っていないか確認しましょう。

# python3 manage.py makemigrations

No changes detected

『No changes detected』が表示されていればModelの修正はDBに適用されています。
まだ適用していないMigrationsが残っている場合は先に実行しておきましょう。

マイグレーションの確認が終われば作業の準備は完了です。


Djangoのモデルの管理、マイグレーション操作の方法については次の記事で取り扱っています。

間違えてモデルの変更をDBに適用してしまった場合や、以前の変更を元に戻したい場合は参考にしてみてくださいね。

▽▽▽ マイグレーション操作について知りたい時はこちら ▽▽▽



Postモデルにslugを追加

今回は上記のマイグレーション操作の記事でマイグレーション関係はロールバックとマイグレーションファイルの削除などを行ってエラーがでる前の状態に戻した状態で始めます。
ただし、UNIQUE constraint failedが出ている状態のマイグレーションファイルを編集する形でも問題ないので、その場合は一つ飛ばしして#2から始めてください。

1.null=Trueに変更しよう!

最初に、モデルを変更していきます。
まずは下記2点の変更を加えます。
1:unique=Truenull=Trueに変更
2:デフォルト値に123を指定

/hogehoge/blog(アプリ名)/models.py/
...
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=30, null=True, default=123) ← ここを変更
content = MDTextField()
description = models.TextField(blank=True)
...


そして、makemigtationsを実行。
python3 manage.py makemigrations blog
Migrations for 'blog':
  blog/migrations/0010_post_slug.py
    - Add field slug to post


2.既存データにslugのデータを追加する関数処理を追加しよう!

先ほど作成したマイグレーションファイルを開きます。
このままでは既存のデータにslugの値が入らないので、関数set_default_slugを作成して 各記事のタイトル名をslugのデフォルトとして入れることにします。

/hogehoge/blog(アプリ名)/models.py/
# Generated by Django 3.0.2 on 2020-xx-xx xx:xx
from django.db import migrations, models

+def set_default_slug(apps, schema_editor):  ← これを追加
+    post_model = apps.get_model('blog', 'Post')
+    for row in post_model.objects.all():
+        row.slug = '{}'.format(row.title)
+        row.save()

class Migration(migrations.Migration):
    dependencies = [
        ('blog', '0009_category_parent'),
    ]

    operations = [
        migrations.AddField(
            model_name='post',
            name='slug',
            field=models.SlugField(default='123', max_length=30, null=True),
            preserve_default=False,
        ),
+        migrations.RunPython(set_default_slug, reverse_code=migrations.RunPython.noop), ← これを追加
    ]

これで既存の記事データは、次に追加するユニーク制約に違反しない状態になります。
それでは、slugをnull=Trueからunique=True戻す処理を追加しましょう。

/hogehoge/blog(アプリ名)/models.py
# Generated by Django 3.0.2 on 2020-xx-xx xx:xx
from django.db import migrations, models

+def set_default_slug(apps, schema_editor):
    post_model = apps.get_model('blog', 'Post')
    for row in post_model.objects.all():
        row.slug = '{}'.format(row.title)
        row.save()

class Migration(migrations.Migration):
    dependencies = [
        ('blog', '0009_category_parent'),
    ]
   operations = [
        migrations.AddField(
            model_name='post',
            name='slug2',
            field=models.SlugField(default='123', max_length=30, null=True),
            preserve_default=False,
        ),
        migrations.RunPython(set_default_slug, reverse_code=migrations.RunPython.noop),

+        migrations.AlterField(  ← slugカラムを修正
+            model_name='post',
+            name='slug2',
+            field=models.CharField(max_length=30, unique=True, null=False, blank=False),  ← NOT NULLなユニーク制約を追加
+            preserve_default=False,
+        ),
    ]

これで全て完了です。
それではmigrateを実行しましょう。

# python3 manage.py migrate blog
Operations to perform:
  Apply all migrations: blog
Running migrations:
  Applying blog.0010_post_slug... OK

成功ですね!お疲れ様でした。

まとめ

今回やった作業を簡単にまとめると下記の2つになります。
一旦nullを許容するようにしておいて既存データに一意なデータを入れてあげて、最後にNOT NULL + uniqueに戻しています。

1:モデルを修正
  ⇒ unique=Truenull=Trueに変更
  ⇒ makemigrationsを実行してマイグレーションファイルを生成

2:マイグレーションファイルを修正
  ⇒ 既存データに対して、新規のslugカラムにtitleカラムのデータを挿入する関数set_default_slugを作成
  ⇒ slugカラムをnull=Trueからunique=Trueに変更(戻す)ように修正
  ⇒ migrateを実行


これでもう後からNOT NULL + uniqueなカラムを追加するときも詰まることなくなりました!良かった...(笑)
以上、ありがとうございました。