Большой гайд по миграциям в Django: готовимся к миграциям и избегаем конфликтов

от автора

Привет! Меня зовут Макс, я backend-разработчик в компании idaproject и автор YouTube-канала PyLounge.

Я всегда хотел создавать контент, который пригодился бы мне самому в прошлом или настоящем. Эта большая статья — не исключение. Она для тех, кто только начинает изучение Django: жалею, что когда я начинал, мне не попался подобный материал. Надеюсь, он станет для вас хорошим подспорьем. 

Кому-то всё сказанное здесь покажется очевидным, но я всегда придерживался принципа — «то что очевидно мне или вам, не всегда очевидно другому».

Что будет? Я расскажу, что такое миграции, зачем они нужны, как подготовиться к работе с ними и провести базовую работу на Django; отдельно подсвечу тему конфликтов и схлопываний и покажу, как содержать в чистоте историю миграций. 

Всё это с примерами на практике и иллюстрациями. Погнали!

Важный момент

Что нужно перед изучение материала:

  • иметь базовое представление о базах данных и SQL (рекомендую неплохую книгу и ещё одну);

  • хоть чуть-чуть уметь пользоваться git.

Дисклеймер: все примеры специально упрощены, чтобы неокрепший ум выцепил концепции, а не детали реализации. Не бейте, или бейте там, где синяков не видно 🙂

P.S. При написании материала ни одна база данных не пострадала.

Что такое миграции и зачем они нужны

Для начала договоримся об основных понятиях, чтобы мы вели разговор на одном языке. Чаще всего в Django мы работаем с реляционными базами данных (далее БД), с которыми взаимодействуем через системы управления базами данных (далее СУБД): например, PostgreSQL, MySQL или SQLite. NoSQL тоже встречаются, но реже.

В реляционной БД данные хранятся в таблицах. Таблица БД имеет определенное количество столбцов и может иметь любое количество строк (записей). Каждый столбец отвечает за одно свойство или характеристику того, что мы описываем в таблице. Кроме того, каждый столбец имеет тип данных, например, строку, положительное целое число, дробное число и т.д. Описание таблиц и их столбцов с типами данных называется схемой базы данных.

Представим таблицу Сотрудников. Здесь каждая строка хранит информацию об одном конкретном сотруднике, а каждый столбец отражает какую-то его характеристику (например, титул в компании, имя, уникальный номер и т.д.).

ID

Имя

Компания

Титул

1

Максим

idaproject

Страж Западных пределов

2

Гендальф

idaproject

Серый

3

Егор

idaproject

Атанатар

Для создания, чтения, обновления и удаления данных в реляционных БД, а также для создания, изменения и удаления самих таблиц используется язык SQL. Так, представленную выше таблицу Сотрудников можно создать и наполнить данными с помощью двух SQL-запросов:

-- Запрос для создания таблицы CREATE TABLE employees (     id INT PRIMARY KEY,     name VARCHAR(255),     company VARCHAR(255),     title VARCHAR(255) );  -- Запрос для вставки данных INSERT INTO employees (id, name, company, title) VALUES (1, 'Максим', 'idaproject', 'Страж Западных пределов'), (2, 'Гендальф', 'idaproject', 'Серый');

Однако работать напрямую с SQL может быть не всегда удобно.

Во-первых, писать запросы надо уметь и практиковать, что отрывает от работы на выбранном языке (например Python). К тому же, написание сложных запросов на чистом SQL иногда может стать нетривиальной задачей.

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

Поэтому для современных веб-фреймворков (в том числе и для Django) изобрели ORM (объектно-реляционное отображение). Напоминаю, что реляционные БД используют таблицы, а в языках программирования есть объекты. Следовательно, ORM позволяет связать объекты конкретного языка программирования с таблицами в БД.

Вместо создания таблиц БД через SQL-запросы мы можем использовать модели Django, которые представлены специальными классами языка Python. Модели представляют собой таблицы (кроме случаев, когда модель является proxy или абстрактной) и определяют свойства (атрибуты), которые соответствуют столбцам в соответствующих таблицах БД.

Вот тот же пример создания таблицы Сотрудников, но уже с использованием Django ORM:

from django.db import models  # описываем модель (таблицу) class Employee(models.Model):     name = models.CharField("Имя", max_length=255)     company = models.CharField("Компания", max_length=255)     title = models.CharField("Титул", max_length=255)      # потом заполняем данные Employee.objects.create(id=1, name='Максим', company='idaproject', title='Страж Западных пределов') Employee.objects.create(id=2, name='Гендальф', company='idaproject', title='Серый')

Но когда мы просто описываем класс Employee, никаких изменений с БД не происходит. Питоновский код нельзя просто взять и применить к БД, поскольку БД понимает только язык SQL и его диалекты (T-SQL, PL/SQL, MySQL и прочие). Нам нужен инструмент, который позволит перевести Python-код с описанием моделей в SQL-код и отдать его на выполнение СУБД. Таким инструментом являются миграции.

Чтобы класс Employee превратился в таблицу и сохранился в БД, нужно выполнить следующую последовательность команд в терминале:

# создаём миграцию, которая описывает добавление в БД таблицы Сотрудники  # на основе класса Employee python manage.py makemigrations  # применяет изменения из миграции на БД python manage.py migrate

Когда мы вносим изменения в модель (например, добавляем поле, изменяем тип данных столбца, удаляем столбец и т.д.), база данных также должна быть своевременно изменена. Тут тоже нужны миграции.

Миграции — это описания моделей и их изменений, которые в дальнейшем перекладываются на схему базы данных.

Мы сами можем просмотреть итоговые файлы миграций. Они располагаются в директории migrations внутри приложений (app).

При запуске команды makemigrations, мы сообщаем системе миграций Django, что внесли изменения в модели и теперь хотим, чтобы описание этих изменений сохранились в файл миграции. Потом применим изменения из файла миграций на БД через команду migrate.

В примере выше, когда мы запустили makemigrations, создаётся такой файл миграции:

