Skip to content

Commit 12c78c9

Browse files
committed
Support UniqueConstraint
1 parent e5fb9af commit 12c78c9

File tree

3 files changed

+175
-47
lines changed

3 files changed

+175
-47
lines changed

rest_framework/serializers.py

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,23 @@ def get_extra_kwargs(self):
13961396

13971397
return extra_kwargs
13981398

1399+
def get_unique_together_constraints(self, model):
1400+
"""
1401+
Returns iterator of (fields, queryset), each entry describe an unique together
1402+
constraint on `fields` in `queryset`.
1403+
"""
1404+
for parent_class in [model] + list(model._meta.parents):
1405+
for unique_together in parent_class._meta.unique_together:
1406+
yield unique_together, model._default_manager
1407+
for constraint in parent_class._meta.constraints:
1408+
if isinstance(constraint, models.UniqueConstraint) and len(constraint.fields) > 1:
1409+
yield (
1410+
constraint.fields,
1411+
model._default_manager
1412+
if constraint.condition is None
1413+
else model._default_manager.filter(constraint.condition)
1414+
)
1415+
13991416
def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs):
14001417
"""
14011418
Return any additional field options that need to be included as a
@@ -1424,12 +1441,11 @@ def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs
14241441

14251442
unique_constraint_names -= {None}
14261443

1427-
# Include each of the `unique_together` field names,
1444+
# Include each of the `unique_together` and `UniqueConstraint` field names,
14281445
# so long as all the field names are included on the serializer.
1429-
for parent_class in [model] + list(model._meta.parents):
1430-
for unique_together_list in parent_class._meta.unique_together:
1431-
if set(field_names).issuperset(unique_together_list):
1432-
unique_constraint_names |= set(unique_together_list)
1446+
for unique_together_list, _ in self.get_unique_together_constraints(model):
1447+
if set(field_names).issuperset(unique_together_list):
1448+
unique_constraint_names |= set(unique_together_list)
14331449

14341450
# Now we have all the field names that have uniqueness constraints
14351451
# applied, we can add the extra 'required=...' or 'default=...'
@@ -1526,11 +1542,6 @@ def get_unique_together_validators(self):
15261542
"""
15271543
Determine a default set of validators for any unique_together constraints.
15281544
"""
1529-
model_class_inheritance_tree = (
1530-
[self.Meta.model] +
1531-
list(self.Meta.model._meta.parents)
1532-
)
1533-
15341545
# The field names we're passing though here only include fields
15351546
# which may map onto a model field. Any dotted field name lookups
15361547
# cannot map to a field, and must be a traversal, so we're not
@@ -1556,34 +1567,33 @@ def get_unique_together_validators(self):
15561567
# Note that we make sure to check `unique_together` both on the
15571568
# base model class, but also on any parent classes.
15581569
validators = []
1559-
for parent_class in model_class_inheritance_tree:
1560-
for unique_together in parent_class._meta.unique_together:
1561-
# Skip if serializer does not map to all unique together sources
1562-
if not set(source_map).issuperset(unique_together):
1563-
continue
1564-
1565-
for source in unique_together:
1566-
assert len(source_map[source]) == 1, (
1567-
"Unable to create `UniqueTogetherValidator` for "
1568-
"`{model}.{field}` as `{serializer}` has multiple "
1569-
"fields ({fields}) that map to this model field. "
1570-
"Either remove the extra fields, or override "
1571-
"`Meta.validators` with a `UniqueTogetherValidator` "
1572-
"using the desired field names."
1573-
.format(
1574-
model=self.Meta.model.__name__,
1575-
serializer=self.__class__.__name__,
1576-
field=source,
1577-
fields=', '.join(source_map[source]),
1578-
)
1579-
)
1570+
for unique_together, queryset in self.get_unique_together_constraints(self.Meta.model):
1571+
# Skip if serializer does not map to all unique together sources
1572+
if not set(source_map).issuperset(unique_together):
1573+
continue
15801574

1581-
field_names = tuple(source_map[f][0] for f in unique_together)
1582-
validator = UniqueTogetherValidator(
1583-
queryset=parent_class._default_manager,
1584-
fields=field_names
1575+
for source in unique_together:
1576+
assert len(source_map[source]) == 1, (
1577+
"Unable to create `UniqueTogetherValidator` for "
1578+
"`{model}.{field}` as `{serializer}` has multiple "
1579+
"fields ({fields}) that map to this model field. "
1580+
"Either remove the extra fields, or override "
1581+
"`Meta.validators` with a `UniqueTogetherValidator` "
1582+
"using the desired field names."
1583+
.format(
1584+
model=self.Meta.model.__name__,
1585+
serializer=self.__class__.__name__,
1586+
field=source,
1587+
fields=', '.join(source_map[source]),
1588+
)
15851589
)
1586-
validators.append(validator)
1590+
1591+
field_names = tuple(source_map[f][0] for f in unique_together)
1592+
validator = UniqueTogetherValidator(
1593+
queryset=queryset,
1594+
fields=field_names
1595+
)
1596+
validators.append(validator)
15871597
return validators
15881598

15891599
def get_unique_for_date_validators(self):

rest_framework/utils/field_mapping.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,34 @@ def get_detail_view_name(model):
6262
}
6363

6464

65+
def get_unique_validators(field_name, model_field):
66+
"""
67+
Returns a list of UniqueValidators that should be applied to the field.
68+
"""
69+
field_set = set([field_name])
70+
conditions = {
71+
c.condition
72+
for c in model_field.model._meta.constraints
73+
if isinstance(c, models.UniqueConstraint) and set(c.fields) == field_set
74+
}
75+
if getattr(model_field, 'unique', False):
76+
conditions.add(None)
77+
if not conditions:
78+
return
79+
unique_error_message = model_field.error_messages.get('unique', None)
80+
if unique_error_message:
81+
unique_error_message = unique_error_message % {
82+
'model_name': model_field.model._meta.verbose_name,
83+
'field_label': model_field.verbose_name
84+
}
85+
queryset = model_field.model._default_manager
86+
for condition in conditions:
87+
yield UniqueValidator(
88+
queryset=queryset if condition is None else queryset.filter(condition),
89+
message=unique_error_message
90+
)
91+
92+
6593
def get_field_kwargs(field_name, model_field):
6694
"""
6795
Creates a default instance of a basic non-relational field.
@@ -216,17 +244,7 @@ def get_field_kwargs(field_name, model_field):
216244
if not isinstance(validator, validators.MinLengthValidator)
217245
]
218246

