Сложные случаи при миграциях

В этой статье будем разбирать сложные случаи при миграциях. Если вы не знаете, что это, вот статья о миграциях.

В процессе создания миграций вы можете увидеть сообщение ниже:

$ python3 manage.py makemigrations

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

В этой статье мы разберёмся, что это значит и что с этим делать. Рассматривать ситуацию будем на примере сайта-блога. На сайте есть модель Post с единственным полем для текста статьи:

from django.db import models

class Post(models.Model):
    text = models.TextField()

Теперь добавляем поле для заголовка статьи (title):

from django.db import models

class Post(models.Model):
    text = models.TextField()
    title = models.CharField()

После миграций база данных будет выглядять как в таблице снизу:

скриншот

При запуске makemigrations возникнет ошибка You are trying to add a non-nullable field without a default — вы пытаетесь добавить обязательное к заполнению поле title, но не указали для него значение по-умолчанию.

Для новых статей база данных будет требовать заполнения обоих полей: title и text, но не понятно что делать с теми записями, которые уже есть в базе. Им нужно добавить title, но тогда это поле окажется пустым. База данных такого не разрешит.

Способ первый: Задать значение по-умолчанию

Чтобы база данных сама заполнила пустые ячейки можно задать значение по-умолчанию. Это можно сделать двумя способами.

Одноразовое значение по-умолчанию

Это значение проставится всем незаполненным полям во время миграции. makemigrations предложит вам выбор из двух пунктов, выберите первый. Вам выдадут поле для ввода и там вы можете указать значение, которое проставится во все незаполненные ячейки таблицы. Мы указали строку "default_title":

скриншот

Теперь создастся файл миграции. В нём будет зашита эта строка: "default_title". После запуска команды migrate база данных будет выглядеть как таблица:

скриншот

Многоразовое значение по-умолчанию

Это значение задаёт title не только для старых постов, которые уже лежат в БД, но и для новых с пустым заголовком. Оно задаётся в models.py с помощью аргумента default:

from django.db import models

class Post(models.Model):
    text = models.TextField()
    title = models.CharField(default="default title")

Теперь запуск makemigrations пройдёт успешно и без лишних вопросов. Более того, если при добавлении в базу нового поста вы не укажете title, то он автоматически примет значение по-умолчанию:

>>> post = Post.objects.create(text="Текст статьи")
>>> post.text
Текст статьи
>>> post.title
default title

Способ второй: Разрешить полю быть пустым

Поле не заполнено, но, может, и фиг с ним? Некоторым полям и не нужно всегда быть заполненными. В такой ситуации вам поможет null=True и blank=True. Как это работает мы увидим, если добавим ещё одно поле published_at, в котором будет храниться время и дата публикации поста:

from django.db import models

class Post(models.Model):
    text = models.TextField()
    title = models.CharField(blank=True)
    published_at = models.DateTimeField(null=True)

Что такое null=True

Это свойство позволяет базе данных оставлять поле published_at пустым. Это пригодится, если мы добавили пост в базу данных, но ещё не опубликовали. Чтобы опубликовать пост, достаточно добавить ему текущее время в поле published_at:

>>> some_post = Post.objects.create(text="Текст этого поста")
>>> some_post.published_at
None # Сейчас не опубликован, в поле лежит None
>>> some_post.published_at = timezone.now()
>>> some_post.save()  # Теперь опубликован, в поле лежит текущее время

Крайне не рекомендуется применять это свойство к строковым полям (CharField и TextField), так как вместо None них лучше класть пустую строку. Если указать для строкового поля null=True, то оно может содержать два возможных “пустых” значения: None и пустую строку. Два варианта “пустых” значений создадут путаницу, поэтому в Django разрешают только пустую строку. В этом вам поможет blank=True.

Что такое blank=True

Если null=True управляет поведением базы данных, тоblank=True настраивает поведение админки и Django-форм. Если для поля published_at не указан blank=True, то админка запретит нам сохранить пост, и потребует заполнить поле published_at. Да, админка будет сопротивляться даже если у published_at уже есть null=True.

Способ третий: Если не хочется ставить default или null

Иногда значение по-умолчанию или null=True ставить не хочется. Например, в ситуации, когда у вас есть модель поста и модель комментария, и вы решили их связать. Изначально комментария есть только сам его текст:

from django.db import models

class Post(models.Model):
    text = models.TextField()
    title = models.CharField(blank=True)

class Comment(models.Model):
    text = models.TextField()

Теперь вы захотели добавить комментарию ссылку на пост, чтобы знать, к какому посту писался этот комментарий:

class Comment(models.Model):
    text = models.TextField()
    post = models.ForeignKey(Post, on_delete=models.CASCADE)

При запуске makemigrations возникнет ошибка из начала статьи: You are trying to add a non-nullable field without a default. Проблему можно обойти через добавление атрибутов null=True или default, но это неправильно, ведь на самом деле у комментария поле post не должно пустовать ни при каких условиях.

Эта проблема возникла из-за того что связь не была продумана на этапе проектирования моделей. Обычно все связи модели с другими стараются продумать сразу, до первой миграции.

В такой ситуации есть 2 пути: простой, но варварский и хороший, но сложный.

Простой, но варварский способ решения проблемы

Если вы только проектируете БД и в ней нет никаких важных данных, то самое простое — это удалить все миграции и саму БД. После этого смело создавайте миграции и мигрируйтесь. Получится так, будто вы с самого начала создали модель с ForeignKey, а значит и пустым это поле никогда не было.

Если вы работаете вместе с другими программистами, никогда так не делайте, потому что это вызовет у них большие проблемы: им тоже придётся удалять свои базы данных. Но если вы в проекте один, а в базе данных нет ничего важного — это хороший способ быстро всё исправить.

Сложный, но правильный способ решения проблемы

Если вы работаете с другими программистами и/или в БД уже есть важные данные, то удалять базу — не вариант. Тогда делается следующее:

1. Создайте новую модель PostComment рядом с существующей. В новой модели укажите связь — ForeignKey:

class Comment(models.Model):
    text = models.TextField()

class PostComment(models.Model):
    text = models.TextField()
    post = models.ForeignKey(Post, on_delete=models.CASCADE)

2. Сделайте и примените миграцию с помощью makemigrations и migrate.

3. Скопируйте данные из Comment в PostComment с помощью дата-миграции. Это специальная миграция, которая просто запустит тот код Python, который вы сами напишете. Вам придётся придумать способ как перенести старые данные на новую модель.

4. Запустите дата-миграцию, чтобы скопировать данные из Comment в PostComment.

5. Теперь, когда все важные данные спасены, можно удалить модель Comment. После миграции с удалением старой модели можно переименовать PostComment к старому названию Comment и провести ещё одну миграцию.