# Generated by Django 4.0 on 2024-07-11 00:00  from django.db import migrations, models  class Migration(migrations.Migration):      dependencies = [     ]      operations = [         migrations.CreateModel(             name='Employee',             fields=[                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),                 ('name', models.CharField(max_length=255, verbose_name='Имя')),                 ('company', models.CharField(max_length=255, verbose_name='Компания')),                 ('title', models.CharField(max_length=255, verbose_name='Титул')),             ],         ),     ]

Когда мы захотим применить эту миграцию, этот файл будет переведён в SQL-запрос, который внесет изменения в БД.

Можно как применить миграцию, чтобы обновить схему до нового состояния, так и откатить миграцию, чтобы вернуться к предыдущему состоянию схемы.

На все изменения в джанго-моделях необходимо создавать миграции. Множество миграций образуют граф миграций. Это создаёт историю изменений схемы базы.

Подытожим пользу миграций:

  1. Обеспечивают синхронизацию моделей django и таблиц базы данных

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

  1. Избавляют от необходимости напрямую взаимодействовать с БД и SQL

Теперь для дальнейшего погружения нам необходимо провести некоторую подготовительную работу.

Подготовительная работа

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

Создадим общую директорию проекта с помощью пакетного менеджера poetry (аналог pip):

$ poetry new idashop $ tree idashop  idashop ├── idashop │   └── __init__.py ├── pyproject.toml ├── README.md └── tests     └── __init__.py  2 directories, 4 files

tree — утилита для просмотра содержимого директорий, возможно, предварительно её придётся установить.

Переходим в папку idashop, активируем виртуальное окружение и устанавливаем зависимости (django):

$ cd idashop $ poetry shell  Creating virtualenv idashop-XU5c4BC3-py3.10 in /home/pylounge/.cache/pypoetry/virtualenvs Spawning shell within /home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10 . /home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/bin/activate  $ poetry add django

Переименовываем внутреннюю директорию idashop в src и переходим в неё:

$ mv idashop src $ cd src  . └── idashop     ├── poetry.lock     ├── pyproject.toml     ├── README.md     ├── src     │   └── __init__.py     └── tests         └── __init__.py

Создаем проект Django внутри папки src:

$ django-admin startproject config .

Убедимся, что всё работает. Для этого запустим проект, и если в браузере по адресу 127.0.0.1:8000 есть приветственное сообщение, значит, всё хорошо:

$ python manage.py runserver

Остановим стандартный django-сервер сочетанием клавиш Сtrl + С.

Поднимемся на одну директорию выше и инициализируем git.

$ cd .. $ git init

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

Переходим в директорию src и создаём в ней ещё одну директорию apps с __init__.py файлом:

$ cd src $ mkdir apps $ touch apps/__init__.py

Создадим приложение realty, где будет сосредоточена логика по работе с недвижимостью в нашем проекте:

$ mkdir apps/realty $ python manage.py startapp realty apps/realty $ tree  . ├── apps │   ├── __init__.py │   └── realty │       ├── admin.py │       ├── apps.py │       ├── __init__.py │       ├── migrations │       │   └── __init__.py │       ├── models.py │       ├── tests.py │       └── views.py ├── config │   ├── asgi.py │   ├── __init__.py │   ├── __pycache__ │   │   ├── __init__.cpython-310.pyc │   │   ├── settings.cpython-310.pyc │   │   ├── urls.cpython-310.pyc │   │   └── wsgi.cpython-310.pyc │   ├── settings.py │   ├── urls.py │   └── wsgi.py ├── db.sqlite3 ├── __init__.py └── manage.py  5 directories, 20 files

Отредактируем файл apps/realty/apps.py (в свойство name добавим корневую папку apps):

from django.apps import AppConfig   class RealtyConfig(AppConfig):     default_auto_field = 'django.db.models.BigAutoField'     name = 'apps.realty'

Затем добавим приложение realty в массив INSTALLED_APPS файла config/settings.py:

