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
22 changes: 22 additions & 0 deletions docs/api-guide/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1443,12 +1443,24 @@ def get_unique_together_constraints(self, model):
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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do this need to be removed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're now including the condition fields as part of the applicable fields that contribute to this count below. If we kept this here the condition fields would not be considered, which we need for this fix.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't there be a distinction made between condition fields and constraint fields here?

Copy link
Author

@nefrob nefrob Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could split out the logic for clarity to require >1 constraint fields or 1 constraint field and 1+ condition fields distinct from the constraint field. Practically that would be the same however.

If you want to split out unique together constraints with a single constraint field and distinct condition fields into a new method, that would require a larger refactor of the existing constraint validation code.

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):
"""
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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Django’s UniqueConstraint supports custom error messages and codes via violation_error_message and violation_error_code, but as far as I can see these values are not propagated to UniqueValidator.
It would be great to have the same behaviour as for UniqueTogetherValidator, but I’m not sure this should be done within the scope of this PR.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point about custom error messages! That would be a nice enhancement but outside this PR's scope. Worth tracking in a separate issue.

queryset=queryset if condition is None else queryset.filter(condition),
message=unique_error_message,
)


def get_field_kwargs(field_name, model_field):
Expand Down
95 changes: 90 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,21 @@ def test_updated_instance_excluded_from_unique_together(self):
'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.
Expand Down Expand Up @@ -740,20 +825,20 @@ 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)
"""
# 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'})
Expand Down
Loading