Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
24 changes: 23 additions & 1 deletion docs/api-guide/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,29 @@ For example:
extra_kwargs = {'client': {'required': False}}
validators = [] # Remove a default "unique together" constraint.

### Updating nested serializers
### 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
exclude the current instance from the uniqueness check. The current instance
Expand Down
22 changes: 22 additions & 0 deletions rest_framework/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,25 @@ def split_header_value(value, sep=","):
SHORT_SEPARATORS = (',', ':')
LONG_SEPARATORS = (', ', ': ')
INDENT_SEPARATORS = (',', ': ')


def get_referenced_base_fields_from_q(q_object):
"""
Return the base field names referenced by a Q object.
This is a compatibility helper for Django versions that may not have
`referenced_base_fields` attribute on Q objects.
"""
if q_object is None:
return set()

referenced_fields = set()
for child in q_object.children:
if isinstance(child, tuple):
# child[0] is the field name (e.g., 'status', 'global_id__lte')
# We strip off any lookup part (__lte, __exact, etc.)
field_name = child[0].split('__')[0]
referenced_fields.add(field_name)
else:
# child is another Q object
referenced_fields.update(get_referenced_base_fields_from_q(child))
return referenced_fields
Comment thread
auvipy marked this conversation as resolved.
Outdated
26 changes: 17 additions & 9 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
UniqueForDateValidator, UniqueForMonthValidator, UniqueForYearValidator,
UniqueTogetherValidator
)
from rest_framework.compat import get_referenced_base_fields_from_q

# Note: We do the following so that users of the framework can use this style:
#
Expand Down Expand Up @@ -1459,18 +1460,25 @@ def get_unique_together_constraints(self, model):
for unique_together in parent_class._meta.unique_together:
yield unique_together, model._default_manager, [], None, 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(constraint.condition.referenced_base_fields)
yield (
constraint.fields,
model._default_manager,
condition_fields,
constraint.condition,
constraint.nulls_distinct,
)
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,
constraint.nulls_distinct,
)

def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs):
"""
Expand Down
18 changes: 14 additions & 4 deletions rest_framework/utils/field_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
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 = (
Expand Down Expand Up @@ -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):
Expand Down
97 changes: 92 additions & 5 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,24 @@ class Meta:
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
Expand All @@ -182,6 +200,12 @@ class Meta:
fields = '__all__'


class ConditionUniquenessTogetherSerializer(serializers.ModelSerializer):
class Meta:
model = ConditionUniquenessTogetherModel
fields = '__all__'


class TestUniquenessTogetherValidation(TestCase):
def setUp(self):
self.instance = UniquenessTogetherModel.objects.create(
Expand Down Expand Up @@ -222,6 +246,22 @@ def test_is_not_unique_together(self):
]
}

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
Expand All @@ -235,6 +275,36 @@ def test_is_unique_together(self):
'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
Expand All @@ -248,6 +318,7 @@ def test_updated_instance_excluded_from_unique_together(self):
'position': 1
}


def test_many_update_requires_child_instance(self):
class ListUpdateSerializer(serializers.ListSerializer):
def update(self, instance, validated_data):
Expand Down Expand Up @@ -278,6 +349,22 @@ class Meta(UniquenessTogetherSerializer.Meta):
with pytest.raises(RuntimeError, match=re.escape(message)):
serializer.is_valid()


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.
Expand Down Expand Up @@ -786,22 +873,22 @@ class Meta:
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)
"""
# Backends like PostgreSQL add Min/Max validators for IntegerField;
# SQLite does not because it has no fixed integer range.
has_int_range = connection.ops.integer_field_range('IntegerField')[0] is not None
extra_validators_qty = 2 if has_int_range 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'})
Expand Down
Loading