Наконец-то я пришел к следующему решению.
Сначала я согласился с идеей идентифицировать объект по умолчанию по атрибуту 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)