mirror of
https://github.com/encode/django-rest-framework.git
synced 2026-01-10 18:50:56 +03:00
Merge de157ac760 into 3f190b7ddc
This commit is contained in:
commit
03d043e56f
|
|
@ -222,6 +222,28 @@ For example:
|
|||
extra_kwargs = {'client': {'required': False}}
|
||||
validators = [] # Remove a default "unique together" constraint.
|
||||
|
||||
### UniqueConstraint with conditions
|
||||
|
||||
When using Django's `UniqueConstraint` with conditions that reference other model fields, DRF will automatically use
|
||||
`UniqueTogetherValidator` instead of field-level `UniqueValidator`. This ensures proper validation behavior when the constraint
|
||||
effectively involves multiple fields.
|
||||
|
||||
For example, a single-field constraint with a condition becomes a multi-field validation when the condition references other fields.
|
||||
|
||||
class MyModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
status = models.CharField(max_length=20)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['name'],
|
||||
condition=models.Q(status='active'),
|
||||
name='unique_active_name'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
## Updating nested serializers
|
||||
|
||||
When applying an update to an existing instance, uniqueness validators will
|
||||
|
|
|
|||
|
|
@ -1443,12 +1443,24 @@ class ModelSerializer(Serializer):
|
|||
for unique_together in parent_class._meta.unique_together:
|
||||
yield unique_together, model._default_manager, [], None
|
||||
for constraint in parent_class._meta.constraints:
|
||||
if isinstance(constraint, models.UniqueConstraint) and len(constraint.fields) > 1:
|
||||
if isinstance(constraint, models.UniqueConstraint):
|
||||
if constraint.condition is None:
|
||||
condition_fields = []
|
||||
else:
|
||||
condition_fields = list(get_referenced_base_fields_from_q(constraint.condition))
|
||||
yield (constraint.fields, model._default_manager, condition_fields, constraint.condition)
|
||||
condition_fields = list(
|
||||
get_referenced_base_fields_from_q(constraint.condition)
|
||||
)
|
||||
|
||||
# Combine constraint fields and condition fields. If the union
|
||||
# involves multiple fields, treat as unique-together validation
|
||||
required_fields = {*constraint.fields, *condition_fields}
|
||||
if len(required_fields) > 1:
|
||||
yield (
|
||||
constraint.fields,
|
||||
model._default_manager,
|
||||
condition_fields,
|
||||
constraint.condition,
|
||||
)
|
||||
|
||||
def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ from django.core import validators
|
|||
from django.db import models
|
||||
from django.utils.text import capfirst
|
||||
|
||||
from rest_framework.compat import postgres_fields
|
||||
from rest_framework.compat import (
|
||||
get_referenced_base_fields_from_q, postgres_fields
|
||||
)
|
||||
from rest_framework.validators import UniqueValidator
|
||||
|
||||
NUMERIC_FIELD_TYPES = (
|
||||
|
|
@ -79,10 +81,18 @@ def get_unique_validators(field_name, model_field):
|
|||
unique_error_message = get_unique_error_message(model_field)
|
||||
queryset = model_field.model._default_manager
|
||||
for condition in conditions:
|
||||
yield UniqueValidator(
|
||||
queryset=queryset if condition is None else queryset.filter(condition),
|
||||
message=unique_error_message
|
||||
condition_fields = (
|
||||
get_referenced_base_fields_from_q(condition)
|
||||
if condition is not None
|
||||
else set()
|
||||
)
|
||||
# Only use UniqueValidator if the union of field and condition fields is 1
|
||||
# (i.e. no additional fields referenced in conditions)
|
||||
if len(field_set | condition_fields) == 1:
|
||||
yield UniqueValidator(
|
||||
queryset=queryset if condition is None else queryset.filter(condition),
|
||||
message=unique_error_message,
|
||||
)
|
||||
|
||||
|
||||
def get_field_kwargs(field_name, model_field):
|
||||
|
|
|
|||
|
|
@ -170,6 +170,24 @@ class NullUniquenessTogetherModel(models.Model):
|
|||
unique_together = ('race_name', 'position')
|
||||
|
||||
|
||||
class ConditionUniquenessTogetherModel(models.Model):
|
||||
"""
|
||||
Used to ensure that unique constraints with single fields but at least one other
|
||||
distinct condition field are included when checking unique_together constraints.
|
||||
"""
|
||||
race_name = models.CharField(max_length=100)
|
||||
position = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
name="condition_uniqueness_together_model_race_name",
|
||||
fields=('race_name',),
|
||||
condition=models.Q(position__lte=1)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class UniquenessTogetherSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UniquenessTogetherModel
|
||||
|
|
@ -182,6 +200,12 @@ class NullUniquenessTogetherSerializer(serializers.ModelSerializer):
|
|||
fields = '__all__'
|
||||
|
||||
|
||||
class ConditionUniquenessTogetherSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ConditionUniquenessTogetherModel
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class TestUniquenessTogetherValidation(TestCase):
|
||||
def setUp(self):
|
||||
self.instance = UniquenessTogetherModel.objects.create(
|
||||
|
|
@ -222,6 +246,22 @@ class TestUniquenessTogetherValidation(TestCase):
|
|||
]
|
||||
}
|
||||
|
||||
def test_is_not_unique_together_condition_based(self):
|
||||
"""
|
||||
Failing unique together validation should result in non-field errors when a condition-based
|
||||
unique together constraint is violated.
|
||||
"""
|
||||
ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
|
||||
|
||||
data = {'race_name': 'example', 'position': 1}
|
||||
serializer = ConditionUniquenessTogetherSerializer(data=data)
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors == {
|
||||
'non_field_errors': [
|
||||
'The fields race_name must make a unique set.'
|
||||
]
|
||||
}
|
||||
|
||||
def test_is_unique_together(self):
|
||||
"""
|
||||
In a unique together validation, one field may be non-unique
|
||||
|
|
@ -235,6 +275,36 @@ class TestUniquenessTogetherValidation(TestCase):
|
|||
'position': 2
|
||||
}
|
||||
|
||||
def test_is_unique_together_condition_based(self):
|
||||
"""
|
||||
In a condition-based unique together validation, data is valid when
|
||||
the constrained field differs when the condition applies`.
|
||||
"""
|
||||
ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
|
||||
|
||||
data = {'race_name': 'other', 'position': 1}
|
||||
serializer = ConditionUniquenessTogetherSerializer(data=data)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data == {
|
||||
'race_name': 'other',
|
||||
'position': 1
|
||||
}
|
||||
|
||||
def test_is_unique_together_when_condition_does_not_apply(self):
|
||||
"""
|
||||
In a condition-based unique together validation, data is valid when
|
||||
the condition does not apply, even if constrained fields match existing records.
|
||||
"""
|
||||
ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
|
||||
|
||||
data = {'race_name': 'example', 'position': 2}
|
||||
serializer = ConditionUniquenessTogetherSerializer(data=data)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data == {
|
||||
'race_name': 'example',
|
||||
'position': 2
|
||||
}
|
||||
|
||||
def test_updated_instance_excluded_from_unique_together(self):
|
||||
"""
|
||||
When performing an update, the existing instance does not count
|
||||
|
|
@ -248,6 +318,21 @@ class TestUniquenessTogetherValidation(TestCase):
|
|||
'position': 1
|
||||
}
|
||||
|
||||
def test_updated_instance_excluded_from_unique_together_condition_based(self):
|
||||
"""
|
||||
When performing an update, the existing instance does not count
|
||||
as a match against uniqueness.
|
||||
"""
|
||||
instance = ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
|
||||
|
||||
data = {'race_name': 'example', 'position': 0}
|
||||
serializer = ConditionUniquenessTogetherSerializer(instance, data=data)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data == {
|
||||
'race_name': 'example',
|
||||
'position': 0
|
||||
}
|
||||
|
||||
def test_unique_together_is_required(self):
|
||||
"""
|
||||
In a unique together validation, all fields are required.
|
||||
|
|
@ -740,20 +825,20 @@ class TestUniqueConstraintValidation(TestCase):
|
|||
def test_single_field_uniq_validators(self):
|
||||
"""
|
||||
UniqueConstraint with single field must be transformed into
|
||||
field's UniqueValidator
|
||||
field's UniqueValidator if no distinct condition fields exist (else UniqueTogetherValidator)
|
||||
"""
|
||||
# Django 5 includes Max and Min values validators for IntegerField
|
||||
extra_validators_qty = 2 if django_version[0] >= 5 else 0
|
||||
serializer = UniqueConstraintSerializer()
|
||||
assert len(serializer.validators) == 2
|
||||
assert len(serializer.validators) == 4
|
||||
validators = serializer.fields['global_id'].validators
|
||||
assert len(validators) == 1 + extra_validators_qty
|
||||
assert validators[0].queryset == UniqueConstraintModel.objects
|
||||
ids_in_qs = {frozenset(v.queryset.values_list('id', flat=True)) for v in validators if hasattr(v, "queryset")}
|
||||
assert ids_in_qs == {frozenset({1, 2, 3})}
|
||||
|
||||
validators = serializer.fields['fancy_conditions'].validators
|
||||
assert len(validators) == 2 + extra_validators_qty
|
||||
ids_in_qs = {frozenset(v.queryset.values_list('id', flat=True)) for v in validators if hasattr(v, "queryset")}
|
||||
assert ids_in_qs == {frozenset([1]), frozenset([3])}
|
||||
assert len(validators) == extra_validators_qty
|
||||
|
||||
def test_nullable_unique_constraint_fields_are_not_required(self):
|
||||
serializer = UniqueConstraintNullableSerializer(data={'title': 'Bob'})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user