Как организовать миграцию для двух связанных моделей и автоматически установить значение поля по умолчанию для идентификатора вновь создаваемого объекта? - PullRequest
5 голосов
/ 31 мая 2019

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

Существует модель (уже в дБ), скажем, Model, она имеет внешние ключи для других моделей.

class ModelA: ...
class ModelX: ...

class Model:
  a = models.ForeignKey(ModelA, default = A)
  x = models.ForeignKey(ModelX, default = X)

И нам нужно создать еще одну модель ModelY, на которую должна ссылаться Model. И при создании Model объект должен иметь некоторое значение по умолчанию, связанное с некоторым объектом ModelY, который, очевидно, еще не доступен, но мы должны создать его во время миграции.

class ModelY: ...
class Model:
  y = models.ForeignKey (ModelY, default = ??????)

Итак, последовательность миграции должна быть:

  • Создать ModelY Таблица
  • Создайте объект по умолчанию в этой таблице, поместите его идентификатор куда-нибудь
  • Создать новое поле y в таблице Model со значением по умолчанию из предыдущего пункта

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

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

Есть ли лучшие практики для такого случая? В частности, где хранить идентификатор этого вновь созданного объекта? Какой-то выделенный стол в том же БД?

Ответы [ 3 ]

3 голосов
/ 06 июня 2019

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

Я собираюсь сослаться на два типа миграций здесь, один из них - миграция схемы , и это файлы миграции, которые вы обычно генерируете после смены моделей. Другим является миграция данных , и они должны быть созданы с использованием опции --empty команды makemigrations, например, python manage.py makemigrations my_app --empty, и используются для перемещения данных, установки данных в нулевые столбцы, которые должны быть изменены на ненулевые, и т. Д.

class ModelY(models.Model):
    # Fields ...
    is_default = models.BooleanField(default=False, help_text="Will be specified true by the data migration")

class Model(models.Model):
    # Fields ...
    y = models.ForeignKey(ModelY, null=True, default=None)

Вы заметите, что y принимает ноль, мы можем изменить это позже, сейчас вы можете запустить python manage.py makemigrations, чтобы сгенерировать миграцию схемы.

Чтобы сгенерировать первую миграцию данных, введите команду python manage.py makemigrations <app_name> --empty. Вы увидите пустой файл миграции в своей папке миграции. Вы должны добавить два метода, один из которых создаст ваш экземпляр ModelY по умолчанию и назначит его существующим экземплярам Model, а другой - метод-заглушку, поэтому Django позволит вам позже при необходимости отменить ваши миграции.

from __future__ import unicode_literals

from django.db import migrations


def migrate_model_y(apps, schema_editor):
    """Create a default ModelY instance, and apply this to all our existing models"""
    ModelY = apps.get_model("my_app", "ModelY")
    default_model_y = ModelY.objects.create(something="something", is_default=True)

    Model = apps.get_model("my_app", "Model")
    models = Model.objects.all()
    for model in models:
        model.y = default_model_y
        model.save()


def reverse_migrate_model_y(apps, schema_editor):
    """This is necessary to reverse migrations later, if we need to"""
    return


class Migration(migrations.Migration):

    dependencies = [("my_app", "0100_auto_1092839172498")]

    operations = [
        migrations.RunPython(
            migrate_model_y, reverse_code=reverse_migrate_model_y
        )
    ]

Не импортируйте напрямую ваши модели в эту миграцию! Модели должны быть возвращены с помощью метода apps.get_model("my_app", "my_model"), чтобы получить модель такой, какой она была на момент перехода. Если в будущем вы добавите больше полей и запустите эту миграцию, ваши поля моделей могут не соответствовать столбцам базы данных (потому что модель из будущего, вроде ...), и вы можете получить некоторые ошибки о пропущенных столбцах в базе данных и например. Также будьте осторожны с использованием пользовательских методов на ваших моделях / менеджерах в миграциях, потому что у вас не будет доступа к ним из этой прокси-модели, обычно я могу дублировать некоторый код для миграции, чтобы он всегда выполнялся одинаково.

