Skip to content

Robust uniqueness checks. #4217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 23, 2016
Merged
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
9 changes: 6 additions & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ env:
- TOX_ENV=py27-django110
- TOX_ENV=py35-django110
- TOX_ENV=py34-django110
- TOX_ENV=py27-djangomaster
- TOX_ENV=py34-djangomaster
- TOX_ENV=py35-djangomaster

matrix:
fast_finish: true
allow_failures:
- TOX_ENV=py27-djangomaster
- TOX_ENV=py34-djangomaster
- TOX_ENV=py35-djangomaster
- env: TOX_ENV=py27-djangomaster
- env: TOX_ENV=py34-djangomaster
- env: TOX_ENV=py35-djangomaster

install:
# Virtualenv < 14 is required to keep the Python 3.2 builds running.
Expand Down
35 changes: 27 additions & 8 deletions rest_framework/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,32 @@
"""
from __future__ import unicode_literals

from django.db import DataError
from django.utils.translation import ugettext_lazy as _

from rest_framework.compat import unicode_to_repr
from rest_framework.exceptions import ValidationError
from rest_framework.utils.representation import smart_repr


# Robust filter and exist implementations. Ensures that queryset.exists() for
# an invalid value returns `False`, rather than raising an error.
# Refs https://github.com/tomchristie/django-rest-framework/issues/3381

def qs_exists(queryset):
try:
return queryset.exists()
except (TypeError, ValueError, DataError):
return False


def qs_filter(queryset, **kwargs):
try:
return queryset.filter(**kwargs)
except (TypeError, ValueError, DataError):
return queryset.none()


class UniqueValidator(object):
"""
Validator that corresponds to `unique=True` on a model field.
Expand Down Expand Up @@ -44,7 +63,7 @@ def filter_queryset(self, value, queryset):
Filter the queryset to all instances matching the given attribute.
"""
filter_kwargs = {self.field_name: value}
return queryset.filter(**filter_kwargs)
return qs_filter(queryset, **filter_kwargs)

def exclude_current_instance(self, queryset):
"""
Expand All @@ -59,7 +78,7 @@ def __call__(self, value):
queryset = self.queryset
queryset = self.filter_queryset(value, queryset)
queryset = self.exclude_current_instance(queryset)
if queryset.exists():
if qs_exists(queryset):
raise ValidationError(self.message)

def __repr__(self):
Expand Down Expand Up @@ -124,7 +143,7 @@ def filter_queryset(self, attrs, queryset):
field_name: attrs[field_name]
for field_name in self.fields
}
return queryset.filter(**filter_kwargs)
return qs_filter(queryset, **filter_kwargs)

def exclude_current_instance(self, attrs, queryset):
"""
Expand All @@ -145,7 +164,7 @@ def __call__(self, attrs):
checked_values = [
value for field, value in attrs.items() if field in self.fields
]
if None not in checked_values and queryset.exists():
if None not in checked_values and qs_exists(queryset):
field_names = ', '.join(self.fields)
raise ValidationError(self.message.format(field_names=field_names))

Expand Down Expand Up @@ -209,7 +228,7 @@ def __call__(self, attrs):
queryset = self.queryset
queryset = self.filter_queryset(attrs, queryset)
queryset = self.exclude_current_instance(attrs, queryset)
if queryset.exists():
if qs_exists(queryset):
message = self.message.format(date_field=self.date_field)
raise ValidationError({self.field: message})

Expand All @@ -234,7 +253,7 @@ def filter_queryset(self, attrs, queryset):
filter_kwargs['%s__day' % self.date_field_name] = date.day
filter_kwargs['%s__month' % self.date_field_name] = date.month
filter_kwargs['%s__year' % self.date_field_name] = date.year
return queryset.filter(**filter_kwargs)
return qs_filter(queryset, **filter_kwargs)


class UniqueForMonthValidator(BaseUniqueForValidator):
Expand All @@ -247,7 +266,7 @@ def filter_queryset(self, attrs, queryset):
filter_kwargs = {}
filter_kwargs[self.field_name] = value
filter_kwargs['%s__month' % self.date_field_name] = date.month
return queryset.filter(**filter_kwargs)
return qs_filter(queryset, **filter_kwargs)


class UniqueForYearValidator(BaseUniqueForValidator):
Expand All @@ -260,4 +279,4 @@ def filter_queryset(self, attrs, queryset):
filter_kwargs = {}
filter_kwargs[self.field_name] = value
filter_kwargs['%s__year' % self.date_field_name] = date.year
return queryset.filter(**filter_kwargs)
return qs_filter(queryset, **filter_kwargs)
16 changes: 16 additions & 0 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ class Meta:
fields = '__all__'


class IntegerFieldModel(models.Model):
integer = models.IntegerField()


class UniquenessIntegerSerializer(serializers.Serializer):
# Note that this field *deliberately* does not correspond with the model field.
# This allows us to ensure that `ValueError`, `TypeError` or `DataError` etc
# raised by a uniqueness check does not trigger a deceptive "this field is not unique"
# validation failure.
integer = serializers.CharField(validators=[UniqueValidator(queryset=IntegerFieldModel.objects.all())])


class TestUniquenessValidation(TestCase):
def setUp(self):
self.instance = UniquenessModel.objects.create(username='existing')
Expand Down Expand Up @@ -100,6 +112,10 @@ def test_related_model_is_unique(self):
rs = RelatedModelSerializer(data=data)
self.assertTrue(rs.is_valid())

def test_value_error_treated_as_not_unique(self):
serializer = UniquenessIntegerSerializer(data={'integer': 'abc'})
assert serializer.is_valid()


# Tests for `UniqueTogetherValidator`
# -----------------------------------
Expand Down