219-
if getattr(model_field, 'unique', False):
220-
unique_error_message = model_field.error_messages.get('unique', None)
221-
if unique_error_message:
222-
unique_error_message = unique_error_message % {
223-
'model_name': model_field.model._meta.verbose_name,
224-
'field_label': model_field.verbose_name
225-
}
226-
validator = UniqueValidator(
227-
queryset=model_field.model._default_manager,
228-
message=unique_error_message)
229-
validator_kwarg.append(validator)
247+
validator_kwarg += get_unique_validators(field_name, model_field)
230248

231249
if validator_kwarg:
232250
kwargs['validators'] = validator_kwarg

tests/test_validators.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,106 @@ def filter(self, **kwargs):
451451
assert queryset.called_with == {'race_name': 'bar', 'position': 1}
452452

453453

454+
class UniqueConstraintModel(models.Model):
455+
race_name = models.CharField(max_length=100)
456+
position = models.IntegerField()
457+
global_id = models.IntegerField()
458+
fancy_conditions = models.IntegerField(null=True)
459+
460+
class Meta:
461+
constraints = [
462+
models.UniqueConstraint(
463+
name="unique_constraint_model_global_id_uniq",
464+
fields=('global_id',),
465+
),
466+
models.UniqueConstraint(
467+
name="unique_constraint_model_fancy_1_uniq",
468+
fields=('fancy_conditions',),
469+
condition=models.Q(global_id__lte=1)
470+
),
471+
models.UniqueConstraint(
472+
name="unique_constraint_model_fancy_3_uniq",
473+
fields=('fancy_conditions',),
474+
condition=models.Q(global_id__gte=3)
475+
),
476+
models.UniqueConstraint(
477+
name="unique_constraint_model_together_uniq",
478+
fields=('race_name', 'position'),
479+
condition=models.Q(race_name='example'),
480+
)
481+
]
482+
483+
484+
class UniqueConstraintSerializer(serializers.ModelSerializer):
485+
class Meta:
486+
model = UniqueConstraintModel
487+
fields = '__all__'
488+
489+
490+
class TestUniqueConstraintValidation(TestCase):
491+
def setUp(self):
492+
self.instance = UniqueConstraintModel.objects.create(
493+
race_name='example',
494+
position=1,
495+
global_id=1
496+
)
497+
UniqueConstraintModel.objects.create(
498+
race_name='example',
499+
position=2,
500+
global_id=2
501+
)
502+
UniqueConstraintModel.objects.create(
503+
race_name='other',
504+
position=1,
505+
global_id=3
506+
)
507+
508+
def test_repr(self):
509+
serializer = UniqueConstraintSerializer()
510+
# the order of validators isn't deterministic so delete
511+
# fancy_conditions field that has two of them
512+
del serializer.fields['fancy_conditions']
513+
expected = dedent("""
514+
UniqueConstraintSerializer():
515+
id = IntegerField(label='ID', read_only=True)
516+
race_name = CharField(max_length=100, required=True)
517+
position = IntegerField(required=True)
518+
global_id = IntegerField(validators=[<UniqueValidator(queryset=UniqueConstraintModel.objects.all())>])
519+
class Meta:
520+
validators = [<UniqueTogetherValidator(queryset=<QuerySet [<UniqueConstraintModel: UniqueConstraintModel object (1)>, <UniqueConstraintModel: UniqueConstraintModel object (2)>]>, fields=('race_name', 'position'))>]
521+
""")
522+
assert repr(serializer) == expected
523+
524+
def test_unique_together_field(self):
525+
"""
526+
UniqueConstraint fields and condition attributes must be passed
527+
to UniqueTogetherValidator as fields and queryset
528+
"""
529+
serializer = UniqueConstraintSerializer()
530+
assert len(serializer.validators) == 1
531+
validator = serializer.validators[0]
532+
assert validator.fields == ('race_name', 'position')
533+
assert set(validator.queryset.values_list(flat=True)) == set(
534+
UniqueConstraintModel.objects.filter(race_name='example').values_list(flat=True)
535+
)
536+
537+
def test_single_field_uniq_validators(self):
538+
"""
539+
UniqueConstraint with single field must be transformed into
540+
field's UniqueValidator
541+
"""
542+
serializer = UniqueConstraintSerializer()
543+
assert len(serializer.validators) == 1
544+
validators = serializer.fields['global_id'].validators
545+
assert len(validators) == 1
546+
assert validators[0].queryset == UniqueConstraintModel.objects
547+
548+
validators = serializer.fields['fancy_conditions'].validators
549+
assert len(validators) == 2
550+
ids_in_qs = {frozenset(v.queryset.values_list(flat=True)) for v in validators}
551+
assert ids_in_qs == {frozenset([1]), frozenset([3])}
552+
553+
454554
# Tests for `UniqueForDateValidator`
455555
# ----------------------------------
456556

0 commit comments

Comments
 (0)