Django unique_together 具有可为空的 ForeignKey
Django unique_together with nullable ForeignKey
我在使用 Sqlite 的开发机器中使用 Django 1.8.4,我有这些模型:
class ModelA(Model):
field_a = CharField(verbose_name='a', max_length=20)
field_b = CharField(verbose_name='b', max_length=20)
class Meta:
unique_together = ('field_a', 'field_b',)
class ModelB(Model):
field_c = CharField(verbose_name='c', max_length=20)
field_d = ForeignKey(ModelA, verbose_name='d', null=True, blank=True)
class Meta:
unique_together = ('field_c', 'field_d',)
我已经 运行 正确迁移并在 Django Admin 中注册了它们。所以,使用管理员我完成了这个测试:
- 我能够创建 ModelA 记录,但 Django 禁止我创建重复记录 - 正如预期的那样!
- 当 field_b 不为空时,我无法创建相同的 ModelB 记录
- 但是,当使用 field_d 作为空
时,我能够创建相同的 ModelB 记录
我的问题是:如何为可为空的 ForeignKey 应用 unique_together?
我为这个问题找到的最近答案有 5 年了...我确实认为 Django 已经发展,问题可能不一样了。
更新:我的回答的前一版本是功能性的,但设计不佳,这个版本考虑了一些评论和其他答案。
在SQL NULL 不等于 NULL。这意味着如果您有两个 field_d == None and field_c == "somestring"
不相等的对象,那么您可以同时创建它们。
您可以覆盖 Model.clean
以添加支票:
class ModelB(Model):
#...
def validate_unique(self, exclude=None):
if ModelB.objects.exclude(id=self.id).filter(field_c=self.field_c, \
field_d__isnull=True).exists():
raise ValidationError("Duplicate ModelB")
super(ModelB, self).validate_unique(exclude)
如果在表单之外使用,您必须调用 full_clean
或 validate_unique
。
尽管如此,请注意处理竞争条件。
@ivan,我认为 django 没有简单的方法来处理这种情况。您需要考虑并非总是来自表单的所有创建和更新操作。另外,你应该考虑竞争条件......
并且因为您没有在数据库级别强制执行此逻辑,所以实际上可能会有双倍的记录,您应该在查询结果时检查它。
关于您的解决方案,它可能对表单有好处,但我不认为保存方法会引发 ValidationError。
如果可能的话,最好将此逻辑委托给 DB。在这种特殊情况下,您可以使用两个部分索引。 Whosebug 上有一个类似的问题 - Create unique constraint with null columns
因此您可以创建 Django 迁移,将两个部分索引添加到您的数据库
示例:
# Assume that app name is just `example`
CREATE_TWO_PARTIAL_INDEX = """
CREATE UNIQUE INDEX model_b_2col_uni_idx ON example_model_b (field_c, field_d)
WHERE field_d IS NOT NULL;
CREATE UNIQUE INDEX model_b_1col_uni_idx ON example_model_b (field_c)
WHERE field_d IS NULL;
"""
DROP_TWO_PARTIAL_INDEX = """
DROP INDEX model_b_2col_uni_idx;
DROP INDEX model_b_1col_uni_idx;
"""
class Migration(migrations.Migration):
dependencies = [
('example', 'PREVIOUS MIGRATION NAME'),
]
operations = [
migrations.RunSQL(CREATE_TWO_PARTIAL_INDEX, DROP_TWO_PARTIAL_INDEX)
]
我认为对于 Django 1.2+
,这是更清晰的方法
在表单中它将被提升为 non_field_error 而没有 500 错误,在其他情况下,比如 DRF 你必须检查这个案例手册,因为它会是 500 错误。
但它总是会检查 unique_together!
class BaseModelExt(models.Model):
is_cleaned = False
def clean(self):
for field_tuple in self._meta.unique_together[:]:
unique_filter = {}
unique_fields = []
null_found = False
for field_name in field_tuple:
field_value = getattr(self, field_name)
if getattr(self, field_name) is None:
unique_filter['%s__isnull' % field_name] = True
null_found = True
else:
unique_filter['%s' % field_name] = field_value
unique_fields.append(field_name)
if null_found:
unique_queryset = self.__class__.objects.filter(**unique_filter)
if self.pk:
unique_queryset = unique_queryset.exclude(pk=self.pk)
if unique_queryset.exists():
msg = self.unique_error_message(self.__class__, tuple(unique_fields))
raise ValidationError(msg)
self.is_cleaned = True
def save(self, *args, **kwargs):
if not self.is_cleaned:
self.clean()
super().save(*args, **kwargs)
Django 2.2 添加了一个新的 constraints API,这使得在数据库中处理这种情况变得更加容易。
您将需要两个约束条件:
- 现有的元组约束;和
- 剩余键减去可空键,有条件
如果您有多个可为空的字段,我想您将需要处理排列。
这是一个示例,其中包含必须全部唯一的 thruple 个字段,其中只允许一个 NULL
:
from django.db import models
from django.db.models import Q
from django.db.models.constraints import UniqueConstraint
class Badger(models.Model):
required = models.ForeignKey(Required, ...)
optional = models.ForeignKey(Optional, null=True, ...)
key = models.CharField(db_index=True, ...)
class Meta:
constraints = [
UniqueConstraint(fields=['required', 'optional', 'key'],
name='unique_with_optional'),
UniqueConstraint(fields=['required', 'key'],
condition=Q(optional=None),
name='unique_without_optional'),
]
尚未提及的一种可能的解决方法是创建一个虚拟 ModelA
对象作为您的 NULL
值。然后你可以依赖数据库来强制执行唯一性约束。
为您的模型添加一个干净的方法 - 见下文:
def clean(self):
if Variants.objects.filter("""Your filter """).exclude(pk=self.pk).exists():
raise ValidationError("This variation is duplicated.")
我在使用 Sqlite 的开发机器中使用 Django 1.8.4,我有这些模型:
class ModelA(Model):
field_a = CharField(verbose_name='a', max_length=20)
field_b = CharField(verbose_name='b', max_length=20)
class Meta:
unique_together = ('field_a', 'field_b',)
class ModelB(Model):
field_c = CharField(verbose_name='c', max_length=20)
field_d = ForeignKey(ModelA, verbose_name='d', null=True, blank=True)
class Meta:
unique_together = ('field_c', 'field_d',)
我已经 运行 正确迁移并在 Django Admin 中注册了它们。所以,使用管理员我完成了这个测试:
- 我能够创建 ModelA 记录,但 Django 禁止我创建重复记录 - 正如预期的那样!
- 当 field_b 不为空时,我无法创建相同的 ModelB 记录
- 但是,当使用 field_d 作为空 时,我能够创建相同的 ModelB 记录
我的问题是:如何为可为空的 ForeignKey 应用 unique_together?
我为这个问题找到的最近答案有 5 年了...我确实认为 Django 已经发展,问题可能不一样了。
更新:我的回答的前一版本是功能性的,但设计不佳,这个版本考虑了一些评论和其他答案。
在SQL NULL 不等于 NULL。这意味着如果您有两个 field_d == None and field_c == "somestring"
不相等的对象,那么您可以同时创建它们。
您可以覆盖 Model.clean
以添加支票:
class ModelB(Model):
#...
def validate_unique(self, exclude=None):
if ModelB.objects.exclude(id=self.id).filter(field_c=self.field_c, \
field_d__isnull=True).exists():
raise ValidationError("Duplicate ModelB")
super(ModelB, self).validate_unique(exclude)
如果在表单之外使用,您必须调用 full_clean
或 validate_unique
。
尽管如此,请注意处理竞争条件。
@ivan,我认为 django 没有简单的方法来处理这种情况。您需要考虑并非总是来自表单的所有创建和更新操作。另外,你应该考虑竞争条件......
并且因为您没有在数据库级别强制执行此逻辑,所以实际上可能会有双倍的记录,您应该在查询结果时检查它。
关于您的解决方案,它可能对表单有好处,但我不认为保存方法会引发 ValidationError。
如果可能的话,最好将此逻辑委托给 DB。在这种特殊情况下,您可以使用两个部分索引。 Whosebug 上有一个类似的问题 - Create unique constraint with null columns
因此您可以创建 Django 迁移,将两个部分索引添加到您的数据库
示例:
# Assume that app name is just `example`
CREATE_TWO_PARTIAL_INDEX = """
CREATE UNIQUE INDEX model_b_2col_uni_idx ON example_model_b (field_c, field_d)
WHERE field_d IS NOT NULL;
CREATE UNIQUE INDEX model_b_1col_uni_idx ON example_model_b (field_c)
WHERE field_d IS NULL;
"""
DROP_TWO_PARTIAL_INDEX = """
DROP INDEX model_b_2col_uni_idx;
DROP INDEX model_b_1col_uni_idx;
"""
class Migration(migrations.Migration):
dependencies = [
('example', 'PREVIOUS MIGRATION NAME'),
]
operations = [
migrations.RunSQL(CREATE_TWO_PARTIAL_INDEX, DROP_TWO_PARTIAL_INDEX)
]
我认为对于 Django 1.2+
,这是更清晰的方法在表单中它将被提升为 non_field_error 而没有 500 错误,在其他情况下,比如 DRF 你必须检查这个案例手册,因为它会是 500 错误。 但它总是会检查 unique_together!
class BaseModelExt(models.Model):
is_cleaned = False
def clean(self):
for field_tuple in self._meta.unique_together[:]:
unique_filter = {}
unique_fields = []
null_found = False
for field_name in field_tuple:
field_value = getattr(self, field_name)
if getattr(self, field_name) is None:
unique_filter['%s__isnull' % field_name] = True
null_found = True
else:
unique_filter['%s' % field_name] = field_value
unique_fields.append(field_name)
if null_found:
unique_queryset = self.__class__.objects.filter(**unique_filter)
if self.pk:
unique_queryset = unique_queryset.exclude(pk=self.pk)
if unique_queryset.exists():
msg = self.unique_error_message(self.__class__, tuple(unique_fields))
raise ValidationError(msg)
self.is_cleaned = True
def save(self, *args, **kwargs):
if not self.is_cleaned:
self.clean()
super().save(*args, **kwargs)
Django 2.2 添加了一个新的 constraints API,这使得在数据库中处理这种情况变得更加容易。
您将需要两个约束条件:
- 现有的元组约束;和
- 剩余键减去可空键,有条件
如果您有多个可为空的字段,我想您将需要处理排列。
这是一个示例,其中包含必须全部唯一的 thruple 个字段,其中只允许一个 NULL
:
from django.db import models
from django.db.models import Q
from django.db.models.constraints import UniqueConstraint
class Badger(models.Model):
required = models.ForeignKey(Required, ...)
optional = models.ForeignKey(Optional, null=True, ...)
key = models.CharField(db_index=True, ...)
class Meta:
constraints = [
UniqueConstraint(fields=['required', 'optional', 'key'],
name='unique_with_optional'),
UniqueConstraint(fields=['required', 'key'],
condition=Q(optional=None),
name='unique_without_optional'),
]
尚未提及的一种可能的解决方法是创建一个虚拟 ModelA
对象作为您的 NULL
值。然后你可以依赖数据库来强制执行唯一性约束。
为您的模型添加一个干净的方法 - 见下文:
def clean(self):
if Variants.objects.filter("""Your filter """).exclude(pk=self.pk).exists():
raise ValidationError("This variation is duplicated.")