INSTALLED_APPS = [     'django.contrib.admin',     'django.contrib.auth',     'django.contrib.contenttypes',     'django.contrib.sessions',     'django.contrib.messages',     'django.contrib.staticfiles',     # apps     'apps.realty.apps.RealtyConfig', ]

По аналогии с realty нужно сделать ещё одно приложение — Застройщики (developers).

Теперь создадим базовые миграции, которые есть по умолчанию в любом django-проекте:

$ python manage.py migrate  Operations to perform:   Apply all migrations: admin, auth, contenttypes, sessions Running migrations:   Applying contenttypes.0001_initial... OK   Applying auth.0001_initial... OK   Applying admin.0001_initial... OK   Applying admin.0002_logentry_remove_auto_add... OK   Applying admin.0003_logentry_add_action_flag_choices... OK   Applying contenttypes.0002_remove_content_type_name... OK   Applying auth.0002_alter_permission_name_max_length... OK   Applying auth.0003_alter_user_email_max_length... OK   Applying auth.0004_alter_user_username_opts... OK   Applying auth.0005_alter_user_last_login_null... OK   Applying auth.0006_require_contenttypes_0002... OK   Applying auth.0007_alter_validators_add_error_messages... OK   Applying auth.0008_alter_user_username_max_length... OK   Applying auth.0009_alter_user_last_name_max_length... OK   Applying auth.0010_alter_group_name_max_length... OK   Applying auth.0011_update_proxy_permissions... OK   Applying auth.0012_alter_user_first_name_max_length... OK   Applying sessions.0001_initial... OK

И, конечно, не забудем добавить пользователя-администратора:

$ python manage.py createsuperuser  Username (leave blank to use 'pylounge'): pylounge Email address: pylounge@idaproject.com Password:  Password (again):  This password is too common. This password is entirely numeric. Bypass password validation and create user anyway? [y/N]: y Superuser created successfully.

Базовая настройка завершена. Открываем проект в любимом редакторе/IDE и приступаем к работе. Вообще можно сделать себе шаблон проекта с помощью cookiecutter, но об этом как-нибудь в следующий раз 🙂

Если у вас PyCharm, то открываем через него директорию idashop.

Переходим в настройки → Project → Python Interpretator → Virtualenv Enviroment → Existing и указываем путь до интерпритатора, которы высветился после команды poetry shell. В моём случае: /home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/bin/python3.10.

Выбор интерпритатора для проекта

Выбор интерпретатора для проекта

Базовая работа с миграциями

Создадим первую модель для работы с квартирами (Flat):

from django.db import models   class Flat(models.Model):     article = models.CharField("Артикул", max_length=32)     area = models.FloatField("Площадь",)     price = models.IntegerField("Цена", default=0, blank=True)      class Meta:         indexes = [models.Index(fields=["article"])]         verbose_name = "Квартира"         verbose_name_plural = "Квартиры"

Теперь создадим миграцию (makemigrations) и применим её (migrate):

python manage.py makemigrations — ищет изменения по всем приложениям и создает миграции

python manage.py makemigrations <приложение> — ищет изменения по моделям внутри конкретного приложения и создает миграции

python manage.py migrate — применяет неприменённые миграции

$ python manage.py makemigrations realty  Migrations for 'realty':   apps/realty/migrations/0001_initial.py     - Create model Flat  $ python manage.py migrate  Operations to perform:   Apply all migrations: admin, auth, contenttypes, realty, sessions Running migrations:   Applying realty.0001_initial... OK

В папке с миграциями (apps/realty/migrations) появился файл миграции 0001_initial.py

Инициальная миграция (initial migration) — это первая миграция, созданная для приложения, которое ранее не имело миграций. Она содержит начальное состояние всех моделей этого приложения (имеет свойство initial=True).

Можно посмотреть SQL-запрос, который выполняется при применении нашей миграции во время migrate. Сделаем это с помощью команды sqlmigrate:

python manage.py sqlmigrate <приложение> <название миграции>

К слову, необязательно полностью писать название миграции. Если в папке migrations нет других миграций, которые начинаются с 0001, то Джанго поймёт о каком файле речь.

$ python manage.py sqlmigrate realty 0001  BEGIN; -- -- Create model Flat -- CREATE TABLE "realty_flat" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,  "article" varchar(32) NOT NULL,  "area" real NOT NULL,  "price" integer NOT NULL );  CREATE INDEX "realty_flat_article_f5f3ca_idx" ON "realty_flat" ("article");  COMMIT;

Внутри транзакции BEGIN; COMMIT; с помощью команды CREATE TABLE происходит создание таблицы со столбцами — как в описании модели. Затем создаётся индекс для поля Артикул. Все действия происходят в рамках одной транзакции, поскольку по умолчанию в транзакциях выставлен флаг atomic=True.

Транзакции — это большая отдельная тема. Подробнее про них можно почитать здесь.

Можем сравнить SQL-код с кодом миграции на Python:

# Generated by Django 5.0.7 on 2024-07-15 13:11  from django.db import migrations, models   class Migration(migrations.Migration):      initial = True      dependencies = [     ]      operations = [         migrations.CreateModel(             name='Flat',             fields=[                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),                 ('article', models.CharField(max_length=32, verbose_name='Артикул')),                 ('area', models.FloatField(verbose_name='Площадь')),                 ('price', models.IntegerField(blank=True, default=0, verbose_name='Цена')),             ],             options={                 'verbose_name': 'Квартира',                 'verbose_name_plural': 'Квартиры',                 'indexes': [models.Index(fields=['article'], name='realty_flat_article_f5f3ca_idx')],             },         ),     ]

По аналогии создадим модель Застройщика (и не забываем про миграции):

from django.db import models   class Developer(models.Model):     title = models.CharField("Артикул", max_length=32)      class Meta:         verbose_name = "Застройщик"         verbose_name_plural = "Застройщики"
$ python manage.py makemigrations developers  Migrations for 'developers':   apps/developers/migrations/0001_initial.py     - Create model Developer      $ python manage.py sqlmigrate developers 0001  BEGIN; -- -- Create model Developer -- CREATE TABLE "developers_developer" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,  "title" varchar(32) NOT NULL ); COMMIT;  $ python manage.py migrate   Operations to perform:   Apply all migrations: admin, auth, contenttypes, developers, realty, sessions Running migrations:   Applying developers.0001_initial... OK

Мы получили файл миграции developers.0001_initial и применили его изменения.

Записи о примененных миграциях хранятся в таблице django_migrations. Можно подключиться к БД и через команду SELECT посмотреть список, а можно воспользоваться командой showmigrations:

$ python manage.py showmigrations  admin  [X] 0001_initial  [X] 0002_logentry_remove_auto_add  [X] 0003_logentry_add_action_flag_choices auth  [X] 0001_initial  [X] 0002_alter_permission_name_max_length  [X] 0003_alter_user_email_max_length  [X] 0004_alter_user_username_opts  [X] 0005_alter_user_last_login_null  [X] 0006_require_contenttypes_0002  [X] 0007_alter_validators_add_error_messages  [X] 0008_alter_user_username_max_length  [X] 0009_alter_user_last_name_max_length  [X] 0010_alter_group_name_max_length  [X] 0011_update_proxy_permissions  [X] 0012_alter_user_first_name_max_length contenttypes  [X] 0001_initial  [X] 0002_remove_content_type_name developers  [X] 0001_initial realty  [X] 0001_initial sessions  [X] 0001_initial

У нас есть несколько стандартных приложений, а именно admin, auth, contenttypes и session, которые добавляются по умолчанию при создании django-проекта. Они также имеет свой набор миграций. Кроме того, мы видим два наших приложения с миграциями:

developers  [X] 0001_initial realty  [X] 0001_initial

Здесь крестик (Х) означает, что миграции применены в БД.

Теперь в модель Flat добавим связь 1 ко многим с Developers, так как у одного застройщика может быть много квартир, но у одной квартиры может быть только один застройщик:

