This commit is contained in:
Rob Neff 2026-01-05 09:02:34 -07:00 committed by GitHub
commit 03d043e56f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 141 additions and 12 deletions

View File

@ -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

View File

@ -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):
"""

View File

@ -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):

View File

@ -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'})