chore: unique constraints with distinct condition fields use unique together validator

This commit is contained in:
nefrob 2025-07-20 17:02:33 -06:00
parent 2ae8c117da
commit 6665e71e82
3 changed files with 98 additions and 13 deletions

View File

@ -1435,12 +1435,22 @@ class ModelSerializer(Serializer):
for unique_together in parent_class._meta.unique_together: for unique_together in parent_class._meta.unique_together:
yield unique_together, model._default_manager, [], None yield unique_together, model._default_manager, [], None
for constraint in parent_class._meta.constraints: for constraint in parent_class._meta.constraints:
if isinstance(constraint, models.UniqueConstraint) and len(constraint.fields) > 1: if isinstance(constraint, models.UniqueConstraint):
if constraint.condition is None: if constraint.condition is None:
condition_fields = [] condition_fields = []
else: else:
condition_fields = list(get_referenced_base_fields_from_q(constraint.condition)) condition_fields = list(
yield (constraint.fields, model._default_manager, condition_fields, constraint.condition) get_referenced_base_fields_from_q(constraint.condition)
)
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): def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs):
""" """

View File

@ -8,7 +8,9 @@ from django.core import validators
from django.db import models from django.db import models
from django.utils.text import capfirst 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 from rest_framework.validators import UniqueValidator
NUMERIC_FIELD_TYPES = ( NUMERIC_FIELD_TYPES = (
@ -79,10 +81,16 @@ def get_unique_validators(field_name, model_field):
unique_error_message = get_unique_error_message(model_field) unique_error_message = get_unique_error_message(model_field)
queryset = model_field.model._default_manager queryset = model_field.model._default_manager
for condition in conditions: for condition in conditions:
yield UniqueValidator( condition_fields = (
queryset=queryset if condition is None else queryset.filter(condition), get_referenced_base_fields_from_q(condition)
message=unique_error_message if condition is not None
else set()
) )
if len(field_set | condition_fields) == 1:
yield UniqueValidator(
queryset=queryset if condition is None else queryset.filter(condition),
message=unique_error_message,
)
def get_field_kwargs(field_name, model_field): def get_field_kwargs(field_name, model_field):

View File

@ -170,6 +170,24 @@ class NullUniquenessTogetherModel(models.Model):
unique_together = ('race_name', 'position') 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 UniquenessTogetherSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = UniquenessTogetherModel model = UniquenessTogetherModel
@ -182,6 +200,12 @@ class NullUniquenessTogetherSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
class ConditionUniquenessTogetherSerializer(serializers.ModelSerializer):
class Meta:
model = ConditionUniquenessTogetherModel
fields = '__all__'
class TestUniquenessTogetherValidation(TestCase): class TestUniquenessTogetherValidation(TestCase):
def setUp(self): def setUp(self):
self.instance = UniquenessTogetherModel.objects.create( self.instance = UniquenessTogetherModel.objects.create(
@ -222,6 +246,22 @@ class TestUniquenessTogetherValidation(TestCase):
] ]
} }
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): def test_is_unique_together(self):
""" """
In a unique together validation, one field may be non-unique In a unique together validation, one field may be non-unique
@ -235,6 +275,21 @@ class TestUniquenessTogetherValidation(TestCase):
'position': 2 'position': 2
} }
def test_unique_together_condition_based(self):
"""
In a unique together validation, one field may be non-unique
so long as the set as a whole is unique.
"""
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_updated_instance_excluded_from_unique_together(self): def test_updated_instance_excluded_from_unique_together(self):
""" """
When performing an update, the existing instance does not count When performing an update, the existing instance does not count
@ -248,6 +303,21 @@ class TestUniquenessTogetherValidation(TestCase):
'position': 1 '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.
"""
ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1)
data = {'race_name': 'example', 'position': 0}
serializer = ConditionUniquenessTogetherSerializer(self.instance, data=data)
assert serializer.is_valid()
assert serializer.validated_data == {
'race_name': 'example',
'position': 0
}
def test_unique_together_is_required(self): def test_unique_together_is_required(self):
""" """
In a unique together validation, all fields are required. In a unique together validation, all fields are required.
@ -699,20 +769,17 @@ class TestUniqueConstraintValidation(TestCase):
def test_single_field_uniq_validators(self): def test_single_field_uniq_validators(self):
""" """
UniqueConstraint with single field must be transformed into 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 # Django 5 includes Max and Min values validators for IntegerField
extra_validators_qty = 2 if django_version[0] >= 5 else 0 extra_validators_qty = 2 if django_version[0] >= 5 else 0
serializer = UniqueConstraintSerializer() serializer = UniqueConstraintSerializer()
assert len(serializer.validators) == 2 assert len(serializer.validators) == 4
validators = serializer.fields['global_id'].validators validators = serializer.fields['global_id'].validators
assert len(validators) == 1 + extra_validators_qty assert len(validators) == 1 + extra_validators_qty
assert validators[0].queryset == UniqueConstraintModel.objects assert validators[0].queryset == UniqueConstraintModel.objects
validators = serializer.fields['fancy_conditions'].validators
assert len(validators) == 2 + extra_validators_qty
ids_in_qs = {frozenset(v.queryset.values_list(flat=True)) for v in validators if hasattr(v, "queryset")} ids_in_qs = {frozenset(v.queryset.values_list(flat=True)) for v in validators if hasattr(v, "queryset")}
assert ids_in_qs == {frozenset([1]), frozenset([3])} assert ids_in_qs == {frozenset([1, 2, 3])}
def test_nullable_unique_constraint_fields_are_not_required(self): def test_nullable_unique_constraint_fields_are_not_required(self):
serializer = UniqueConstraintNullableSerializer(data={'title': 'Bob'}) serializer = UniqueConstraintNullableSerializer(data={'title': 'Bob'})