class Flat(models.Model):     article = models.CharField("Артикул", max_length=32)     area = models.FloatField("Площадь",)     price = models.IntegerField("Цена", default=0, blank=True)      developer = models.ForeignKey(         'developers.Developer',         verbose_name="Застройщик",         related_name='flats',         on_delete=models.CASCADE,         blank=True,         null=True,     )      class Meta:         indexes = [models.Index(fields=["article"])]         verbose_name = "Квартира"         verbose_name_plural = "Квартиры"

Создадим миграцию, но перед этим проверим, есть ли другие незаписанные в миграции изменения:

$ python manage.py makemigrations realty --check  Migrations for 'realty':   apps/realty/migrations/0002_flat_developer.py     - Add field developer to flat

Так мы видим, что потенциально будет создана миграция 0002_flat_developer, в которой будет добавлено после developer в модель Flat:

$ python manage.py makemigrations realty  Migrations for 'realty':   apps/realty/migrations/0002_flat_developer.py     - Add field developer to flat      $ python manage.py sqlmigrate realty 0002  BEGIN; -- -- Add field developer to flat -- ALTER TABLE "realty_flat" ADD COLUMN  "developer_id" bigint NULL REFERENCES "developers_developer" ("id") DEFERRABLE INITIALLY DEFERRED; CREATE INDEX "realty_flat_developer_id_38a22c85" ON "realty_flat" ("developer_id"); COMMIT;

В папке миграций появился ещё один файл, но уже с номером 0002:

Перед применением изменений проверим таблицу миграций:

$ python manage.py showmigrations  admin  [X] 0001_initial  [X] 0002_logentry_remove_auto_add  [X] 0003_logentry_add_action_flag_choices auth  [X] 0001_initial  [X] 0002_alter_permission_name_max_length  [X] 0003_alter_user_email_max_length  [X] 0004_alter_user_username_opts  [X] 0005_alter_user_last_login_null  [X] 0006_require_contenttypes_0002  [X] 0007_alter_validators_add_error_messages  [X] 0008_alter_user_username_max_length  [X] 0009_alter_user_last_name_max_length  [X] 0010_alter_group_name_max_length  [X] 0011_update_proxy_permissions  [X] 0012_alter_user_first_name_max_length contenttypes  [X] 0001_initial  [X] 0002_remove_content_type_name developers  [X] 0001_initial realty  [X] 0001_initial  [ ] 0002_flat_developer sessions  [X] 0001_initial

Тут мы видим, что миграция 0002_flat_developer еще не применена, значит эффекта на БД она не оказала. Применим её:

 $ python manage.py migrate    Operations to perform:   Apply all migrations: admin, auth, contenttypes, developers, realty, sessions Running migrations:   Applying realty.0002_flat_developer... OK    $ python manage.py showmigrations admin  [X] 0001_initial  [X] 0002_logentry_remove_auto_add  [X] 0003_logentry_add_action_flag_choices auth  [X] 0001_initial  [X] 0002_alter_permission_name_max_length  [X] 0003_alter_user_email_max_length  [X] 0004_alter_user_username_opts  [X] 0005_alter_user_last_login_null  [X] 0006_require_contenttypes_0002  [X] 0007_alter_validators_add_error_messages  [X] 0008_alter_user_username_max_length  [X] 0009_alter_user_last_name_max_length  [X] 0010_alter_group_name_max_length  [X] 0011_update_proxy_permissions  [X] 0012_alter_user_first_name_max_length contenttypes  [X] 0001_initial  [X] 0002_remove_content_type_name developers  [X] 0001_initial realty  [X] 0001_initial  [X] 0002_flat_developer sessions  [X] 0001_initial

Миграции можно дать любое название с помощью команды:

python manage.py makemigrations <приложение> —name <имя> 

Например, python manage.py makemigrations realty --name add_developer_to_flat. Но лично у нас в команде договорённость оставлять стандартные названия.

Давайте рассмотрим миграцию 0002_flat_developer поближе:

# Generated by Django 5.0.7 on 2024-07-15 14:33  import django.db.models.deletion from django.db import migrations, models   class Migration(migrations.Migration):      dependencies = [         ('developers', '0001_initial'),         ('realty', '0001_initial'),     ]      operations = [         migrations.AddField(             model_name='flat',             name='developer',             field=models.ForeignKey(                 blank=True,                 null=True,                 on_delete=django.db.models.deletion.CASCADE,                 related_name='flats',                 to='developers.developer',                 verbose_name='Застройщик',             ),         ),     ]

Мы видим новое свойство dependencies в котором перечислены две миграции в формате — (’приложение’, ‘миграция’):

dependencies = [         ('developers', '0001_initial'),         ('realty', '0001_initial'),     ]

dependencies — это список миграций, которые обязательно нужно применить перед текущей миграцией.

Звучит разумно, ведь мы не можем добавить поле developer типа Developer из миграции  (‘developers’, ‘0001_initial’), в модель Flat из миграции  (‘realty’, ‘0001_initial’). Значит, наша миграция 0002_flat_developer должна быть применена строго после этих двух.

В то же время две 0001 миграции имеют у себя пустой dependencies и не зависят от других.

Таким образом, у нас получается своего рода дерево миграций, а если быть точнее — направленный ациклический граф (DAG).

Одна миграция (узел) может быть связана с другими миграциями через dependencies. Эти линии связей образуют своего рода ребра.

Миграции применяются в порядке их dependencies, а если нет dependencies, то они применяются в алфавитном порядке (с учетом номеров).

Визуализируем наш пример:

0002_flat_developer зависит от двух миграций от developers/0001_initial и от realty/0001_initial.

Кроме того, мы помним, что у нас был применён ряд стандартных миграций.

Если откатить все миграции и накатить их снова, то мы увидим определенный порядок применения.

python manage.py migrate <приложение> zero — откатить все миграции приложения

python manage.py migrate <приложение> <название миграции> — откатить все миграции до миграции с определённым названием, включая её.

