Переименование модели суперкласса Django и правильное обновление указателей подкласса - PullRequest
10 голосов
/ 07 мая 2020

У меня возникли проблемы с рефакторингом суперкласса в Django v2.2.12 с участием трех моделей, с одной моделью суперкласса и двумя моделями подкласса:

class BaseProduct(models.Model):
    name = models.CharField()
    description = models.CharField()


class GeneralProduct(BaseProduct):
    pass


class SoftwareProduct(BaseProduct):
    pass

Модель BaseProduct необходимо переименовать в просто Product, поэтому я изменил этот код на:

class Product(models.Model):
    name = models.CharField()
    description = models.CharField()

class GeneralProduct(Product):
    pass


class SoftwareProduct(Product):
    pass

И затем запустил python manage.py makemigrations, в котором Django, кажется, правильно видит, что изменилось:

Did you rename the yourapp.BaseProduct model to Product? [y/N] y
Did you rename generalproduct.baseproduct_ptr to generalproduct.product_ptr (a OneToOneField)? [y/N] y
Did you rename softwareproduct.baseproduct_ptr to softwareproduct.product_ptr (a OneToOneField)? [y/N] y

Migrations for 'yourapp':
  .../yourapp/migrations/002_auto_20200507_1830.py
    - Rename model BaseProduct to Product
    - Rename field baseproduct_ptr on generalproduct to product_ptr
    - Rename field baseproduct_ptr on softwareproduct to product_ptr

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

Полученная миграция появляется with выглядит примерно так же кратко, как и должно быть:

# Generated by Django 2.2.12 on 2020-05-07 18:30

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('yourapp', '0001_initial'),
    ]

    operations = [
        migrations.RenameModel(
            old_name='BaseProduct',
            new_name='Product',
        ),
        migrations.RenameField(
            model_name='generalproduct',
            old_name='baseproduct_ptr',
            new_name='product_ptr',
        ),
        migrations.RenameField(
            model_name='softwareproduct',
            old_name='baseproduct_ptr',
            new_name='product_ptr',
        ),
    ]

Все выглядит идеально, но при применении этой миграции с помощью python manage.py migrate происходит сбой:

Running migrations:
  Applying yourapp.0002_auto_20200507_1830...Traceback (most recent call last):
  [...]
  File ".../python3.7/site-packages/django/db/migrations/executor.py", line 245, in apply_migration
    state = migration.apply(state, schema_editor)
  File ".../python3.7/site-packages/django/db/migrations/migration.py", line 114, in apply
    operation.state_forwards(self.app_label, project_state)
  File ".../python3.7/site-packages/django/db/migrations/operations/models.py", line 340, in state_forwards
    state.reload_models(to_reload, delay=True)
  File ".../python3.7/site-packages/django/db/migrations/state.py", line 165, in reload_models
    self._reload(related_models)
  File ".../python3.7/site-packages/django/db/migrations/state.py", line 191, in _reload
    self.apps.render_multiple(states_to_be_rendered)
  File ".../python3.7/site-packages/django/db/migrations/state.py", line 308, in render_multiple
    model.render(self)
  File ".../python3.7/site-packages/django/db/migrations/state.py", line 579, in render
    return type(self.name, bases, body)
  File ".../python3.7/site-packages/django/db/models/base.py", line 253, in __new__
    base.__name__,
django.core.exceptions.FieldError: Auto-generated field 'baseproduct_ptr' in class 'SoftwareProduct' for
parent_link to base class 'BaseProduct' clashes with declared field of the same name.

Я искал в Интернете это ошибка, а также для переименования модели Django, которая является суперклассом для других моделей, но, похоже, нет какой-либо (обнаруживаемой) документации, сообщений в блогах или ответов SO, которые говорят об этой проблеме.

1 Ответ

8 голосов
/ 11 мая 2020

Канонический ответ

