Skip to content

Commit 1a1a2a7

Browse files
committed
fix: Add support for unique_together to UniqueFieldsMixin
Refs #49
1 parent 056b332 commit 1a1a2a7

File tree

5 files changed

+132
-2
lines changed

5 files changed

+132
-2
lines changed

drf_writable_nested/mixins.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from django.utils.translation import gettext_lazy as _
1111
from rest_framework import serializers
1212
from rest_framework.exceptions import ValidationError
13-
from rest_framework.validators import UniqueValidator
13+
from rest_framework.validators import UniqueTogetherValidator, UniqueValidator
1414

1515

1616
class BaseNestedModelSerializer(serializers.ModelSerializer):
@@ -400,12 +400,14 @@ class Meta:
400400
(`UniqueFieldsMixin` and `NestedCreateMixin` or `NestedUpdateMixin`)
401401
you should put `UniqueFieldsMixin` ahead.
402402
"""
403-
_unique_fields = [] # type: List[Tuple[str,UniqueValidator]]
403+
_unique_fields = [] # type: List[Tuple[str, UniqueValidator]]
404+
_unique_together_validators = [] # type: List[UniqueTogetherValidator]
404405

405406
def get_fields(self):
406407
self._unique_fields = []
407408

408409
fields = super(UniqueFieldsMixin, self).get_fields()
410+
409411
for field_name, field in fields.items():
410412
unique_validators = [validator
411413
for validator in field.validators
@@ -419,6 +421,10 @@ def get_fields(self):
419421

420422
return fields
421423

424+
def get_unique_together_validators(self):
425+
self._unique_together_validators = super().get_unique_together_validators()
426+
return []
427+
422428
def _validate_unique_fields(self, validated_data):
423429
for unique_field in self._unique_fields:
424430
field_name, unique_validator = unique_field
@@ -434,6 +440,8 @@ def _validate_unique_fields(self, validated_data):
434440
self.fields[field_name])
435441
except ValidationError as exc:
436442
raise ValidationError({field_name: exc.detail})
443+
for validator in self._unique_together_validators:
444+
validator(validated_data, self)
437445

438446
def create(self, validated_data):
439447
self._validate_unique_fields(validated_data)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 5.2.6 on 2025-09-14 14:12
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('tests', '0002_alter_profile_sites_setnullforeignkey_and_more'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='ItemCategory',
16+
fields=[
17+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('name', models.CharField(max_length=50)),
19+
('company', models.CharField(max_length=50)),
20+
],
21+
options={
22+
'unique_together': {('name', 'company')},
23+
},
24+
),
25+
migrations.CreateModel(
26+
name='ItemParent',
27+
fields=[
28+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
29+
('child', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.itemcategory')),
30+
],
31+
),
32+
]

tests/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ class UFMParent(models.Model):
128128
child = models.ForeignKey(UFMChild, on_delete=models.CASCADE)
129129

130130

131+
class ItemCategory(models.Model):
132+
name = models.CharField(max_length=50)
133+
company = models.CharField(max_length=50)
134+
135+
class Meta:
136+
unique_together = (
137+
("name", "company"),
138+
)
139+
140+
141+
class ItemParent(models.Model):
142+
child = models.ForeignKey(ItemCategory, on_delete=models.CASCADE)
143+
144+
131145
# Models for different relations
132146

133147
class ForeignKeyChild(models.Model):

tests/serializers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,20 @@ class Meta:
228228
model = models.UFMParent
229229
fields = ('pk', 'child')
230230

231+
# UniqueFieldsMixin, unique_together validation serializers
232+
233+
class ItemCategorySerializer(UniqueFieldsMixin, serializers.ModelSerializer):
234+
class Meta:
235+
model = models.ItemCategory
236+
fields = "__all__"
237+
238+
239+
class ItemParentSerializer(WritableNestedModelSerializer):
240+
child = ItemCategorySerializer()
241+
242+
class Meta:
243+
model = models.ItemParent
244+
fields = "__all__"
231245

232246
# Different relations
233247

tests/test_unique_fields_mixin.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,65 @@ def test_unique_field_not_required_for_partial_updates(self):
101101
)
102102
self.assertTrue(serializer.is_valid())
103103
serializer.save()
104+
105+
106+
class UniqueFieldsMixinUniqueTogetherTestCase(TestCase):
107+
def test_create_update_success(self):
108+
serializer = serializers.ItemParentSerializer(
109+
data={'child': {'name': 'Video Cards', 'company': 'Example'}})
110+
self.assertTrue(serializer.is_valid())
111+
parent = serializer.save() # type: models.ItemParent
112+
113+
serializer = serializers.ItemParentSerializer(
114+
instance=parent,
115+
data={
116+
'pk': parent.pk,
117+
'child': {
118+
'pk': parent.child.pk,
119+
'name': 'value',
120+
'company': 'value',
121+
}
122+
}
123+
)
124+
self.assertTrue(serializer.is_valid())
125+
serializer.save()
126+
127+
def test_create_update_failed(self):
128+
# In this case everything is valid on the validation stage, because
129+
# UniqueTogetherValidator is skipped
130+
# But `save` should raise an exception on create/update
131+
132+
child = models.ItemCategory.objects.create(name='value', company='value')
133+
parent = models.ItemParent.objects.create(child=child)
134+
135+
default_error_detail = ErrorDetail(
136+
string='The fields name, company must make a unique set.',
137+
code='unique')
138+
serializer = serializers.ItemParentSerializer(
139+
data={
140+
'child': {
141+
'name': child.name,
142+
'company': child.company,
143+
}
144+
}
145+
)
146+
147+
self.assertTrue(serializer.is_valid())
148+
149+
with self.assertRaises(ValidationError) as ctx:
150+
serializer.save()
151+
self.assertEqual(
152+
ctx.exception.detail,
153+
{'child': [default_error_detail]}
154+
)
155+
156+
157+
def test_unique_field_not_required_for_partial_updates(self):
158+
child = models.ItemCategory.objects.create(name='value', company='value')
159+
serializer = serializers.ItemCategorySerializer(
160+
instance=child,
161+
data={},
162+
partial=True
163+
)
164+
self.assertTrue(serializer.is_valid())
165+
serializer.save()

0 commit comments

Comments
 (0)