Skip to content

Commit 8043317

Browse files
committed
chore: unique constraints with distinct condition fields use unique together validator
1 parent d0a5d5e commit 8043317

File tree

3 files changed

+97
-9
lines changed

3 files changed

+97
-9
lines changed

rest_framework/serializers.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,12 +1443,22 @@ def get_unique_together_constraints(self, model):
14431443
for unique_together in parent_class._meta.unique_together:
14441444
yield unique_together, model._default_manager, [], None
14451445
for constraint in parent_class._meta.constraints:
1446-
if isinstance(constraint, models.UniqueConstraint) and len(constraint.fields) > 1:
1446+
if isinstance(constraint, models.UniqueConstraint):
14471447
if constraint.condition is None:
14481448
condition_fields = []
14491449
else:
1450-
condition_fields = list(get_referenced_base_fields_from_q(constraint.condition))
1451-
yield (constraint.fields, model._default_manager, condition_fields, constraint.condition)
1450+
condition_fields = list(
1451+
get_referenced_base_fields_from_q(constraint.condition)
1452+
)
1453+
1454+
required_fields = {*constraint.fields, *condition_fields}
1455+
if len(required_fields) > 1:
1456+
yield (
1457+
constraint.fields,
1458+
model._default_manager,
1459+
condition_fields,
1460+
constraint.condition,
1461+
)
14521462

14531463
def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs):
14541464
"""

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: 72 additions & 2 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.
@@ -740,12 +810,12 @@ class Meta:
740810
def test_single_field_uniq_validators(self):
741811
"""
742812
UniqueConstraint with single field must be transformed into
743-
field's UniqueValidator
813+
field's UniqueValidator if no distinct condition fields exist (else UniqueTogetherValidator)
744814
"""
745815
# Django 5 includes Max and Min values validators for IntegerField
746816
extra_validators_qty = 2 if django_version[0] >= 5 else 0
747817
serializer = UniqueConstraintSerializer()
748-
assert len(serializer.validators) == 2
818+
assert len(serializer.validators) == 4
749819
validators = serializer.fields['global_id'].validators
750820
assert len(validators) == 1 + extra_validators_qty
751821
assert validators[0].queryset == UniqueConstraintModel.objects

0 commit comments

Comments
 (0)