【问题标题】:Django objects uniqueness hell with M2M fieldsDjango 对象唯一性地狱与 M2M 字段
【发布时间】:2019-02-08 08:43:28
【问题描述】:
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 #1:使用 unique_together -> 不适用于 M2M 字段,如 [in documentation] 所述 (https://docs.djangoproject.com/en/2.1/ref/models/options/#unique-together)
  • 想法#2:覆盖save() 方法。不完全适用于 M2M,因为在调用 addremove 方法时,不会调用 save()
  • idea #3:使用显式的through 模型,但由于我在生产环境中,我想避免在迁移重要结构(如论文)时冒险。 编辑:想了想,我看不出它实际上有什么帮助。

  • 想法#4:在调用add() 方法时,使用m2m_changedsignal 来检查唯一性。

我最终得到了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() 不会被提交?

谢谢!

【问题讨论】:

  • 为什么不创建一个带有 m2m 到餐厅的 IdentifiedBadge。通过这种方式,您可以保证这一点设计。通常最好按设计强制执行,然后以修补这些为目标。信号、.save() 等可以被绕过(例如在批量更新时)。
  • 这确实是第五个想法,谢谢。然而,我不喜欢仅仅为了处理其他类模型的完整性而添加一个新类的想法。这个解决方案如何优于idea #3
  • save 不应该捕获信号中引发的错误,这是发送信号github.com/django/django/blob/stable/2.1.x/django/db/models/… 的源代码,因为您可以看到它包装到事务原子并且不会捕获错误。你确定你的信号被执行了吗?
  • 标识符在数据库级别不是唯一的硬性要求吗?或者这只是一种偏好?
  • @ubadub 硬性要求

标签: django django-models django-signals


【解决方案1】:

您可以为您的 M2M 模型指定 your own connecting model,然后在成员模型的元类中添加 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)

【讨论】:

  • 嗨,约翰,感谢您的帮助。但是,我不确定unique_together = (("restaurant", "badge"),) 语句是否能确保唯一性。仍然可以创建 2 个具有相同标识符和相同餐厅的徽章。我需要像unique_together = (("restaurant", "badge__identifier"),) 这样的东西,但不可能写这个。
  • @DavidD。您是否尝试过在连接BadgeRestaurant 的会员模型上使用unique_together? (即不在BadgeRestaurant 型号上)。
【解决方案2】:

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

看看this example,它几乎概括了你需要的一切。

【讨论】:

  • 您能否详细说明“通过”模型如何帮助确保模型级别的完整性?对我来说,即使使用这种中间模型,由于原子性,我们仍然可以创建没有餐厅链接的徽章(请参阅问题中的初始问题)。这个解决方案比我已经使用的信号好多少?
  • @DavidD。我已经更新了关于自定义 through 模型将如何解决您的问题的答案。
【解决方案3】:

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

  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 验证。

【讨论】:

  • 感谢您的回答。我没有谈论视图,因为我绝对希望在模型级别处理这个完整性规则。我不能依靠观点来确保这个角色。请问您如何看待建议的#idea3 作为解决方案?
  • @DavidD.:完整性规则(第 1 点)可以在模型级别处理,但您还希望对不同的数据库表进行一系列原子操作(第 2 点)。 Badge 对象必须存在于数据库中,然后您才能尝试(并且可能会失败)向其中添加任何 Restaurants。至于想法#3,你能把你的问题编辑得更明确吗? through 模型会是什么样子,它将如何解决问题?
  • 我认为你是对的,考虑到想法#3,似乎 id 并不能解决问题。我想用unique_together,但Django不允许写unique_together=(badge__identifier, restaurant)之类的东西。
  • 我有一个新想法#5:删除信号中的徽章本身怎么样?这有点脏,因为一个对象会被创建然后被删除,但我猜它应该按预期工作......?
  • @DavidD.:删除徽章可能会起作用,但您必须小心以仅在新的Badges 上这样做的方式构建您的条件(而不是添加 @ 987654340@ 到现有的Badges)。可能会与信号系统发生意外的交互,并且您会错过使用 Django 验证系统的好处。我不会推荐它,但它可能会起作用。
猜你喜欢
  • 2013-02-05
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-04-18
  • 2013-06-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多