From 8d796e810d09b97fe97f3e3221755ef9be0fd4a7 Mon Sep 17 00:00:00 2001 From: nefrob Date: Sun, 20 Jul 2025 17:02:33 -0600 Subject: [PATCH 1/6] chore: unique constraints with distinct condition fields use unique together validator --- rest_framework/serializers.py | 24 +++-- rest_framework/utils/field_mapping.py | 16 +++- tests/test_validators.py | 132 ++++++++++++++++++++------ 3 files changed, 129 insertions(+), 43 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index a8aa3df68f..655c355b54 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -43,6 +43,7 @@ UniqueForDateValidator, UniqueForMonthValidator, UniqueForYearValidator, UniqueTogetherValidator ) +from rest_framework.compat import get_referenced_base_fields_from_q # Note: We do the following so that users of the framework can use this style: # @@ -1459,18 +1460,23 @@ def get_unique_together_constraints(self, model): for unique_together in parent_class._meta.unique_together: yield unique_together, model._default_manager, [], None, None 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: condition_fields = [] else: - condition_fields = list(constraint.condition.referenced_base_fields) - yield ( - constraint.fields, - model._default_manager, - condition_fields, - constraint.condition, - constraint.nulls_distinct, - ) + condition_fields = list( + 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, + constraint.nulls_distinct, + ) def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs): """ diff --git a/rest_framework/utils/field_mapping.py b/rest_framework/utils/field_mapping.py index fd456a08c9..36ef5ebe8a 100644 --- a/rest_framework/utils/field_mapping.py +++ b/rest_framework/utils/field_mapping.py @@ -8,7 +8,9 @@ from django.db import models 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 NUMERIC_FIELD_TYPES = ( @@ -79,10 +81,16 @@ def get_unique_validators(field_name, model_field): unique_error_message = get_unique_error_message(model_field) queryset = model_field.model._default_manager for condition in conditions: - yield UniqueValidator( - queryset=queryset if condition is None else queryset.filter(condition), - message=unique_error_message + condition_fields = ( + get_referenced_base_fields_from_q(condition) + 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): diff --git a/tests/test_validators.py b/tests/test_validators.py index 82181f746f..7e14548c4f 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -170,6 +170,24 @@ class Meta: 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 Meta: model = UniquenessTogetherModel @@ -182,6 +200,12 @@ class Meta: fields = '__all__' +class ConditionUniquenessTogetherSerializer(serializers.ModelSerializer): + class Meta: + model = ConditionUniquenessTogetherModel + fields = '__all__' + + class TestUniquenessTogetherValidation(TestCase): def setUp(self): self.instance = UniquenessTogetherModel.objects.create( @@ -222,6 +246,22 @@ def test_is_not_unique_together(self): ] } + 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): """ In a unique together validation, one field may be non-unique @@ -235,6 +275,21 @@ def test_is_unique_together(self): '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): """ When performing an update, the existing instance does not count @@ -248,35 +303,52 @@ def test_updated_instance_excluded_from_unique_together(self): 'position': 1 } - def test_many_update_requires_child_instance(self): - class ListUpdateSerializer(serializers.ListSerializer): - def update(self, instance, validated_data): - return instance - - class Serializer(UniquenessTogetherSerializer): - id = serializers.IntegerField() - - class Meta(UniquenessTogetherSerializer.Meta): - list_serializer_class = ListUpdateSerializer - - serializer = Serializer( - instance=UniquenessTogetherModel.objects.all(), - data=[{ - 'id': self.instance.pk, - 'race_name': self.instance.race_name, - 'position': self.instance.position, - }], - many=True, - ) - message = ( - '`UniqueTogetherValidator` cannot determine the current instance ' - 'during a multiple update. Override ' - '`ListSerializer.run_child_validation()` to set `child.instance` ' - 'before validation.' - ) - with pytest.raises(RuntimeError, match=re.escape(message)): - serializer.is_valid() +def test_many_update_requires_child_instance(self): + class ListUpdateSerializer(serializers.ListSerializer): + def update(self, instance, validated_data): + return instance + + class Serializer(UniquenessTogetherSerializer): + id = serializers.IntegerField() + + class Meta(UniquenessTogetherSerializer.Meta): + list_serializer_class = ListUpdateSerializer + + serializer = Serializer( + instance=UniquenessTogetherModel.objects.all(), + data=[{ + 'id': self.instance.pk, + 'race_name': self.instance.race_name, + 'position': self.instance.position, + }], + many=True, + ) + message = ( + '`UniqueTogetherValidator` cannot determine the current instance ' + 'during a multiple update. Override ' + '`ListSerializer.run_child_validation()` to set `child.instance` ' + 'before validation.' + ) + + with pytest.raises(RuntimeError, match=re.escape(message)): + serializer.is_valid() + + +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): """ @@ -786,14 +858,14 @@ class Meta: def test_single_field_uniq_validators(self): """ UniqueConstraint with single field must be transformed into - field's UniqueValidator + field's UniqueValidator if no distinct condition fields exist (else UniqueTogetherValidator) """ # Backends like PostgreSQL add Min/Max validators for IntegerField; # SQLite does not because it has no fixed integer range. has_int_range = connection.ops.integer_field_range('IntegerField')[0] is not None extra_validators_qty = 2 if has_int_range else 0 serializer = UniqueConstraintSerializer() - assert len(serializer.validators) == 2 + assert len(serializer.validators) == 4 validators = serializer.fields['global_id'].validators assert len(validators) == 1 + extra_validators_qty assert validators[0].queryset == UniqueConstraintModel.objects From a2cc17a92f73e2d345a6ef7fd503d7d97c333da5 Mon Sep 17 00:00:00 2001 From: nefrob Date: Fri, 12 Dec 2025 11:08:35 -0700 Subject: [PATCH 2/6] chore: add comments and improve tests --- docs/api-guide/validators.md | 24 +++++++++++++++++++++++- rest_framework/serializers.py | 2 ++ rest_framework/utils/field_mapping.py | 2 ++ tests/test_validators.py | 27 +++++++++++++++++++++------ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/api-guide/validators.md b/docs/api-guide/validators.md index f598a7e062..41481c981d 100644 --- a/docs/api-guide/validators.md +++ b/docs/api-guide/validators.md @@ -222,7 +222,29 @@ For example: extra_kwargs = {'client': {'required': False}} validators = [] # Remove a default "unique together" constraint. -### Updating nested serializers +### UniqueConstraint with conditions + +When using Django's `UniqueConstraint` with conditions that reference other model fields, DRF will automatically use +`UniqueTogetherValidator` instead of field-level `UniqueValidator`. This ensures proper validation behavior when the constraint +effectively involves multiple fields. + +For example, a single-field constraint with a condition becomes a multi-field validation when the condition references other fields. + + class MyModel(models.Model): + name = models.CharField(max_length=100) + status = models.CharField(max_length=20) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['name'], + condition=models.Q(status='active'), + name='unique_active_name' + ) + ] + + +## Updating nested serializers When applying an update to an existing instance, uniqueness validators will exclude the current instance from the uniqueness check. The current instance diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 655c355b54..58ca9cad50 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -1468,6 +1468,8 @@ def get_unique_together_constraints(self, model): get_referenced_base_fields_from_q(constraint.condition) ) + # Combine constraint fields and condition fields. If the union + # involves multiple fields, treat as unique-together validation required_fields = {*constraint.fields, *condition_fields} if len(required_fields) > 1: yield ( diff --git a/rest_framework/utils/field_mapping.py b/rest_framework/utils/field_mapping.py index 36ef5ebe8a..64e7f8f248 100644 --- a/rest_framework/utils/field_mapping.py +++ b/rest_framework/utils/field_mapping.py @@ -86,6 +86,8 @@ def get_unique_validators(field_name, model_field): if condition is not None else set() ) + # Only use UniqueValidator if the union of field and condition fields is 1 + # (i.e. no additional fields referenced in conditions) if len(field_set | condition_fields) == 1: yield UniqueValidator( queryset=queryset if condition is None else queryset.filter(condition), diff --git a/tests/test_validators.py b/tests/test_validators.py index 7e14548c4f..464698f32e 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -248,7 +248,7 @@ def test_is_not_unique_together(self): def test_is_not_unique_together_condition_based(self): """ - Failing unique together validation should result in non field errors when a condition-based + 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) @@ -275,10 +275,10 @@ def test_is_unique_together(self): 'position': 2 } - def test_unique_together_condition_based(self): + def test_is_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. + In a condition-based unique together validation, data is valid when + the constrained field differs when the condition applies`. """ ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1) @@ -290,6 +290,21 @@ def test_unique_together_condition_based(self): 'position': 1 } + def test_is_unique_together_when_condition_does_not_apply(self): + """ + In a condition-based unique together validation, data is valid when + the condition does not apply, even if constrained fields match existing records. + """ + ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1) + + data = {'race_name': 'example', 'position': 2} + serializer = ConditionUniquenessTogetherSerializer(data=data) + assert serializer.is_valid() + assert serializer.validated_data == { + 'race_name': 'example', + 'position': 2 + } + def test_updated_instance_excluded_from_unique_together(self): """ When performing an update, the existing instance does not count @@ -340,10 +355,10 @@ 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) + instance = ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1) data = {'race_name': 'example', 'position': 0} - serializer = ConditionUniquenessTogetherSerializer(self.instance, data=data) + serializer = ConditionUniquenessTogetherSerializer(instance, data=data) assert serializer.is_valid() assert serializer.validated_data == { 'race_name': 'example', From 867e9be3d52b6c74133d82c037101454f89be410 Mon Sep 17 00:00:00 2001 From: nefrob Date: Fri, 12 Dec 2025 11:31:38 -0700 Subject: [PATCH 3/6] test: correct assertions --- tests/test_validators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_validators.py b/tests/test_validators.py index 464698f32e..2e77316110 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -884,11 +884,11 @@ def test_single_field_uniq_validators(self): validators = serializer.fields['global_id'].validators assert len(validators) == 1 + extra_validators_qty assert validators[0].queryset == UniqueConstraintModel.objects + ids_in_qs = {frozenset(v.queryset.values_list('id', flat=True)) for v in validators if hasattr(v, "queryset")} + assert ids_in_qs == {frozenset({1, 2, 3})} validators = serializer.fields['fancy_conditions'].validators - assert len(validators) == 2 + extra_validators_qty - ids_in_qs = {frozenset(v.queryset.values_list('id', flat=True)) for v in validators if hasattr(v, "queryset")} - assert ids_in_qs == {frozenset([1]), frozenset([3])} + assert len(validators) == extra_validators_qty def test_nullable_unique_constraint_fields_are_not_required(self): serializer = UniqueConstraintNullableSerializer(data={'title': 'Bob'}) From ff4ced3ef2e3d6793586fbe2bb1c14ac3c3d4a56 Mon Sep 17 00:00:00 2001 From: Rob Neff <25070989+nefrob@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:02:34 -0700 Subject: [PATCH 4/6] Add get_referenced_base_fields_from_q to compat and fix test indentation - Add helper function for extracting fields from Q objects - Move test methods inside TestUniquenessTogetherValidation class - All 67 tests passing Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- rest_framework/compat.py | 22 +++++++++++ tests/test_validators.py | 80 ++++++++++++++++++++-------------------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 67f3a72864..1d00696561 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -156,3 +156,25 @@ def split_header_value(value, sep=","): SHORT_SEPARATORS = (',', ':') LONG_SEPARATORS = (', ', ': ') INDENT_SEPARATORS = (',', ': ') + + +def get_referenced_base_fields_from_q(q_object): + """ + Return the base field names referenced by a Q object. + This is a compatibility helper for Django versions that may not have + `referenced_base_fields` attribute on Q objects. + """ + if q_object is None: + return set() + + referenced_fields = set() + for child in q_object.children: + if isinstance(child, tuple): + # child[0] is the field name (e.g., 'status', 'global_id__lte') + # We strip off any lookup part (__lte, __exact, etc.) + field_name = child[0].split('__')[0] + referenced_fields.add(field_name) + else: + # child is another Q object + referenced_fields.update(get_referenced_base_fields_from_q(child)) + return referenced_fields \ No newline at end of file diff --git a/tests/test_validators.py b/tests/test_validators.py index 2e77316110..45a2cc9674 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -278,7 +278,7 @@ def test_is_unique_together(self): def test_is_unique_together_condition_based(self): """ In a condition-based unique together validation, data is valid when - the constrained field differs when the condition applies`. + the constrained field differs when the condition applies. """ ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1) @@ -319,51 +319,51 @@ def test_updated_instance_excluded_from_unique_together(self): } -def test_many_update_requires_child_instance(self): - class ListUpdateSerializer(serializers.ListSerializer): - def update(self, instance, validated_data): - return instance + def test_many_update_requires_child_instance(self): + class ListUpdateSerializer(serializers.ListSerializer): + def update(self, instance, validated_data): + return instance - class Serializer(UniquenessTogetherSerializer): - id = serializers.IntegerField() + class Serializer(UniquenessTogetherSerializer): + id = serializers.IntegerField() - class Meta(UniquenessTogetherSerializer.Meta): - list_serializer_class = ListUpdateSerializer + class Meta(UniquenessTogetherSerializer.Meta): + list_serializer_class = ListUpdateSerializer - serializer = Serializer( - instance=UniquenessTogetherModel.objects.all(), - data=[{ - 'id': self.instance.pk, - 'race_name': self.instance.race_name, - 'position': self.instance.position, - }], - many=True, - ) - message = ( - '`UniqueTogetherValidator` cannot determine the current instance ' - 'during a multiple update. Override ' - '`ListSerializer.run_child_validation()` to set `child.instance` ' - 'before validation.' - ) + serializer = Serializer( + instance=UniquenessTogetherModel.objects.all(), + data=[{ + 'id': self.instance.pk, + 'race_name': self.instance.race_name, + 'position': self.instance.position, + }], + many=True, + ) + message = ( + '`UniqueTogetherValidator` cannot determine the current instance ' + 'during a multiple update. Override ' + '`ListSerializer.run_child_validation()` to set `child.instance` ' + 'before validation.' + ) - with pytest.raises(RuntimeError, match=re.escape(message)): - serializer.is_valid() + with pytest.raises(RuntimeError, match=re.escape(message)): + serializer.is_valid() -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. - """ - instance = ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1) - - data = {'race_name': 'example', 'position': 0} - serializer = ConditionUniquenessTogetherSerializer(instance, data=data) - assert serializer.is_valid() - assert serializer.validated_data == { - 'race_name': 'example', - 'position': 0 - } + 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. + """ + instance = ConditionUniquenessTogetherModel.objects.create(race_name='example', position=1) + + data = {'race_name': 'example', 'position': 0} + serializer = ConditionUniquenessTogetherSerializer(instance, data=data) + assert serializer.is_valid() + assert serializer.validated_data == { + 'race_name': 'example', + 'position': 0 + } def test_unique_together_is_required(self): """ From d773cffabfa625bf33378ee31e04368a53035d24 Mon Sep 17 00:00:00 2001 From: Majid Date: Thu, 13 Aug 2026 17:52:03 +0330 Subject: [PATCH 5/6] Fix pre-commit formatting issues (isort and flake8) --- rest_framework/compat.py | 2 +- rest_framework/serializers.py | 5 +++-- tests/test_validators.py | 2 -- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 1d00696561..f8521311fb 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -177,4 +177,4 @@ def get_referenced_base_fields_from_q(q_object): else: # child is another Q object referenced_fields.update(get_referenced_base_fields_from_q(child)) - return referenced_fields \ No newline at end of file + return referenced_fields diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 58ca9cad50..4b517cbaf1 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -26,7 +26,9 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from rest_framework.compat import postgres_fields +from rest_framework.compat import ( + get_referenced_base_fields_from_q, postgres_fields +) from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.fields import get_error_detail from rest_framework.settings import api_settings @@ -43,7 +45,6 @@ UniqueForDateValidator, UniqueForMonthValidator, UniqueForYearValidator, UniqueTogetherValidator ) -from rest_framework.compat import get_referenced_base_fields_from_q # Note: We do the following so that users of the framework can use this style: # diff --git a/tests/test_validators.py b/tests/test_validators.py index 45a2cc9674..96fc5b2e0b 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -318,7 +318,6 @@ def test_updated_instance_excluded_from_unique_together(self): 'position': 1 } - def test_many_update_requires_child_instance(self): class ListUpdateSerializer(serializers.ListSerializer): def update(self, instance, validated_data): @@ -349,7 +348,6 @@ class Meta(UniquenessTogetherSerializer.Meta): with pytest.raises(RuntimeError, match=re.escape(message)): serializer.is_valid() - def test_updated_instance_excluded_from_unique_together_condition_based(self): """ When performing an update, the existing instance does not count From e8794dc8018b7f42f2fba02a73d0b222f4b80643 Mon Sep 17 00:00:00 2001 From: Majid Date: Fri, 14 Aug 2026 11:33:27 +0330 Subject: [PATCH 6/6] Apply Copilot suggestions: fix heading hierarchy and use Django's Q.referenced_base_fields --- docs/api-guide/validators.md | 2 +- rest_framework/compat.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/validators.md b/docs/api-guide/validators.md index 41481c981d..106d6d4f06 100644 --- a/docs/api-guide/validators.md +++ b/docs/api-guide/validators.md @@ -244,7 +244,7 @@ For example, a single-field constraint with a condition becomes a multi-field va ] -## Updating nested serializers +### Updating nested serializers When applying an update to an existing instance, uniqueness validators will exclude the current instance from the uniqueness check. The current instance diff --git a/rest_framework/compat.py b/rest_framework/compat.py index f8521311fb..25947e2d71 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -167,12 +167,17 @@ def get_referenced_base_fields_from_q(q_object): if q_object is None: return set() + # Prefer Django's built-in implementation when available. + referenced = getattr(q_object, "referenced_base_fields", None) + if referenced is not None: + return set(referenced) + referenced_fields = set() for child in q_object.children: if isinstance(child, tuple): # child[0] is the field name (e.g., 'status', 'global_id__lte') # We strip off any lookup part (__lte, __exact, etc.) - field_name = child[0].split('__')[0] + field_name = child[0].split('__', 1)[0] referenced_fields.add(field_name) else: # child is another Q object