Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-guide/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ The validator should be applied to *serializer classes*, like so:
]

!!! note
The `UniqueTogetherValidator` class always imposes an implicit constraint that all the fields it applies to are always treated as required. Fields with `default` values are an exception to this as they always supply a value even when omitted from user input.
The `UniqueTogetherValidator` class always imposes an implicit constraint that all the fields it applies to are always treated as required. Fields with `default` values are an exception to this as they always supply a value even when omitted from user input. When using `ModelSerializer`, model fields with `null=True` default to `None`, and `CharField` or `TextField` model fields with `blank=True` default to an empty string, so these are not treated as required either.

## UniqueForDateValidator

Expand Down
5 changes: 5 additions & 0 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,11 @@ def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs
default = unique_constraint_field.default
elif unique_constraint_field.null:
default = None
elif unique_constraint_field.blank and isinstance(unique_constraint_field, (models.CharField, models.TextField)):
# Django saves an omitted `blank=True` text field as an
# empty string, so use that as the default rather than
# making the field required.
default = ''
Comment thread
MehrazRumman marked this conversation as resolved.
else:
default = empty

Expand Down
128 changes: 128 additions & 0 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,27 @@ class Meta:
fields = '__all__'


class BlankUniquenessTogetherModel(models.Model):
"""
Used to ensure that `blank=True` text fields which are part of a
unique_together constraint are not treated as required.

Django stores an omitted blank text field as an empty string, so the
serializer should default to '' and still validate uniqueness against it.
"""
race_name = models.CharField(max_length=100, blank=True)
position = models.IntegerField()

class Meta:
unique_together = ('race_name', 'position')
Comment thread
MehrazRumman marked this conversation as resolved.


class BlankUniquenessTogetherSerializer(serializers.ModelSerializer):
class Meta:
model = BlankUniquenessTogetherModel
fields = '__all__'


class TestUniquenessTogetherValidation(TestCase):
def setUp(self):
self.instance = UniquenessTogetherModel.objects.create(
Expand Down Expand Up @@ -631,6 +652,47 @@ def test_ignore_validation_for_missing_nullable_fields(self):
serializer = NullUniquenessTogetherSerializer(data=data)
assert serializer.is_valid(), serializer.errors

def test_blank_fields_are_not_required(self):
# A `blank=True` text field that is part of the uniqueness constraint
# should not become required; it defaults to an empty string.
serializer = BlankUniquenessTogetherSerializer()
field = serializer.fields['race_name']
assert field.required is False
assert field.default == ''

serializer = BlankUniquenessTogetherSerializer(data={'position': 1})
assert serializer.is_valid(), serializer.errors
instance = serializer.save()
assert instance.race_name == ''

def test_validation_for_missing_blank_fields(self):
# An omitted blank field is validated using its empty string default,
# matching what the database would store and reject as a duplicate.
BlankUniquenessTogetherModel.objects.create(race_name='', position=1)
serializer = BlankUniquenessTogetherSerializer(data={'position': 1})
assert not serializer.is_valid()
assert serializer.errors == {
'non_field_errors': [
'The fields race_name, position must make a unique set.'
]
}

def test_validation_for_provided_blank_fields(self):
BlankUniquenessTogetherModel.objects.create(race_name='', position=1)
data = {'race_name': '', 'position': 1}
serializer = BlankUniquenessTogetherSerializer(data=data)
assert not serializer.is_valid()
assert serializer.errors == {
'non_field_errors': [
'The fields race_name, position must make a unique set.'
]
}

def test_ignore_validation_for_missing_blank_fields_without_duplicate(self):
BlankUniquenessTogetherModel.objects.create(race_name='', position=1)
serializer = BlankUniquenessTogetherSerializer(data={'position': 2})
assert serializer.is_valid(), serializer.errors

def test_do_not_ignore_validation_for_null_fields(self):
# None values that are not on fields part of the uniqueness constraint
# do not cause the instance to skip validation.
Expand Down Expand Up @@ -798,6 +860,26 @@ class Meta:
]


class UniqueConstraintBlankModel(models.Model):
"""
Mirrors the model from issue #9750: a conditional UniqueConstraint on a
`blank=True` text field, where empty strings are excluded from the
constraint and should be allowed to repeat.
"""
title = models.CharField(max_length=100)
age = models.IntegerField()
tag = models.CharField(max_length=100, blank=True)

class Meta:
constraints = [
models.UniqueConstraint(
name='unique_age_tag_when_tag_not_blank',
fields=('age', 'tag'),
condition=~models.Q(tag=''),
),
]


class UniqueConstraintNullsDistinctModel(models.Model):
name = models.CharField(max_length=100)
code = models.CharField(max_length=100, null=True)
Expand Down Expand Up @@ -845,6 +927,12 @@ class Meta:
fields = ('title', 'age', 'tag')


class UniqueConstraintBlankSerializer(serializers.ModelSerializer):
class Meta:
model = UniqueConstraintBlankModel
fields = ('title', 'age', 'tag')


class UniqueConstraintCustomMessageCodeSerializer(serializers.ModelSerializer):
class Meta:
model = UniqueConstraintCustomMessageCodeModel
Expand Down Expand Up @@ -961,6 +1049,46 @@ def test_nullable_unique_constraint_fields_are_not_required(self):
result = serializer.save()
self.assertIsInstance(result, UniqueConstraintNullableModel)

def test_blank_unique_constraint_fields_are_not_required(self):
serializer = UniqueConstraintBlankSerializer(data={'title': 'Bob', 'age': 1})
self.assertTrue(serializer.is_valid(), serializer.errors)
result = serializer.save()
self.assertIsInstance(result, UniqueConstraintBlankModel)
self.assertEqual(result.tag, '')

def test_multiple_blank_values_are_allowed(self):
"""
The scenario from issue #9750: the constraint's condition `~Q(tag='')`
excludes blank tags, so multiple rows may share the same `age` as long
as their tag is empty, whether the tag is omitted or sent as ''.
"""
UniqueConstraintBlankModel.objects.create(title='Alice', age=1, tag='')

serializer = UniqueConstraintBlankSerializer(data={'title': 'Bob', 'age': 1, 'tag': ''})
self.assertTrue(serializer.is_valid(), serializer.errors)
serializer.save()

serializer = UniqueConstraintBlankSerializer(data={'title': 'Carol', 'age': 1})
self.assertTrue(serializer.is_valid(), serializer.errors)
serializer.save()

self.assertEqual(
UniqueConstraintBlankModel.objects.filter(age=1, tag='').count(), 3
)

def test_conditional_unique_constraint_rejects_repeated_non_blank_values(self):
UniqueConstraintBlankModel.objects.create(title='Alice', age=1, tag='vip')

serializer = UniqueConstraintBlankSerializer(data={'title': 'Bob', 'age': 1, 'tag': 'vip'})
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors,
{'non_field_errors': ['The fields age, tag must make a unique set.']},
)

serializer = UniqueConstraintBlankSerializer(data={'title': 'Bob', 'age': 1, 'tag': 'other'})
self.assertTrue(serializer.is_valid(), serializer.errors)

def test_unique_constraint_source(self):
class SourceUniqueConstraintSerializer(serializers.ModelSerializer):
raceName = serializers.CharField(source="race_name")
Expand Down