Как модифицировать базу данных после миграции в Django

Spread the love

Оригинальная статья: Adam JohnsonHow to Add Database Modifications Beyond Migrations to Your Django Project

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

  • Управление хранимыми процедурами (stored procedures)
  • Управление проверочными ограничениями (check constraints)
  • Импорт статических данных из файла
  • Запись операций миграции в лог

Давайте рассмотрим три возможных способа, сделать это максимально аккуратно.

1. Использовать миграции Django

Часто я нахожу, что разработчиков учат только тому, как использовать миграции Django для операций с моделями. Они могут знать некоторые SQL команды, но, поскольку они не используют их в Django, они предполагают, что миграции не могут напрямую использовать SQL команды. Но они могут! Многие из применений пользовательского кода SQL в стиле миграции, которые я видел, могли бы быть лучше реализованы в рамках миграций.

Давайте посмотрим на пример, который мы будем использовать для остальной части статьи. Представьте, что вы используете версию Django до 2.2, которая не поддерживает database check constraints. Вы можете добавить такое ограничение в таблицу базы данных используя следующий SQL:

ALTER TABLE
  myapp_book
ADD
  CONSTRAINT percent_lovers_haters_sum CHECK (
    (percent_lovers + percent_haters) = 100
  );

Это ограничение заставит базу данных выдавать ошибку для любых строк, добавленных или обновленных в таблице myapp_book, у которых сумма percent_lovers и percent_haters не равна 100.

При использовании именования таблиц по умолчанию в Django таблица myapp_book будет создана для модели под названием Book внутри приложения myapp. Мы будем использовать эти имена и в этой статье.

Вышеприведенный SQL может быть выполнен с использованием django.db.connection:

from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("""
        ALTER TABLE
          myapp_book
        ADD
          CONSTRAINT percent_lovers_haters_sum CHECK (
            (percent_lovers + percent_haters) = 100
          );
        """)

Это работает, но это немного не типично. Вы можете запустить его с помощью manage.py shell или лучше в custom management (пользовательской команде управления) (подробнее об этом позже!).

Или вместо этого мы можем использовать миграцию.

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

$ python manage.py makemigrations --empty myapp
Migrations for 'myapp':
  myapp/migrations/0101_auto_20190715_1057.py

Затем отредактируем этот файл миграции с использованием операции RunSQL:

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('myapp', '0100_add_book'),
    ]

    operations = [
        migrations.RunSQL("""
            ALTER TABLE
              myapp_book
            ADD
              CONSTRAINT percent_lovers_haters_sum CHECK (
                (percent_lovers + percent_haters) = 100
              );
        """)
    ]

Еще желательно переименовать файл миграции во что-то описательное. Например, вместо 0101_auto_20190715_1057.py мы могли бы переименовать в 0101_add_book_percentage_sum_constraint.py. Это очень поможет в долгосрочной перспективе.

Чтобы еще улучшить миграцию, мы можем добавить аргумент reverse_sql. Он говорит Django, как обратить вспять процесс миграции. Вам редко понадобится отменять миграцию, но когда вам это понадобиться… он реально спасет положение!

В нашем примере мы можем расширить операцию миграции до:

migrations.RunSQL(
    sql="""
        ALTER TABLE
          myapp_book
        ADD
          CONSTRAINT percent_lovers_haters_sum CHECK (
            (percent_lovers + percent_haters) = 100
          );
    """,
    reverse_sql="ALTER TABLE myapp_book DROP CONSTRAINT percent_lovers_haters_sum",
)

Это удалит проверочное ограничение при отмене операции.

Если код, который вы хотите выполнить, является более сложным, например, если вы хотите выполнить что-то для каждой модели/таблицы, вы можете использовать операцию RunPython. С его помощью вы можете делать практически все что угодно!

Оценка данного подхода

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

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

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

2. Переопределение команды ‘migrate’

Это похоже на секретную функцию, учитывая, как мало проектов, которые я видел, используют ее. Тем не менее, я нашел его удобным в нескольких случаях.

Django позволяет вам переопределить команды управления, добавив еще одну с тем же именем. Вы можете переопределить команды из ядра Django или из других приложений. Это задокументировано в «пользовательских командах управления».

При запуске команды Django просматривает все приложения в INSTALLED_APPS, а затем в ядре. Первым делом он найдет пользовательскую команду управления с заданным именем. Таким образом, ваши приложения могут переопределять любые встроенные модули.

Чтобы переопределить migrate и добавить собственное поведение, вам нужно создать myapp/management/commands/migrate.py (заменив myapp именем одного из ваших приложений). Внутри этого файла вы можете создать подкласс встроенной команды migrate и добавить собственное поведение. Например:

from django.core.management.commands.migrate import Command as CoreMigrateCommand
from myapp.db import create_constraints


class Command(CoreMigrateCommand):
    def handle(self, *args, **options):
        # Do normal migrate
        super().handle(*args, **options)

        # Then our custom extras
        create_constraints()

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

Переопределение работает, потому что во всем коде Django миграция вызывается как команда, а не импортируется напрямую. Это делается с помощью функции call_command. Таким образом, если вы переопределили migrate, вызовется новая версия вместо основной.