Становится понятно, что python manage.py migrate developers zero — это сокращение для python manage.py migrate developers 0001_initial

python manage.py migrate realty zero python manage.py migrate developers zero python manage.py migrate sessions zero python manage.py migrate admin zero python manage.py migrate auth zero python manage.py migrate contenttypes zero  python manage.py migrate

Следим за порядком применения миграций:

  Applying contenttypes.0001_initial... OK   Applying auth.0001_initial... OK   Applying admin.0001_initial... OK   Applying admin.0002_logentry_remove_auto_add... OK   Applying admin.0003_logentry_add_action_flag_choices... OK   Applying contenttypes.0002_remove_content_type_name... OK   Applying auth.0002_alter_permission_name_max_length... OK   Applying auth.0003_alter_user_email_max_length... OK   Applying auth.0004_alter_user_username_opts... OK   Applying auth.0005_alter_user_last_login_null... OK   Applying auth.0006_require_contenttypes_0002... OK   Applying auth.0007_alter_validators_add_error_messages... OK   Applying auth.0008_alter_user_username_max_length... OK   Applying auth.0009_alter_user_last_name_max_length... OK   Applying auth.0010_alter_group_name_max_length... OK   Applying auth.0011_update_proxy_permissions... OK   Applying auth.0012_alter_user_first_name_max_length... OK   Applying developers.0001_initial... OK   Applying realty.0001_initial... OK   Applying realty.0002_flat_developer... OK   Applying sessions.0001_initial... OK

Миграции применяются по алфавиту, но если у миграции есть dependencies, то сначала применяются они, а потом дальше по алфавиту.

Проследим порядок снизу вверх:

По алфавиту первой должна примениться миграция admin.0001_initial, но если мы заглянем в код миграции, то увидим зависимости (dependencies), которые должны примениться раньше:

import django.contrib.admin.models from django.conf import settings from django.db import migrations, models   class Migration(migrations.Migration):     dependencies = [         migrations.swappable_dependency(settings.AUTH_USER_MODEL),         ("contenttypes", "__first__"),     ]

При использовании migrations.swappable_dependency(settings.AUTH_USER_MODEL) Django создает зависимость от миграций текущей активной модели пользователя. Важно использовать swappable_dependency, потому что это гарантирует корректную работу миграции независимо от того, используется ли модель пользователя по умолчанию или мы написали свою.

Значит, у нас в зависимостях должны быть миграции для работы с пользователями (это приложение auth) и зависимости из contenttypes (а именно первая миграция оттуда — __first__).

Первая миграция auth.0001_initial также в зависимостях использует contenttypes:

import django.contrib.auth.models from django.contrib.auth import validators from django.db import migrations, models from django.utils import timezone   class Migration(migrations.Migration):     dependencies = [         ("contenttypes", "__first__"),     ]

Поэтому как на рисунке, так и в логах после migrate мы видим, что сначала применяется первая миграция из contenttypes:

import django.contrib.contenttypes.models from django.db import migrations, models   class Migration(migrations.Migration):     dependencies = []

Эта миграция зависимостей не имеет. Значит, следующей будет auth.0001_initial. В свою очередь она в dependencies имеет только инициальную миграцию contenttypes, которая уже применялась. Поэтому идём дальше:

Applying contenttypes.0001_initial... OK Applying auth.0001_initial... OK Applying admin.0001_initial... OK

Отмечу еще раз: хоть по алфавиту admin.0001_initial идет первой, сначала применялись все миграции из цепочки зависимостей, которые перечислены в admin.0001_initial dependencies. Затем идёт admin.0002_logentry_remove_auto_add. У неё в зависимостях только предыдущая миграция из admin и т.д.

from django.db import migrations, models from django.utils import timezone   class Migration(migrations.Migration):     dependencies = [         ("admin", "0001_initial"),     ]

Потом, спускаясь по алфавиту, мы дойдём и до миграций из наших созданных приложений:

  Applying developers.0001_initial... OK   Applying realty.0001_initial... OK   Applying realty.0002_flat_developer... OK

Визуализация миграций сделана с помощью библиотеки django-migrations-graph

Кроме блока dependencies в миграциях присутствует блок operations. В нём содержится описание всех изменений, которые нужно провести в БД, например, AddField для добавления столбца:

# Generated by Django 5.0.7 on 2024-07-15 14:33  import django.db.models.deletion from django.db import migrations, models   class Migration(migrations.Migration):      dependencies = [         ('developers', '0001_initial'),         ('realty', '0001_initial'),     ]      operations = [         migrations.AddField(             model_name='flat',             name='developer',             field=models.ForeignKey(                 blank=True,                 null=True,                 on_delete=django.db.models.deletion.CASCADE,                 related_name='flats',                 to='developers.developer',                 verbose_name='Застройщик',             ),         ),     ]

Эти изменения потом будут переведены в SQL-код.

Конфликт миграций

Допустим, в рамках некоторой задачи №133 нам необходимо добавить в модель Developer поле для хранения номера ИНН. Создадим новую ветку в git для работы с задачей и там уже добавим необходимое поле и миграцию.

$ git switch -c 133-add-inn-field  Switched to a new branch '133-add-inn-field'  $ git branch  * 133-add-inn-field   master
from django.db import models   class Developer(models.Model):     title = models.CharField("Артикул", max_length=32)     inn = models.CharField(verbose_name="ИНН", max_length=12, blank=True)      class Meta:         verbose_name = "Застройщик"         verbose_name_plural = "Застройщики"
$ python manage.py makemigrations developers  Migrations for 'developers':   apps/developers/migrations/0002_developer_inn.py     - Add field inn to developer

В результате мы получили миграцию 0002_developer_inn.

Сделаем коммит и запушим наши изменения:

$ git add src/apps/developers/migrations/0002_developer_inn.py src/apps/developers/models.py $ git commit -m "добавил поле инн и миграцию"  [133-add-inn-field e8e8b48] добавил поле инн и миграция  2 files changed, 19 insertions(+)  create mode 100644 src/apps/developers/migrations/0002_developer_inn.py    $ git push --set-upstream origin 133-add-inn-field

