Django: 'unique_together' and 'blank=True'

25

我有一个Django模型,它看起来像这样:

class MyModel(models.Model):
    parent = models.ForeignKey(ParentModel)
    name   = models.CharField(blank=True, max_length=200)
    ... other fields ...

    class Meta:
        unique_together = ("name", "parent")

这个功能按预期工作;如果在同一parent中有多个相同的name,那么我会收到一个错误消息:"MyModel with this Name and Parent already exists."

但是,当我保存多个没有填写name字段但有相同parentMyModel时,我也会收到错误。实际上应该允许这种情况。所以,当name字段为空时,我不想收到上述错误消息。这是否有可能?

5个回答

18

首先,空白(空字符串)并不等同于null(即'' != None)。

其次,当通过表单使用Django CharField时,如果您将字段留空,它将存储空字符串

因此,如果您的字段不是CharField,则应只需将null=True添加到其中。但在这种情况下,您需要做更多的工作。您需要创建forms.CharField子类并覆盖其clean方法,在空字符串上返回None,就像这样:

class NullCharField(forms.CharField):
    def clean(self, value):
        value = super(NullCharField, self).clean(value)
        if value in forms.fields.EMPTY_VALUES:
            return None
        return value

然后在您的ModelForm中使用它:

class MyModelForm(forms.ModelForm):
    name = NullCharField(required=False, ...)

如果将其留空,则会在数据库中存储 null 而不是空字符串('')。


你看到行为上的差异是因为你的名字字段是CharField。CharFields不存储NULL,而是存储空字符串。快速的谷歌搜索会让你了解这背后的原因。 - dting
1
好的调用。整个问题实际上是由于CharField缺少null引起的。 - rombarcz
2
+1 我更喜欢数据库强制执行约束的解决方案。 - MattH
1
你应该继承 models.CharField 而不是 forms.CharField 吗?请参考 这个答案 - Mechanical snail
1
不,我改变的不是模型字段的行为,而是表单字段。它是将空字段转换为空字符串''的表单,这是完全可以的,但在唯一性值为None(数据库中的Null)的情况下,更合适。 - rombarcz
非常感谢!这是最干净的解决方案! - WesDec

13

使用 unique_together ,你告诉 Django 不希望任何两个具有相同 parentname 属性的 MyModel 实例存在,即使 name 是空字符串也是如此。

这通过在适当的数据库列上使用 unique 属性来强制执行。因此,如果要对此行为进行任何例外处理,则必须避免在模型中使用 unique_together

相反,您可以通过覆盖模型上的 save 方法并在其中强制执行唯一约束条件来实现目标。当您尝试保存模型实例时,您的代码可以检查是否存在具有相同的 parentname 组合的现有实例,如果存在,则拒绝保存该实例。但是,如果 name 为空字符串,则还可以允许保存该实例。这个基本版本可能看起来像这样:

class MyModel(models.Model):
    ...

    def save(self, *args, **kwargs):

        if self.name != '':
            conflicting_instance = MyModel.objects.filter(parent=self.parent, \
                                                          name=self.name)
            if self.id:
                # This instance has already been saved. So we need to filter out
                # this instance from our results.
                conflicting_instance = conflicting_instance.exclude(pk=self.id)

            if conflicting_instance.exists():
                raise Exception('MyModel with this name and parent already exists.')

        super(MyModel, self).save(*args, **kwargs)

希望这有所帮助。


3
我认为使用.exists是首选方式,而不是len(queryset)。 - dting
@bigmatty:这种解决方案不好,因为这意味着如果你以后想要修改实例的其他字段并使用 .save() 保存它们,那么将会引发异常。 - WesDec
@WesDec:只有在尝试保存与另一个对象具有相同“parent”和“name”的实例时,才应该出现异常--当然,除非“name”属性是空字符串。这就是您想要的行为,对吧? - Matt Howell
@bigmattyh:是的,但是想象一下,我创建了一个实例,其中“parent”和“name”是唯一的,并且名称不是空字符串。如果我稍后从数据库中获取相同的实例并更改字段,然后将实例保存到数据库中,那么“parent”和“name”的相同检查将再次进行,这次我会得到一个异常。 - WesDec
@WesDec:这并不难修复。请参见上面的编辑代码以处理此情况。 - Matt Howell
请注意,这仍然存在竞态条件。我们已经在生产环境中看到了由于此代码而创建的重复项。 - mlissner

2

您可以使用约束条件来设置部分索引,如下所示:

class MyModel(models.Model):
    parent = models.ForeignKey(ParentModel)
    name   = models.CharField(blank=True, max_length=200)
    ... other fields ...

    class Meta:    
      constraints = [
        models.UniqueConstraint(
          fields=['name', 'parent'],
          condition=~Q(name='')
          name='unique_name_for_parent'
        )
      ]

这允许像UniqueTogether这样的约束只适用于某些行(基于您可以使用Q定义的条件)。

顺便说一下,这也是Django推荐的前进路径:https://docs.djangoproject.com/en/3.2/ref/models/options/#unique-together

更多文档:https://docs.djangoproject.com/en/3.2/ref/models/constraints/#django.db.models.UniqueConstraint


2
这个解决方案与@bigmattyh提供的解决方案非常相似,但我发现下面的页面描述了验证应该在哪里进行:http://docs.djangoproject.com/en/1.3/ref/models/instances/#validating-objects。我最终使用的解决方案如下:
from django    import forms

class MyModel(models.Model):
...

def clean(self):
    if self.name != '':
        instance_exists = MyModel.objects.filter(parent=self.parent,
                                                 name=self.name).exists()
        if instance_exists:
            raise forms.ValidationError('MyModel with this name and parent already exists.')

请注意,抛出的是ValidationError而不是通用异常。这种解决方案的好处在于,在验证ModelForm时使用.is_valid()方法时,上面的模型.clean()方法会自动调用,并将ValidationError字符串保存在.errors中,以便在html模板中显示。
如果您不同意此解决方案,请告诉我。

这个解决方案唯一的问题是当调用模型的save()方法时,clean()不会被调用(参见文档)。我从来没有真正理解为什么,因为它似乎是一个更干净(无双关语)的解决方案。 - Mathieu Dhondt
你可以在modelsave方法中调用self.clean() - Carl Brubaker

-1

bigmattyh给出了一个很好的解释,我只是补充一个可能的save方法。

def save(self, *args, **kwargs):
    if self.parent != None and MyModels.objects.filter(parent=self.parent, name=self.name).exists():
        raise Exception('MyModel with this name and parent exists.')
    super(MyModel, self).save(*args, **kwargs)

我认为我选择通过重写模型的clean方法来实现类似的功能,大致如下:

from django.core.exceptions import ValidationError
def clean(self):
    if self.parent != None and MyModels.objects.filter(parent=self.parent, name=self.name).exists():
        raise ValidationError('MyModel with this name and parent exists.')

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接