在手动迁移中删除列时的 Django 约束

Django constraints when removing columns in manual migrations

Django 在删除列时静默删除约束,然后任意选择将它们包含在迁移中。

我在 Django 的生产代码中遇到了一个奇怪的错误(?)。在迁移中手动删除列 而不 删除其约束会导致 Django 不知道这些约束已被删除,并自动生成不正确的迁移。

这是一个简短的例子。

此模式的迁移:

class M():
    a = models.IntegerField(default=1)
    b = models.IntegerField(default=2)
    class Meta:
        constraints = [
            UniqueConstraint(
                name="uniq_ab_1", fields=["a", "b"]
            )
        ]

创建约束 uniq_ab_1 是预期的 Postgres(使用 table 上的 \d+ 命令验证)。

但是,由于其中一个成员列被删除,此手动迁移将删除约束;这只是标准的 Postgres 行为:

        migrations.RemoveField(
            model_name="m",
            name="a",
        ),
        migrations.AddField(
            model_name="m",
            name="a",
            field=models.IntegerField(default=6),
        ),

这次迁移 运行 很好。我什至可以再次修改 M 的字段并 运行 进一步迁移。但是,使用 \d+ 显示 uniq_ab_1 约束已从数据库中消失。

我发现此行为的唯一方法是将约束重命名为 uniq_ab_2,然后自动生成迁移并收到错误:

django.db.utils.ProgrammingError: constraint "uniq_ab_1" of relation … does not exist.

换句话说,在重命名时,Django 意识到正在重命名并尝试从数据库中删除约束,尽管它在几次迁移中已经消失。

这种行为是相当出乎意料的。我假设 Django 会:

  1. 请注意代码模型与原始迁移中的数据库模式不同(当约束被无意中删除时)并失败。
  2. 请注意,在下一次迁移中缺少约束(例如,将任意字段添加到 M 时)并尝试再次将其添加回来。

就目前而言,某些迁移操作似乎会观察到此幽灵约束,而其他操作则不会。

这是 Django 中的已知错误吗? 有没有办法防止这种行为? 这是完全正常的,我做错了什么吗?

这个问题提出了一些关于 Django 的误解。

Django 在 python manage.py makemigrations 中通过比较模型定义和从现有迁移构建的模型模式(即 code-first 行为)生成迁移,而不是通过比较模型定义和数据库模式(如它通常是不可复制的)。

消除误解

Django silently removes constraints when removing columns

不,Django 不会那样做。约束由 PostgreSQL 自动删除。
https://postgrespro.com/docs/postgresql/9.6/sql-altertable

您可以查看 Django 使用 python manage.py sqlmigrate 做什么。

[Django] arbitrarily chooses to include [constraints] in migrations

不,Django 在生成迁移时总是考虑约束。

In other words, on a rename, Django become aware a rename was happening and tried to remove the constraint from the database, in spite of it being gone for a few migrations.

Django 不知道正在重命名。 Django 只是删除了前一个(在从现有迁移构建的模型模式中)并添加了新的(在模型定义中)。

This behavior is rather unexpected. I'd assume Django would either:

  • Notice the code model differs from the database schema in the original migration (when the constraint gets inadvertently removed) and fail.
  • Notice that the constraint is missing in the next migration (e.g. when adding an arbitrary field to M) and try to add it back again.

Django 会在 python manage.py migrate 开始时检查模型定义是否有问题,但不会将其与数据库模式进行比较——尤其是在迁移过程中 operations 的每一步.

当您创建手动迁移时,以上是您的责任。

Is this a known bug in Django?

如上所述,这不是错误,是由错误计划的手动迁移引起的。

虽然 Django 可以帮助防止这种错误的手动操作(如下所示),但用户可以通过太多方式搞砸 Django 的手动迁移以合理地覆盖。

防止这种行为

Is there a way to guard against this behavior?

您可以修补 BaseDatabaseSchemaEditor.remove_field 以阻止删除模型定义中具有唯一约束的字段:

def _patch_remove_field():
    from itertools import chain
    from django.core import checks
    from django.db.backends.base.schema import BaseDatabaseSchemaEditor
    from django.db.models.constraints import UniqueConstraint

    old_remove_field = BaseDatabaseSchemaEditor.remove_field

    def remove_field(self, model, field):
        field_names = set(chain.from_iterable(
            (*constraint.fields, *constraint.include)
            for constraint in model._meta.constraints if isinstance(constraint, UniqueConstraint)
        ))
        if field.name in field_names:
            raise ValueError(checks.Error(
                "Cannot remove field '%s' as '%s' refers to it" % (field.name, 'constraints'),
                obj=model,
                id='models.E012',
            ))
        old_remove_field(self, model, field)

    BaseDatabaseSchemaEditor.remove_field = remove_field

正确迁移

Is this perfectly normal and I am doing something wrong?

是的。这是正确的迁移。

生成的迁移

python manage.py makemigrations 保留了唯一约束,但没有相同的行为(将现有行更新为 a = 6)。

        migrations.AlterField(
            model_name='m',
            name='a',
            field=models.IntegerField(default=6),
        ),

手动迁移

您可以通过将字段 a 替换为 c 来获得 Django 的部分帮助,生成迁移,然后在模型定义和生成的迁移中恢复为 a

        migrations.RemoveConstraint(
            model_name='m',
            name='uniq_ab_1',
        ),
        migrations.RemoveField(
            model_name='m',
            name='a',
        ),
        migrations.AddField(
            model_name='m',
            name='a',
            field=models.IntegerField(default=6),
        ),
        migrations.AddConstraint(
            model_name='m',
            constraint=models.UniqueConstraint(fields=('a', 'b'), name='uniq_ab_1'),
        ),