如何为两个相关模型组织迁移并自动为新建对象的 id 设置默认字段值?

How to organize migration for two related models and automatically set default field value for id of newly created object?

假设有一个生产数据库,里面有一些数据。我需要在下一个棘手的案例中迁移。

有一个模型(已经在数据库中),比如说Model,它有其他模型的外键。

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

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

我们需要再创建一个模型 ModelYModel 应该引用它。而在创建一个Model时,一个对象应该有一些与某个ModelY对象相关的默认值,这显然还没有,但我们应该在迁移时创建它。

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

所以迁移顺序应该是:

当然,我想将所有这些自动化。因此,为了避免手动应用一个迁移,然后创建一些对象,然后记下它的 id,然后使用这个 id 作为新字段的默认值,然后才用这个新字段应用另一个迁移。

而且我也想一步完成,所以在旧模型中同时定义 ModelY 和一个新字段 y,生成迁移,以某种方式修复它,然后立即申请并使其发挥作用。

这种情况有什么最佳做法吗?特别是,在哪里存储这个新创建的对象的 id?同一个数据库中的一些专用 table?

您将无法在单个迁移文件中执行此操作,但您可以创建多个迁移文件来实现此目的。我会尽力帮助你,虽然我不完全确定这是你想要的,它应该教你一两件关于 Django 迁移的事情。

我将在这里提到两种类型的迁移,一种是 架构迁移,这些是您通常在更改模型后生成的迁移文件。另一个是数据迁移,这些需要使用makemigrations命令的--empty选项创建,例如python manage.py makemigrations my_app --empty,用于移动数据,将需要更改为 non-null 的空列上的数据设置为

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 接受 null,我们稍后可以更改它,现在您可以 运行 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") 方法返回,以便及时获取模型在迁移的时间点上的样子。如果将来您添加更多字段并且 运行 此迁移,您的模型字段可能与数据库列不匹配(因为模型来自未来,有点......),并且您可能会收到一些关于缺少列的错误在数据库等。还要注意在迁移中对 models/managers 使用自定义方法,因为您无法从此代理模型访问它们,通常我可能会将一些代码复制到迁移中,因此它总是 运行 相同.

现在我们可以返回并修改 Model 模型以确保 y 不为 null 并且它在将来选择默认的 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 再次创建另一个架构迁移。

您不应混合架构迁移和数据迁移,因为迁移包含在事务中的方式可能会导致数据库错误,这会抱怨尝试 create/alter 表并在事务中执行 INSERT 查询。

最后你可以 运行 python manage.py migrate 它应该创建一个默认的 ModelY 对象,将它添加到模型的外键,并删除 null 使其成为默认对象外键。

最后我得出了以下解决方案。

首先我接受了通过isDefault属性来识别默认对象的想法,并编写了一些抽象模型来处理它,尽可能保持数据完整性(代码在post底部).

我不太喜欢接受的解决方案是数据迁移与模式迁移混合在一起。很容易丢失它们,即在挤压过程中。有时我也会完全删除迁移,当我确定我所有的生产和备份数据库都与代码一致时,所以我可以生成单个初始迁移并伪造它。将数据迁移与模式迁移保持在一起会破坏此工作流程。

所以我决定将所有数据迁移保存在 migrations 包之外的单个文件中。所以我在我的应用程序包中创建 data.py 并将所有数据迁移放在单个函数 migratedata 中,记住这个函数可以在早期阶段调用,当某些模型可能还不存在时,所以我们需要捕获应用注册表访问的 LookupError 异常。比起我在数据迁移中的每个 RunPython 操作都使用这个函数。

所以工作流程看起来像这样(我们假设 ModelModelX 已经到位):

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_migrationstable之前)。

哈,如此简单的任务的解决方案如此棘手!

终于有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)

我留下我之前的答案只是为了展示搜索想法。最后,我建立了全自动解决方案,因此不再需要手动编辑 django 生成的迁移,但代价是猴子修补,通常如此。

这个想法是为 ForeignKey 的默认值提供可调用的,它创建引用模型的默认实例,如果它不存在的话。但问题是,这个 callable 不仅可以在最终的 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 repository