Идемпотентные дата-миграции

Дата-миграции часто используются для добавления новых записей. Если делать это с помощью метода create, то запустить её повторно будет весьма проблематично. Каждый новый запуск создает в таблице всё больше дублей. Если миграция не добавляет записи, а удаляет их, то проблемой станут вызовы get — при повторном запуске они ничего не найдут и сломают код.

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

Как делать не надо

У вас есть сайт школьной библиотеки и вы хотите пополнить базу новой коллекцией книг. От администратора приходит текстовый файл с новыми книгами, вы садитесь писать дата-миграцию:

from django.db import migrations


def load_books(apps, schema_editor):
    Book = apps.get_model('books', 'Book')
    with open('books.txt', 'r') as file:
        new_books = file.readlines()

    for title in new_books:
        Book.objects.create(title=title)


class Migration(migrations.Migration):

    dependencies = [
        ('appname', '0009_auto'),
    ]

    operations = [
        migrations.RunPython(load_books),
    ]

Код запустился, книги добавлены в тестовую БД, всё готово. Вы обновляете код на сервере, запускаете migrate, и, на всякий случай, заглядываете в админку проверить что всё в порядке. Как оказалось, заглянули вы не зря — администратор библиотеки не стал вас ждать и пока вы писали миграцию сам загрузил в базу пару десятков книг из файла books.txt. Не все книги в books.txt оказались новыми, в базе появились десятки дублей, все их придется удалять вручную. Ох уж этот администратор, чёрт его дернул лезть в базу без спроса ...

Но корень проблемы не администраторе. Между моментом написания дата-миграции и её запуском всегда проходит время, иногда довольно много. В коде миграции вы не можете рассчитывать на наличие или обязательное отсутствие нужных вам записей. С базой данных работает много людей и все их действия вы не проконтролируете.

Новый вариант

Чтобы избежать дублирования записей вы можете сналача проверить их наличие в БД, прежде чем вызывать create:

def load_books(apps, schema_editor):
    Book = apps.get_model('books', 'Book')
    with open('books.txt', 'r') as file:
        new_books = file.readlines()

    for title in new_books:
        if not Book.objects.filter(title=title).exists():
            Book.objects.create(title=title)

Этот подход мешает плодиться дублям и надежно решает проблему. А чтобы не писать каждый раз одни и те же проверки воспользуйтесь специальным методом get_or_create, он всё сделает за вас:

def load_books(apps, schema_editor):
    ...
    for title in new_books:
        Book.objects.get_or_create(title=title)

У этого подхода есть еще одно преимущество — он позволяет запускать дата-миграции многократно без предварительной очистки БД от старых записей. Это очень удобно при отладке в сочетании с migrate --fake.

Сложный случай

Модель данных редко хранит одно единственное поле title, обычно там еще много чего есть: автор книги, издательство, обложка, год издательства и не только. Рассмотренный пример кода с новыми книгами слишком упрощен, но метод get_or_create может справиться и со сложными случаями. Если уникальными полями будет сочетание "название — год", то код будет выглядеть так:

def load_books(apps, schema_editor):
    ...
    for title, year, pages_number in new_books:
        Book.objects.get_or_create(title=title, year=year, defaults={
            'pages_number': pages_number,
            'present': True,
        })

Дата-миграция сначала будет искать в базе данных книгу с указанным title и year. Если найдет, то на этом всё и закончится. Если нет — создаст новую книгу с указанными title, year и со всеми полями из словаря defaults.

Помимо get_or_create Django предлагает похожий метод update_or_create. Работает он ровно так, как обещает его название. И это прекрасно.

Что почитать