Теперь мы можем вернуться и изменить модель Model, чтобы убедиться, что y не равен нулю, и что в будущем она подберет экземпляр ModelY по умолчанию:

def get_default_model_y():
    default_model_y = ModelY.objects.filter(is_default=True).first()
    assert default_model_y is not None, "There is no default ModelY to populate with!!!"
    return default_model_y.pk  # We must return the primary key used by the relation, not the instance

class Model(models.Model):
    # Fields ...
    y = models.ForeignKey(ModelY, default=get_default_model_y)

Теперь вам нужно снова запустить python manage.py makemigrations, чтобы создать другую миграцию схемы.

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

Наконец, вы можете запустить python manage.py migrate, и он должен создать объект ModelY по умолчанию, добавить его в ForeignKey вашей модели и удалить null, чтобы сделать его похожим на ForeignKey по умолчанию.

0 голосов
/ 21 июля 2019

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

Идея состоит в том, чтобы обеспечить возможность вызова по умолчанию для ForeignKey, которая создаетэкземпляр по умолчанию для ссылочной модели, если он не существует.Но проблема в том, что этот вызываемый объект может вызываться не только на завершающей стадии проекта Django, но и во время миграций, со старыми этапами проекта, поэтому его можно вызывать для удаленной модели на ранних этапах, когда модель еще существовала.

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

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

migration_apps = None


def set_migration_apps(apps):
    global migration_apps
    migration_apps = apps


def get_or_create_default(model_name, app_name):
    M = (migration_apps or django.apps.apps).get_model(app_name, model_name)

    try:
        return M.objects.get(isDefault=True).id

    except M.DoesNotExist as e:
        o = M.objects.create(isDefault=True)
        print '{}.{} default object not found, creating default object : OK'.format(model_name, app_name)
        return o


def monkey_patch_fields_operations():
    def patch(klass):

        old_database_forwards = klass.database_forwards
        def database_forwards(self, app_label, schema_editor, from_state, to_state):
            set_migration_apps(to_state.apps)
            old_database_forwards(self, app_label, schema_editor, from_state, to_state)
        klass.database_forwards = database_forwards

        old_database_backwards = klass.database_backwards
        def database_backwards(self, app_label, schema_editor, from_state, to_state):
            set_migration_apps(to_state.apps)
            old_database_backwards(self, app_label, schema_editor, from_state, to_state)
        klass.database_backwards = database_backwards

    patch(django.db.migrations.AddField)
    patch(django.db.migrations.RemoveField)

Остальное, включая модель по умолчанию с проверкой целостности данных, находится в GitHub репозитории

0 голосов
/ 10 июля 2019

Наконец-то я пришел к следующему решению.

Сначала я согласился с идеей идентифицировать объект по умолчанию по атрибуту isDefault и написал некоторую абстрактную модель, чтобы справиться с ней, максимально сохраняя целостность данных (код находится внизу поста).

Что мне не нравится в принятом решении, так это то, что миграции данных смешиваются с миграциями схемы. Их легко потерять, т. Е. Во время сквоша. Иногда я вообще удаляю миграции, когда я уверен, что все мои производственные и резервные базы данных соответствуют коду, поэтому я могу сгенерировать одну первоначальную миграцию и подделать ее. Хранение миграции данных вместе с миграцией схемы нарушает этот рабочий процесс.

Поэтому я решил сохранить все миграции данных в одном файле вне пакета migrations. Поэтому я создаю data.py в своем пакете приложения и помещаю все миграции данных в одну функцию migratedata, имея в виду, что эту функцию можно вызывать на ранних этапах, когда некоторые модели еще могут не существовать, поэтому нам нужно отловить LookupError исключение для доступа к реестру приложений. Чем я использую эту функцию для каждых RunPython операций при переносе данных.