Всё, наши изменения сделаны и пока что живут в отдельной ветке 133-add-inn-field.

Параллельно нам прилетела ещё одна задача — в модель Developer на поле title надо повесить индекс, чтобы ускорить поиск застройщиков (задача №134).

Аналогично создаём отдельную ветку, где будем работать, и там производим наши манипуляции:

$ git switch master $ git switch -c 134-add-developer-title-index  Switched to a new branch '134-add-developer-title-index'  $ git branch    133-add-inn-field * 134-add-developer-title-index   master
from django.db import models   class Developer(models.Model):     title = models.CharField("Артикул", max_length=32)      class Meta:         indexes = [models.Index(fields=["title"])]         verbose_name = "Застройщик"         verbose_name_plural = "Застройщики"
$ python manage.py makemigrations developers  Migrations for 'developers':   apps/developers/migrations/0002_developer_developers__title_0428ce_idx.py     - Create index developers__title_0428ce_idx on field(s) title of model developer      $ git add src/apps/developers/migrations/0002_developer_developers__title_0428ce_idx.py src/apps/developers/models.py $ git commit -m "добавил индекс на поле title и миграцию" $ git push --set-upstream origin 134-add-developer-title-index

Появилась миграция 0002_developer_developers__title_0428ce_idx.

Задачи выполнены, теперь их осталось слить из наших отдельных фича-веток в одну — в master-ветку.

$ git switch master $ git merge 133-add-inn-field $ git merge 134-add-developer-title-index

Проверим таблицу миграций:

python manage.py showmigrations  admin  [X] 0001_initial  [X] 0002_logentry_remove_auto_add  [X] 0003_logentry_add_action_flag_choices auth  [X] 0001_initial  [X] 0002_alter_permission_name_max_length  [X] 0003_alter_user_email_max_length  [X] 0004_alter_user_username_opts  [X] 0005_alter_user_last_login_null  [X] 0006_require_contenttypes_0002  [X] 0007_alter_validators_add_error_messages  [X] 0008_alter_user_username_max_length  [X] 0009_alter_user_last_name_max_length  [X] 0010_alter_group_name_max_length  [X] 0011_update_proxy_permissions  [X] 0012_alter_user_first_name_max_length contenttypes  [X] 0001_initial  [X] 0002_remove_content_type_name developers  [X] 0001_initial  [ ] 0002_developer_developers__title_0428ce_idx  [ ] 0002_developer_inn realty  [X] 0001_initial  [X] 0002_flat_developer sessions  [X] 0001_initial

Видим, что ещё не успели применить две наших свежесозданных миграции:

[ ] 0002_developer_developers__title_0428ce_idx [ ] 0002_developer_inn

Запустим migrate и получим ошибку:

$ python manage.py migrate  CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph: (0002_developer_developers__title_0428ce_idx, 0002_developer_inn in developers). To fix them run 'python manage.py makemigrations --merge'

Ошибка, которую мы видим, возникает из-за конфликта миграций в Django. В частности, у нас есть две миграции с одним и тем же номером (0002), что приводит к конфликту в графе миграций.

Две или более миграции с одинаковым номером (в данном случае 0002), но с разными изменениями, поэтому Django не знает, какую миграцию применять первой, и возникает неоднозначность.

Чтобы решить конфликт, необходимо выполнить команду makemigrations с флагом—merge:

$ python manage.py makemigrations --merge  Merging developers   Branch 0002_developer_developers__title_0428ce_idx     - Create index developers__title_0428ce_idx on field(s) title of model developer   Branch 0002_developer_inn     - Add field inn to developer  Merging will only work if the operations printed above do not conflict with each other (working on different fields or models) Should these migration branches be merged? [y/N] y  Created new merge migration /home/pylounge/Desktop/migration_habr/idashop/src/apps/developers/migrations/0003_merge_20240716_0544.py   $ python manage.py migrate  Operations to perform:   Apply all migrations: admin, auth, contenttypes, developers, realty, sessions Running migrations:   Applying developers.0002_developer_inn... OK   Applying developers.0002_developer_developers__title_0428ce_idx... OK   Applying developers.0003_merge_20240716_0544... OK

Это команда создаст мерж-миграцию 0003_merge_20240716_0544, которая устранит неоднозначность и в своих dependencies задаст порядок применения миграций:

# Generated by Django 5.0.7 on 2024-07-16 05:44  from django.db import migrations   class Migration(migrations.Migration):      dependencies = [         # сохраняем алфавитный порядок         ('developers', '0002_developer_developers__title_0428ce_idx'),         ('developers', '0002_developer_inn'),     ]      operations = [     ]

Мерж-миграция — миграция, которая создается для разрешения конфликта миграций и задаёт последовательность применения миграций с одинаковыми номерами через свои зависимости dependencies.

Как мы видим, созданная мерж-миграция явно задала порядок применения миграций и сняла неопределённость у Django.

dependencies = [         ('developers', '0002_developer_developers__title_0428ce_idx'),         ('developers', '0002_developer_inn'),     ]

Проверим таблицу миграций и визуализацию:

$ python manage.py showmigrations  admin  [X] 0001_initial  [X] 0002_logentry_remove_auto_add  [X] 0003_logentry_add_action_flag_choices auth  [X] 0001_initial  [X] 0002_alter_permission_name_max_length  [X] 0003_alter_user_email_max_length  [X] 0004_alter_user_username_opts  [X] 0005_alter_user_last_login_null  [X] 0006_require_contenttypes_0002  [X] 0007_alter_validators_add_error_messages  [X] 0008_alter_user_username_max_length  [X] 0009_alter_user_last_name_max_length  [X] 0010_alter_group_name_max_length  [X] 0011_update_proxy_permissions  [X] 0012_alter_user_first_name_max_length contenttypes  [X] 0001_initial  [X] 0002_remove_content_type_name developers  [X] 0001_initial  [X] 0002_developer_inn  [X] 0002_developer_developers__title_0428ce_idx  [X] 0003_merge_20240716_0544 realty  [X] 0001_initial  [X] 0002_flat_developer sessions  [X] 0001_initial

