Django 使用 M2M 字段对象唯一性地狱

Django objects uniqueness hell with M2M fields

class Badge(SafeDeleteModel):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL,
                              blank=True, null=True,
                              on_delete=models.PROTECT)
    restaurants = models.ManyToManyField(Restaurant)
    identifier = models.CharField(max_length=2048)  # not unique at a DB level!

我想确保对于给定餐厅的任何徽章,它必须具有唯一标识符。以下是我的 4 个想法:

我以 idea 4 结束,并认为一切正常,有了这个信号...

@receiver(m2m_changed, sender=Badge.restaurants.through)
def check_uniqueness(sender, **kwargs):
    badge = kwargs.get('instance', None)
    action = kwargs.get('action', None)
    restaurant_pks = kwargs.get('pk_set', None)

    if action == 'pre_add':
        for restaurant_pk in restaurant_pks:
            if Badge.objects.filter(identifier=badge.identifier).filter(restaurants=restaurant_pk):
                raise BadgeNotUnique(MSG_BADGE_NOT_UNIQUE.format(
                    identifier=badge.identifier,
                    restaurant=Restaurant.objects.get(pk=restaurant_pk)
                ))

...直到今天我在我的数据库中发现许多具有相同标识符但没有餐厅的徽章(不应该发生在业务级别) 我知道 save() 和信号之间 没有原子性 。 这意味着,如果用户在尝试创建徽章时出现关于唯一性的错误,则会创建徽章,但没有餐厅链接到它。

所以,问题是:如何确保在模型级别,如果信号引发错误,save() 不会被提交?

谢谢!

我在这里看到两个不同的问题:

  1. 您想对您的数据实施特定的约束。

  2. 如果违反约束,您想恢复以前的操作。特别是,如果在同一请求中添加了任何违反约束的 Restaurants,您希望恢复 Badge 实例的创建。

关于1,你的约束很复杂,因为它涉及到多个表。这排除了数据库约束(好吧,您可以使用触发器来完成)或简单的模型级验证。

您上面的代码显然可以有效地防止 adds 违反约束。但是请注意,如果更改现有 Badge 的标识符,也可能违反此约束。大概你也想阻止这种情况?如果是这样,您需要向 Badge 添加类似的验证(例如在 Badge.clean() 中)。

关于 2,如果您希望在违反约束时恢复 Badge 实例的创建,您需要确保操作包含在数据库事务中。您还没有告诉我们创建这些对象区域的视图(自定义视图?Django 管理员?)所以很难给出具体的建议。本质上,你想要这个:

with transaction.atomic():
    badge_instance.save()
    badge_instance.add(...)

如果这样做,您的 M2M pre_add 信号抛出的异常将回滚事务,并且您不会在数据库中获得剩余的 Badge。请注意,默认情况下,管理员视图在交易中 运行,因此如果您使用管理员,这应该已经发生了。

另一种方法是在创建 Badge 对象之前进行验证。例如,参见 this answer 关于在 Django 管理中使用 ModelForm 验证。

您可以为您的 M2M 模型指定 your own connecting model,然后在成员模型 class 的元 class 中添加一个 unique_together 约束

class Badge(SafeDeleteModel):
    ...
    restaurants = models.ManyToManyField(Restaurant, through='BadgeMembership')

class BadgeMembership(models.Model):
    restaurant = models.ForeignKey(Restaurant, null=False, blank=False, on_delete=models.CASCADE)
    badge = models.ForeignKey(Badge, null=False, blank=False, on_delete=models.CASCADE)

    class Meta:
        unique_together = (("restaurant", "badge"),)

这将创建一个介于 BadgeRestaurant 之间的对象,每个餐厅的每个徽章都是唯一的。

可选:保存检查

您还可以添加自定义 save 函数,您可以在其中手动检查唯一性。这样就可以手动抛出异常了。

class BadgeMembership(models.Model):
    restaurant = models.ForeignKey(Restaurant, null=False, blank=False, on_delete=models.CASCADE)
    badge = models.ForeignKey(Badge, null=False, blank=False, on_delete=models.CASCADE)

    def save(self, *args, **kwargs):
        # Only save if the object is new, updating won't do anything
        if self.pk is None:
            membershipCount = BadgeMembership.objects.filter(
                Q(restaurant=self.restaurant) &
                Q(badge=self.badge)
            ).count()
            if membershipCount > 0:
                raise BadgeNotUnique(...);
            super(BadgeMembership, self).save(*args, **kwargs)

恐怕真正实现这一目标的正确方法是调整 "through" 模型。但请记住,在数据库级别,此 "through" 模型已经存在,因此您的迁移将只是添加一个唯一约束。这是一个相当简单的操作,它并不真正涉及任何真正的迁移,我们经常在生产环境中这样做。

看看 this example,它几乎涵盖了您需要的一切。