Причина, по которой это идет не так, заключается в том, что хотя Django видит , что модель была переименована, а подклассы нуждаются в обновлении указателя, она не может правильно выполнить в этих обновлениях. На момент написания (https://github.com/django/django/pull/11222) существует PR, чтобы добавить это к Django, но до тех пор, пока это не произойдет, решение состоит в том, чтобы временно «обмануть» Django, заставив думать о подклассах на самом деле являются простыми моделями без какого-либо наследования и вносят изменения вручную, выполнив следующие шаги:

  1. переименование автоматически сгенерированного указателя наследования с superclass_ptr на newsuperclass_ptr вручную (в данном случае baseproduct_ptr становится product_prt), затем
  2. трюк Django, заставляющий думать, что подклассы являются просто общими c реализациями модели, буквально переписывая для них свойство .bases и сообщая Django перезагрузить их, затем
  3. переименование суперкласса в его новое имя (в этом случае BaseProduct становится Product) и, наконец,
  4. обновление полей newsuperclass_ptr, чтобы они указывали на новый суперкласс вместо этого укажите auto_created=True, а также parent_link=True.

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

Итак, еще несколько шагов, чем всего manage makemigrations, но каждый из них прост, и мы можем сделать все это, написав один настраиваемый файл миграции.

Используя имена из сообщения с вопросом:

# Custom Django 2.2.12 migration for handling superclass model renaming.

from django.db import migrations, models
import django.db.models.deletion

# with a file called custom_operations.py in our migrations dir:
from .custom_operations import AlterModelBases


class Migration(migrations.Migration):
    dependencies = [
        ('yourapp', '0001_initial'),
        # Note that if the last real migration starts with 0001,
        # this migration file has to start with 0002, etc. 
        #
        # Django simply looks at the initial sequence number in
        # order to build its migration tree, so as long as we
        # name the file correctly, things just work.
    ]

    operations = [
        # Step 1: First, we rename the parent links in our
        # subclasses to match their future name:

        migrations.RenameField(
            model_name='generalproduct',
            old_name='baseproduct_ptr',
            new_name='product_ptr',
        ),

        migrations.RenameField(
            model_name='softwareproduct',
            old_name='baseproduct_ptr',
            new_name='product_ptr',
        ),

        # Step 2: then, temporarily set the base model for
        #         our subclassses to just `Model`, which makes
        #         Django think there are no parent links, which
        #         means it won't try to apply crashing logic in step 3.

        AlterModelBases("GeneralProduct", (models.Model,)),
        AlterModelBases("SoftwareProduct", (models.Model,)),

        # Step 3: Now we can safely rename the superclass without
        #         Django trying to fix subclass pointers:

        migrations.RenameModel(
            old_name="BaseProduct",
            new_name="Product"
        ),

        # Step 4: Which means we can now update the `parent_link`
        #         fields for the subclasses: even though we altered
        #         the model bases earlier, this step will restore
        #         the class hierarchy we actually need:

        migrations.AlterField(
            model_name='generalproduct',
            name='product_ptr',
            field=models.OneToOneField(
                auto_created=True,
                on_delete=django.db.models.deletion.CASCADE,
                parent_link=True, primary_key=True,
                serialize=False,
                to='buyersguide.Product'
            ),
        ),

        migrations.AlterField(
            model_name='softwareproduct',
            name='product_ptr',
            field=models.OneToOneField(
                auto_created=True,
                on_delete=django.db.models.deletion.CASCADE,
                parent_link=True,
                primary_key=True,
                serialize=False,
                to='buyersguide.Product'
            ),
        ),
    ]

Решающим шагом является «разрушение» наследования: мы сообщаем Django, что подклассы наследуются от models.Model, так что переименование суперкласса не затрагивает подклассы (вместо того, чтобы Django пытаться обновлять сами указатели наследования), но мы фактически ничего не меняем в базе данных. Мы вносим это изменение только в текущий работающий код, поэтому, если мы выйдем из Django, это будет выглядеть так, как будто это изменение никогда не было сделано с самого начала.

Итак, чтобы добиться этого, мы используем custom ModelOperation , который может изменить наследование (ny) класса на (ny коллекцию) различных суперклассов во время выполнения:

# contents of yourapp/migrations/custom_operations.py

from django.db.migrations.operations.models import ModelOperation


class AlterModelBases(ModelOperation):
    reduce_to_sql = False
    reversible = True

    def __init__(self, name, bases):
        self.bases = bases
        super().__init__(name)

    def state_forwards(self, app_label, state):
        """
        Overwrite a models base classes with a custom list of
        bases instead, then force Django to reload the model
        with this (probably completely) different class hierarchy.
        """
        state.models[app_label, self.name_lower].bases = self.bases
        state.reload_model(app_label, self.name_lower)

    def database_forwards(self, app_label, schema_editor, from_state, to_state):
        pass

    def database_backwards(self, app_label, schema_editor, from_state, to_state):
        pass

    def describe(self):
        return "Update %s bases to %s" % (self.name, self.bases)

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

class Product(models.Model):
    name = models.CharField()
    description = models.CharField()

class GeneralProduct(Product):
    pass


class SoftwareProduct(Product):
    pass

А затем применить manage migrate, который будет запускаться и обновлять все по мере необходимости.

ПРИМЕЧАНИЕ. : в зависимости от того, «предварительно обработали» ли вы код при подготовке к переименованию, используйте что-то вроде этого:

class BaseProduct(models.Model):
    name = models.CharField()
    description = models.CharField()


# "handy" aliasing so that all code can start using `Product`
# even though we haven't renamed actually renamed this class yet:
Product = BaseProduct


class GeneralProduct(Product):
    pass


class SoftwareProduct(Product):
    pass

, возможно, вам придется обновить ForeignKey и ManyToMany отношения к Product в других классах, добавьте явные инструкции add models.AlterField для обновления BaseProduct в Product:

        ...
        migrations.AlterField(
            model_name='productrating',
            name='product',
            field=models.ForeignKey(
                 on_delete=django.db.models.deletion.CASCADE,
                 to='yourapp.Product'
            ),
        ),
        ...


Исходный ответ

О, да, это непростой вопрос. Но я решил в своем проекте вот как я это сделал.

1) Удалите только что созданную миграцию и откатите изменения вашей модели