Отмечу, что создание мерж-миграций это один из способов решения конфликта миграций.

Некоторые команды придерживаются иного подхода — не допускать конфликтов 🙂

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

Как содержать историю миграций в чистоте при решении конфликтов

Если вы остановились на подходе с использованием мерж-миграций, то существует большая вероятность накопить много мерж-миграций при разработке фичи «в долгую» (когда она долго висит на ревью, имеет несколько итераций и т.д.). Разработчики постоянно подливают новые миграции, ваши с ними конфликтуют, и приходится создавать много-много мерж миграций. Это в итоге попадает на тестовые (и не только) среды и увеличивает нагрузку на БД, а также усложняется история миграций, увеличивается их общее количество.

Решение проблемы предложил мой коллега Илья Боюр, за что ему спасибо.

Пример приведён с использованием стандартного git flow. Когда имеется dev и prod-среда, а соответственно работа ведётся в dev и master-ветках.

Фича-ветка — это ветка от master.

Фича-дев ветка — это ветка от dev, куда подливаются изменения из фича-ветки для дальнейшей отправки на dev-стенд и тестирования.

Почитать про Git Flow можно тут.

Имеем два сценария:

1. Когда вы кидаете MR в develop, и пайплайн проходит успешно, но по какой-то стихийной причине он не вливается в develop сразу

Такое могло произойти по следующим причинам:

  • не прошел ревью

  • бизнес стоппер

  • прилетели инопланетяне

Потом мы хотим обновить МR «с теми же изменениями и так, чтоб снова миграции прошли» — не нужно делать новую merge-миграцию.

