Skip to content

Commit 6665e71

Browse files
committed
chore: unique constraints with distinct condition fields use unique together validator
1 parent 2ae8c11 commit 6665e71

File tree

3 files changed

+98
-13
lines changed

3 files changed

+98
-13
lines changed

rest_framework/serializers.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,12 +1435,22 @@ def get_unique_together_constraints(self, model):
14351435
for unique_together in parent_class._meta.unique_together:
14361436
yield unique_together, model._default_manager, [], None
14371437
for constraint in parent_class._meta.constraints:
1438-
if isinstance(constraint, models.UniqueConstraint) and len(constraint.fields) > 1:
1438+
if isinstance(constraint, models.UniqueConstraint):
14391439
if constraint.condition is None:
14401440
condition_fields = []
14411441
else:
1442-
condition_fields = list(get_referenced_base_fields_from_q(constraint.condition))
1443-
yield (constraint.fields, model._default_manager, condition_fields, constraint.condition)
1442+
condition_fields = list(
1443+
get_referenced_base_fields_from_q(constraint.condition)
1444+
)
1445+
1446+
required_fields = {*constraint.fields, *condition_fields}
1447+
if len(required_fields) > 1:
1448+
yield (
1449+
constraint.fields,
1450+
model._default_manager,
1451+
condition_fields,
1452+
constraint.condition,
1453+
)
14441454

14451455
def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs):
14461456
"""

rest_framework/utils/field_mapping.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from django.db import models
99
from django.utils.text import capfirst
1010

11-
from rest_framework.compat import postgres_fields
11+
from rest_framework.compat import (
12+
get_referenced_base_fields_from_q, postgres_fields
13+
)
1214
from rest_framework.validators import UniqueValidator
1315

1416
NUMERIC_FIELD_TYPES = (
@@ -79,10 +81,16 @@ def get_unique_validators(field_name, model_field):
7981
unique_error_message = get_unique_error_message(model_field)
8082
queryset = model_field.model._default_manager
8183
for condition in conditions:
82-
yield UniqueValidator(
83-
queryset=queryset if condition is None else queryset.filter(condition),
84-
message=unique_error_message
84+
condition_fields = (
85+
get_referenced_base_fields_from_q(condition)
86+
if condition is not None
87+
else set()
8588
)
89+
if len(field_set | condition_fields) == 1:
90+
yield UniqueValidator(
91+
queryset=queryset if condition is None else queryset.filter(condition),
92+
message=unique_error_message,
93+
)
8694

8795

8896
def get_field_kwargs(field_name, model_field):

tests/test_validators.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,24 @@ class Meta:
170170
unique_together = ('race_name', 'position')
171171

172172

173+
class ConditionUniquenessTogetherModel(models.Model):
174+
"""
175+
Used to ensure that unique constraints with single fields but at least one other
176+
distinct condition field are included when checking unique_together constraints.
177+
"""
178+
race_name = models.CharField(max_length=100)
179+
position = models.IntegerField()
180+
181+
class Meta:
182+
constraints = [
183+
models.UniqueConstraint(
184+
name="condition_uniqueness_together_model_race_name",
185+
fields=('race_name',),
186+
condition=models.Q(position__lte=1)
187+
)
188+
]
189+
190+
173191
class UniquenessTogetherSerializer(serializers.ModelSerializer):
174192
class Meta:
175193
model = UniquenessTogetherModel
@@ -182,6 +200,12 @@ class Meta:
182200
fields = '__all__'
183201

184202

203+
class ConditionUniquenessTogetherSerializer(serializers.ModelSerializer):
204+
class Meta:
205+
model = ConditionUniquenessTogetherModel
206+
fields = '__all__'
207+
208+
185209
class TestUniquenessTogetherValidation(TestCase):
186210
def setUp(self):
187211
self.instance = UniquenessTogetherModel.objects.create(
@@ -222,6 +246,22 @@ def test_is_not_unique_together(self):
222246
]
223247
}
224248

249+
def test_is_not_unique_together_condition_based(self):
250+
"""
251+
Failing unique together validation should result in non field errors when a condition-based
252+
unique together constraint is violated.
253+
"""
254+
ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
255+
256+
data = {'race_name': 'example', 'position': 1}
257+
serializer = ConditionUniquenessTogetherSerializer(data=data)
258+
assert not serializer.is_valid()
259+
assert serializer.errors == {
260+
'non_field_errors': [
261+
'The fields race_name must make a unique set.'
262+
]
263+
}
264+
225265
def test_is_unique_together(self):
226266
"""
227267
In a unique together validation, one field may be non-unique
@@ -235,6 +275,21 @@ def test_is_unique_together(self):
235275
'position': 2
236276
}
237277

278+
def test_unique_together_condition_based(self):
279+
"""
280+
In a unique together validation, one field may be non-unique
281+
so long as the set as a whole is unique.
282+
"""
283+
ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
284+
285+
data = {'race_name': 'other', 'position': 1}
286+
serializer = ConditionUniquenessTogetherSerializer(data=data)
287+
assert serializer.is_valid()
288+
assert serializer.validated_data == {
289+
'race_name': 'other',
290+
'position': 1
291+
}
292+
238293
def test_updated_instance_excluded_from_unique_together(self):
239294
"""
240295
When performing an update, the existing instance does not count
@@ -248,6 +303,21 @@ def test_updated_instance_excluded_from_unique_together(self):
248303
'position': 1
249304
}
250305

306+
def test_updated_instance_excluded_from_unique_together_condition_based(self):
307+
"""
308+
When performing an update, the existing instance does not count
309+
as a match against uniqueness.
310+
"""
311+
ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
312+
313+
data = {'race_name': 'example', 'position': 0}
314+
serializer = ConditionUniquenessTogetherSerializer(self.instance, data=data)
315+
assert serializer.is_valid()
316+
assert serializer.validated_data == {
317+
'race_name': 'example',
318+
'position': 0
319+
}
320+
251321
def test_unique_together_is_required(self):
252322
"""
253323
In a unique together validation, all fields are required.
@@ -699,20 +769,17 @@ class Meta:
699769
def test_single_field_uniq_validators(self):
700770
"""
701771
UniqueConstraint with single field must be transformed into
702-
field's UniqueValidator
772+
field's UniqueValidator if no distinct condition fields exist (else UniqueTogetherValidator)
703773
"""
704774
# Django 5 includes Max and Min values validators for IntegerField
705775
extra_validators_qty = 2 if django_version[0] >= 5 else 0
706776
serializer = UniqueConstraintSerializer()
707-
assert len(serializer.validators) == 2
777+
assert len(serializer.validators) == 4
708778
validators = serializer.fields['global_id'].validators
709779
assert len(validators) == 1 + extra_validators_qty
710780
assert validators[0].queryset == UniqueConstraintModel.objects
711-
712-
validators = serializer.fields['fancy_conditions'].validators
713-
assert len(validators) == 2 + extra_validators_qty
714781
ids_in_qs = {frozenset(v.queryset.values_list(flat=True)) for v in validators if hasattr(v, "queryset")}
715-
assert ids_in_qs == {frozenset([1]), frozenset([3])}
782+
assert ids_in_qs == {frozenset([1, 2, 3])}
716783

717784
def test_nullable_unique_constraint_fields_are_not_required(self):
718785
serializer = UniqueConstraintNullableSerializer(data={'title': 'Bob'})

0 commit comments

Comments
 (0)