2) Измените поля неявной родительской ссылки на явные с помощью параметра parent_link . Нам нужно это, чтобы вручную переименовать наше поле в имя propper на более поздних этапах

class BaseProduct(models.Model):
    ...

class GeneralProduct(BaseProduct):
    baseproduct_ptr = models.OneToOneField(BaseProduct, django.db.models.deletion.CASCADE, parent_link=True, primary_key=True)

class SoftwareProduct(BaseProduct):
    baseproduct_ptr = models.OneToOneField(BaseProduct, django.db.models.deletion.CASCADE, parent_link=True, primary_key=True)

3) Сгенерируйте миграцию через makemigrations и получите что-то вроде этого

...
migrations.AlterField(
    model_name='generalproduct',
    name='baseproduct_ptr',
    field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='BaseProduct'),
),
migrations.AlterField(
    model_name='softwareproduct',
    name='baseproduct_ptr',
    field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='BaseProduct'),
)
...

4) Теперь у вас есть явные ссылки на вашу родительскую модель, вы можете переименовать их в product_ptr, которое будет соответствовать желаемому имени ссылки

class GeneralProduct(BaseProduct):
    product_ptr = models.OneToOneField(BaseProduct, django.db.models.deletion.CASCADE, parent_link=True, primary_key=True)

class SoftwareProduct(BaseProduct):
    product_ptr = models.OneToOneField(BaseProduct, django.db.models.deletion.CASCADE, parent_link=True, primary_key=True)

5) Сгенерируйте миграцию через makemigrations и получите что-то вроде этого

...
migrations.RenameField(
    model_name='generalproduct',
    old_name='baseproduct_ptr',
    new_name='product_ptr',
),
migrations.RenameField(
    model_name='softwareproduct',
    old_name='baseproduct_ptr',
    new_name='product_ptr',
),
...

6) Теперь самая сложная часть: нам нужно добавить новую операцию миграции (источник можно найти здесь https://github.com/django/django/pull/11222) и вставьте наш код, у меня лично есть пакет contrib в моем проекте, куда я помещаю весь персонал, подобный этому

Файл в contrib/django/migrations.py

# https://github.com/django/django/pull/11222/files
# https://code.djangoproject.com/ticket/26488
# https://code.djangoproject.com/ticket/23521
# https://code.djangoproject.com/ticket/26488#comment:18
# https://github.com/django/django/pull/11222#pullrequestreview-233821387
from django.db.migrations.operations.models import ModelOperation


class DisconnectModelBases(ModelOperation):
    reduce_to_sql = False
    reversible = True

    def __init__(self, name, bases):
        self.bases = bases
        super().__init__(name)

    def state_forwards(self, app_label, state):
        state.models[app_label, self.name_lower].bases = self.bases
        state.reload_model(app_label, self.name_lower)

    def database_forwards(self, app_label, schema_editor, from_state, to_state):
        pass

    def database_backwards(self, app_label, schema_editor, from_state, to_state):
        pass

    def describe(self):
        return "Update %s bases to %s" % (self.name, self.bases)

7) Теперь мы готовы переименовать нашу родительскую модель

class Product(models.Model):
    ....

class GeneralProduct(Product):
    pass


class SoftwareProduct(Product):
    pass

8) Сгенерировать миграцию через makemigrations. Убедитесь, что вы добавили шаг DisconnectModelBases, он не будет добавлен автоматически, даже если успешно сгенерировать миграцию. Если это не помогает, и вы можете попробовать создать --empty один вручную.

from django.db import migrations, models
import django.db.models.deletion

from contrib.django.migrations import DisconnectModelBases


class Migration(migrations.Migration):

    dependencies = [
        ("contenttypes", "0002_remove_content_type_name"),
        ("products", "0071_auto_20200122_0614"),
    ]

    operations = [
        DisconnectModelBases("GeneralProduct", (models.Model,)),
        DisconnectModelBases("SoftwareProduct", (models.Model,)),
        migrations.RenameModel(
            old_name="BaseProduct", new_name="Product"
        ),
        migrations.AlterField(
            model_name='generalproduct',
            name='product_ptr',
            field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='products.Product'),
        ),
        migrations.AlterField(
            model_name='softwareproduct',
            name='product_ptr',
            field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='proudcts.Product'),
        ),
    ]

ПРИМЕЧАНИЕ: после всего этого вам не нужны явные поля parent_link. Так что вы можете их удалить. Что я и сделал на шаге 7.

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