Вместо этого заменяем текущую merge-миграцию на новую. Как это сделать:

  1. Перейдите на фича-дев ветку feat/<ваша задача>-develop

  1. Подлейте (замержите) ветку develop в feat/<ваша задача>-develop

  1. Создайте merge-миграцию в feat/<ваша задача>-develop

  1. Откатите миграции до последней миграции в develop, после которой пошли ваши миграции

  1. Удалите созданную миграцию на этапе 3 и merge-миграцию с прошлого МR`а в develop (просто удали файлы, ведь вы уже откатили изменения из БД)

  1. Создайте миграцию в feat/<ваша задача>-develop — она будет последней в списке миграций

2. До слияния первого МРа фича-ветки ваш код ещё не в дев-ветке. Если нет миграций данных (то есть все миграции автоматические)

  1. откатите все миграции в вашей ветке (feat/<номер>)

  1. удалите все миграции вашей ветки

  1. создайте одну миграцию на МР

P. S. Так у вас в идеале может быть всего две миграции:

  • 1-я миграция в фича-ветке (feat/<номер>)

  • 2-я миграция в фича-дев ветке (feat/<<ваша задача>-develo)

Инструкция касается одного django приложения. Соответственно в каждом приложении алгоритм повторяется

Еще один подход в избавлении от накопления миграций — сквошинг миграций.

Схлопывание миграций

Допустим, бизнес решили добавить рейтинг каждому застройщику. Изначально договорились, что рейтинг может быть только положительным целым числом:

from django.db import models   class Developer(models.Model):     title = models.CharField("Артикул", max_length=32)     inn = models.CharField(verbose_name="ИНН", max_length=12, blank=True)     rating = models.PositiveSmallIntegerField(verbose_name="Рейтинг", default=0)      class Meta:         indexes = [models.Index(fields=["title"])]         verbose_name = "Застройщик"         verbose_name_plural = "Застройщики"
$ python manage.py makemigrations developers  Migrations for 'developers':   apps/developers/migrations/0004_developer_rating.py     - Add field rating to developer      $ python manage.py migrate  Operations to perform:   Apply all migrations: admin, auth, contenttypes, developers, realty, sessions Running migrations:   Applying developers.0004_developer_rating... OK

Во время работы пришли правки от бизнеса: «Рейтинг может быть отрицательным!». Придётся поменять поле:

from django.db import models   class Developer(models.Model):     title = models.CharField("Артикул", max_length=32)     inn = models.CharField(verbose_name="ИНН", max_length=12, blank=True)     rating = models.SmallIntegerField(verbose_name="Рейтинг", default=0)      class Meta:         indexes = [models.Index(fields=["title"])]         verbose_name = "Застройщик"         verbose_name_plural = "Застройщики"
$ python manage.py makemigrations developers  Migrations for 'developers':   apps/developers/migrations/0005_alter_developer_rating.py     - Alter field rating on developer      $ python manage.py migrate  Operations to perform:   Apply all migrations: admin, auth, contenttypes, developers, realty, sessions Running migrations:   Applying developers.0005_alter_developer_rating... OK

Бог любит троицу, поэтому чуть менее своевременно, но более очевидно для бизнеса становится, что рейтинг должен быть дробным и отрицательным. Поменяем еще раз:

from django.db import models   class Developer(models.Model):     title = models.CharField("Артикул", max_length=32)     inn = models.CharField(verbose_name="ИНН", max_length=12, blank=True)     rating = models.FloatField(verbose_name="Рейтинг", default=0.0)      class Meta:         indexes = [models.Index(fields=["title"])]         verbose_name = "Застройщик"         verbose_name_plural = "Застройщики"
$ python manage.py makemigrations developers  Migrations for 'developers':   apps/developers/migrations/0006_alter_developer_rating.py     - Alter field rating on developer  $ python manage.py migrate  Operations to perform:   Apply all migrations: admin, auth, contenttypes, developers, realty, sessions Running migrations:   Applying developers.0006_alter_developer_rating... OK

По итогу на одно изменение модели мы создали три файла миграций:

developers  [X] 0001_initial  [X] 0002_developer_inn  [X] 0002_developer_developers__title_0428ce_idx  [X] 0003_merge_20240716_0544  [X] 0004_developer_rating  [X] 0005_alter_developer_rating  [X] 0006_alter_developer_rating
# Generated by Django 5.0.7 on 2024-07-16 07:10  from django.db import migrations, models   class Migration(migrations.Migration):      dependencies = [         ('developers', '0003_merge_20240716_0544'),     ]      operations = [         migrations.AddField(             model_name='developer',             name='rating',             field=models.PositiveSmallIntegerField(default=0, verbose_name='Рейтинг'),         ),     ]    # Generated by Django 5.0.7 on 2024-07-16 07:13  from django.db import migrations, models   class Migration(migrations.Migration):      dependencies = [         ('developers', '0004_developer_rating'),     ]      operations = [         migrations.AlterField(             model_name='developer',             name='rating',             field=models.SmallIntegerField(default=0, verbose_name='Рейтинг'),         ),     ]    # Generated by Django 5.0.7 on 2024-07-16 07:15  from django.db import migrations, models   class Migration(migrations.Migration):      dependencies = [         ('developers', '0005_alter_developer_rating'),     ]      operations = [         migrations.AlterField(             model_name='developer',             name='rating',             field=models.FloatField(default=0.0, verbose_name='Рейтинг'),         ),     ]

Если изменения не попали в «боевую» базу данных и данных еще нет, то мы могли спокойно откатить миграции до 0004_developer_rating (включая её) с помощью команды:

python manage.py migrate developers 0004_developer_rating

А затем просто удалить файлы миграций 0004, 0005 и 0006 и ещё раз сделать makemigrations и migrate, чтобы зафиксировать итоговое состояние.

Но когда изменения уже применены имеет смысл просто засквошить миграции.

Сквошинг миграций — это сокращение существующего количества миграций до одной (или иногда нескольких) миграций, которые по-прежнему представляют те же самые изменения. Мы буквально схлопываем несколько файлов миграций в один с учётом некоторых оптимизаций, которые происходят под капотом системы миграций Django.

Засквошим миграции 0004, 0005 и 0006 в одну.

python manage.py squashmigrations <приложение> <от какой миграции свкошить>  <до какой>

$ python manage.py squashmigrations developers 0004 0006   Will squash the following migrations:  - 0004_developer_rating  - 0005_alter_developer_rating  - 0006_alter_developer_rating Do you wish to proceed? [yN] y Optimizing...   Optimized from 3 operations to 1 operations. Created new squashed migration /home/pylounge/Desktop/migration_habr/idashop/src/apps/developers/migrations/0004_developer_rating_squashed_0006_alter_developer_rating.py   You should commit this migration but leave the old ones in place;   the new migration will be used for new installs. Once you are sure   all instances of the codebase have applied the migrations you squashed,   you can delete them.   $ python manage.py migrate  Operations to perform:   Apply all migrations: admin, auth, contenttypes, developers, realty, sessions Running migrations:   No migrations to apply.     $ python manage.py showmigrations  admin  [X] 0001_initial  [X] 0002_logentry_remove_auto_add  [X] 0003_logentry_add_action_flag_choices auth  [X] 0001_initial  [X] 0002_alter_permission_name_max_length  [X] 0003_alter_user_email_max_length  [X] 0004_alter_user_username_opts  [X] 0005_alter_user_last_login_null  [X] 0006_require_contenttypes_0002  [X] 0007_alter_validators_add_error_messages  [X] 0008_alter_user_username_max_length  [X] 0009_alter_user_last_name_max_length  [X] 0010_alter_group_name_max_length  [X] 0011_update_proxy_permissions  [X] 0012_alter_user_first_name_max_length contenttypes  [X] 0001_initial  [X] 0002_remove_content_type_name developers  [X] 0001_initial  [X] 0002_developer_inn  [X] 0002_developer_developers__title_0428ce_idx  [X] 0003_merge_20240716_0544  [X] 0004_developer_rating_squashed_0006_alter_developer_rating (3 squashed migrations) realty  [X] 0001_initial  [X] 0002_flat_developer sessions  [X] 0001_initial

У нас добавился новый файл миграции 0004_developer_rating_squashed_0006_alter_developer_rating, который представляет собой сквош трех указанных файлов:

# Generated by Django 5.0.7 on 2024-07-16 07:25  from django.db import migrations, models   class Migration(migrations.Migration):      replaces = [('developers', '0004_developer_rating'), ('developers', '0005_alter_developer_rating'), ('developers', '0006_alter_developer_rating')]      dependencies = [         ('developers', '0003_merge_20240716_0544'),     ]      operations = [         migrations.AddField(             model_name='developer',             name='rating',             field=models.FloatField(default=0.0, verbose_name='Рейтинг'),         ),     ]

replaces указывает какие миграции заменила сквош миграция, а умный подкапотный оптимизатор сократил количество operations до одной итоговой.

После того, как мы выполнили сквош миграций и применили через migrate новую сквош-миграцию к БД (если предыдущие миграции не были применены по одиночке), можно удалить старые файлы миграций, объединенные в этот сквош.

В документации django рекомендуется «выполнить сквош, сохранив старые файлы, зафиксировать изменения и выпустить релиз, дождаться, пока все системы обновятся до нового релиза (или, если вы являетесь сторонним проектом, убедиться, что ваши пользователи обновляют релизы по порядку, не пропуская ни одного), а затем удалить старые файлы, зафиксировать и сделать второй релиз».

На этом мы закончим первую часть. To be continued… 

Ну а пока не вышла вторая часть — если вы нашли неточности или у вас есть какие-то замечания, предложения, то милости прошу в комментарии. 

Источники

https://docs.djangoproject.com/en/5.0/topics/migrations/

https://realpython.com/django-migrations-a-primer/

https://dvmn.org/encyclopedia/#django-migrations

https://habr.com/ru/companies/yandex/articles/745534/

https://dmkpress.com/catalog/computer/web/978-5-93700-204-4/


ссылка на оригинал статьи https://habr.com/ru/articles/861386/