Итак, рабочий процесс выглядит так (мы предполагаем, что Model и ModelX уже на месте):

1) Создать ModelY:

class ModelY(Defaultable):
    y_name = models.CharField(max_length=255, default='ModelY')

2) Создать миграцию:

manage.py makemigration

3) Добавить миграцию данных в data.py (добавить имя модели в список defaultable в моем случае):

# data.py in myapp
def migratedata(apps, schema_editor):
    defaultables = ['ModelX', 'ModelY']

    for m in defaultables:
        try:
            M = apps.get_model('myapp', m)
            if not M.objects.filter(isDefault=True).exists():
                M.objects.create(isDefault=True)
        except LookupError as e:
            print '[{} : ignoring]'.format(e)

    # owner model, should be after defaults to support squashed migrations over empty database scenario
    Model = apps.get_model('myapp', 'Model')
    if not Model.objects.all().exists():
        Model.objects.create()

4) Изменить миграцию, добавив операцию RunPython:

from myapp.data import migratedata
class Migration(migrations.Migration):
    ...
    operations = [
        migrations.CreateModel(name='ModelY', ...),
        migrations.RunPython(migratedata, reverse_code=migratedata),
    ]

5) Добавить ForeignKey(ModelY) к Model:

class Model(models.Model):
    # SET_DEFAULT ensures that there will be no integrity issues, but make sure default object exists
    y = models.ForeignKey(ModelY, default=ModelY.default, on_delete=models.SET_DEFAULT)

6) Снова создать миграцию:

manage.py makemigration

7) Миграция:

manage.py migrate

8) Готово!

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

Когда мы уверены, что наша БД синхронизирована с кодом, мы можем легко удалить длинную цепочку миграций, сгенерировать одну начальную, добавить к ней RunPython(migratedata, ...), а затем выполнить миграцию с --fake-initial (прежде чем удалить таблицу django_migrations) ).

Ага, вот такое хитрое решение для такой простой задачи!

Наконец, есть Defaultable исходный код модели:

class Defaultable(models.Model):
    class Meta:
        abstract = True

    isDefault = models.BooleanField(default=False)

    @classmethod
    def default(cls):
        # type: (Type[Defaultable]) -> Defaultable
        """
        Search for default object in given model.
        Returning None is useful when applying sqashed migrations on empty database,
        the ForeignKey with this default can still be non-nullable, as return value
        is not used during migration if there is no model instance (Django is not pushing
        returned default to the SQL level).

        Take a note on only(), this is kind of dirty hack  to avoide problems during 
        model evolution, as default() can be called in migrations within some 
        historical project state, so ideally we should use model from this historical
        apps registry, but we have no access to it globally. 

        :return: Default object id, or None if no or many.
        """

        try:
            return cls.objects.only('id', 'isDefault').get(isDefault=True).id
        except cls.DoesNotExist:
            return None

    # take care of data integrity
    def save(self, *args, **kwargs):
        super(Defaultable, self).save(*args, **kwargs)
        if self.isDefault:  # Ensure only one default, so make all others non default
            self.__class__.objects.filter(~Q(id=self.id), isDefault=True).update(isDefault=False)
        else:  # Ensure at least one default exists
            if not self.__class__.objects.filter(isDefault=True).exists():
                self.__class__.objects.filter(id=self.id).update(isDefault=True)

    def __init__(self, *args, **kwargs):
        super(Defaultable, self).__init__(*args, **kwargs)

        # noinspection PyShadowingNames,PyUnusedLocal
        def pre_delete_defaultable(instance, **kwargs):
            if instance.isDefault:
                raise IntegrityError, "Can not delete default object {}".format(instance.__class__.__name__)

        pre_delete.connect(pre_delete_defaultable, self.__class__, weak=False, dispatch_uid=self._meta.db_table)
...