Мы можем увидеть это в тестовом фреймворковом коде Django. Его функция setup_databases вызывает метод каждого соединения create_test_db. Они, в свою очередь, запускают call_command(‘migrate’) следующим образом:

# We report migrate messages at one level lower than that requested.
# This ensures we don't get flooded with messages during testing
# (unless you really ask to be flooded).
call_command(
    'migrate',
    verbosity=max(verbosity - 1, 0),
    interactive=False,
    database=self.connection.alias,
    run_syncdb=True,
)

Вы можете переопределить любую встроенную команду.

Оценка данного подхода

Этот подход более гибкий, чем использование RunSQL в миграциях. Мы можем добавить любой код, который мы хотим, до или после выполнения migrate, или даже «во время» с помощью диспетчера контекста.

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

Наличие одного «проектного приложения» вполне рабочий выбор — я поддерживаю рекомендацию из неофициального Kristian Glass’ Unofficial FAQ. Если у вас уже есть несколько приложений, вы можете сделать одно из них «основным», и пусть оно будет содержать пользовательскую команду migrate, а импортировать код из других приложений. Это будет просто отличный подход.

Тем не менее, иногда мы хотим создавать более слабые связи, например, при создании сторонних пакетов. Итак, давайте посмотрим на окончательный подход.

3. Добавление сигнала post_migrate

Этот подход еще более продвинутый. Он использует сигналы Django, которые имеют неоднозначную репутацию из-за их «действия на расстоянии» (action at a distance).

Django отправляет сигнал post_migrate в самом конце операций миграции. Вы можете увидеть это в исходном коде migrate.

Чтобы запустить какой-то дополнительный код на этом этапе, запишите его как обработчик сигнала. Зарегистрировать обработчика сигнала лучше всего в методе AppConfig.ready(), который Django будет вызывать во время инициализации.

Например, давайте посмотрим на Django contenttypes framework. Он импортируется из django.contrib.contenttypes. И использует обработчик сигнала post_migrate для создания одного экземпляра ContentType для каждой модели.

Обработчик create_contenttypes зарегистрирован в AppConfig.ready() примерно так:

class ContentTypesConfig(AppConfig):
    name = 'django.contrib.contenttypes'
    verbose_name = _("Content Types")

    def ready(self):
        pre_migrate.connect(inject_rename_contenttypes_operations, sender=self)
        post_migrate.connect(create_contenttypes)
        checks.register(check_generic_foreign_keys, checks.Tags.models)
        checks.register(check_model_name_lengths, checks.Tags.models)

Обработчик определен в management/__init__.py. Это не самое описательное имя файла, содержащее обработчик сигнала — я обычно использую handlers.py в app. У фреймворка contenttypes есть это по историческим причинам.

В Django 2.2.3 create_contenttypes определяется следующим образом:

def create_contenttypes(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, apps=global_apps, **kwargs):
    """
    Create content types for models in the given app.
    """
    if not app_config.models_module:
        return

    app_label = app_config.label
    try:
        app_config = apps.get_app_config(app_label)
        ContentType = apps.get_model('contenttypes', 'ContentType')
    except LookupError:
        return

    content_types, app_models = get_contenttypes_and_models(app_config, using, ContentType)

    if not app_models:
        return

    cts = [
        ContentType(
            app_label=app_label,
            model=model_name,
        )
        for (model_name, model) in app_models.items()
        if model_name not in content_types
    ]
    ContentType.objects.using(using).bulk_create(cts)
    if verbosity >= 2:
        for ct in cts:
            print("Adding content type '%s | %s'" % (ct.app_label, ct.model))

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

Интересно посмотреть на аргументы функции.

Обработчики сигналов вызываются только с ключевыми словами. Для прямой совместимости они должны принимать любые дополнения в конце в **kwargs, чтобы нераспознанные аргументы, добавленные отправителем, не нарушали обработчик — таким образом создается более слабая связь.

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

  • app_config — это текущий AppConfig — сигнал отправляется один раз для каждого приложения.
  • verbosity — текущий уровень ведения журнала.
  • interactive говорит нам, безопасно ли спрашивать пользователя о вводе.
  • using — псевдоним подключения к базе данных, который будет отличаться от DEFAULT_DB_ALIAS при использовании нескольких баз данных.
  • apps — реестр приложений, содержащий определенное состояние всех моделей после выполнения миграций. Поскольку пользователь, возможно, не выполнил каждую доступную миграцию, его следует использовать для доступа к классам моделей вместо прямого импорта.

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

Во-первых, app_config может использоваться для ограничения вашего обработчика на запуск только для определенного приложения или набора приложений. Вы также можете сделать это с помощью аргумента отправителя для Signal.connect().

Во-вторых, стоит всегда описывать using для любых операции с базой данных, которые вы используете. Даже если ваш проект в настоящее время использует одну базу данных, в будущем это может измениться.

Оценка данного подхода

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

Заключение

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

Была ли вам полезна эта статья?
[3 / 3.7]

Spread the love
Подписаться
Уведомление о
guest
0 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments