Skip to content

Conversation

@mag123c
Copy link

@mag123c mag123c commented Jan 12, 2026

Fixes #8409

This PR adds support for Django 5.0's nulls_distinct option in UniqueTogetherValidator.

Problem

When nulls_distinct=False is set on a UniqueConstraint, DRF's validator still skips validation for NULL values, causing integrity errors on databases like Oracle where NULLs can violate unique constraints.

Solution

  • Extract nulls_distinct from UniqueConstraint in get_unique_together_constraints()
  • Pass it to UniqueTogetherValidator.__init__
  • When nulls_distinct=False, validate NULL values as potential duplicates

Changes

  • rest_framework/serializers.py: Updated get_unique_together_constraints() to yield nulls_distinct
  • rest_framework/validators.py: Added nulls_distinct parameter to UniqueTogetherValidator
  • tests/test_validators.py: Added tests for nulls_distinct=False behavior (Django 5.0+)

Backward Compatibility

  • nulls_distinct=None (default): Existing behavior preserved (skip NULL validation)
  • Django < 5.0: Uses getattr() fallback, no breaking changes

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for Django 5.0's nulls_distinct parameter to the UniqueTogetherValidator class in Django REST Framework. When nulls_distinct=False is set on a UniqueConstraint, the validator now properly treats NULL values as equal for uniqueness checks, preventing integrity errors on databases like Oracle where NULLs can violate unique constraints.

Changes:

  • Modified get_unique_together_constraints() to extract and yield the nulls_distinct attribute from constraints
  • Updated UniqueTogetherValidator to accept and handle the nulls_distinct parameter in validation logic
  • Added comprehensive tests for the new functionality with Django 5.0+ version guards

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
rest_framework/serializers.py Updated get_unique_together_constraints() to yield nulls_distinct from constraints and modified call sites to unpack the additional tuple element
rest_framework/validators.py Added nulls_distinct parameter to UniqueTogetherValidator, updated validation logic to skip NULL checks when nulls_distinct=False, and modified __repr__() and __eq__() methods
tests/test_validators.py Added test model with nulls_distinct=False constraint and comprehensive test cases for create operations and validator equality

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1092 to +1197
class TestUniqueConstraintNullsDistinct(TestCase):
"""
Tests for UniqueConstraint with nulls_distinct=False option.
When nulls_distinct=False, NULL values should be treated as equal
for uniqueness validation.
"""

def setUp(self):
from tests.test_validators import UniqueConstraintNullsDistinctModel

class UniqueConstraintNullsDistinctSerializer(serializers.ModelSerializer):
class Meta:
model = UniqueConstraintNullsDistinctModel
fields = ('name', 'code', 'category')

self.serializer_class = UniqueConstraintNullsDistinctSerializer

def test_nulls_distinct_false_validates_null_as_duplicate(self):
"""
When nulls_distinct=False, creating a second record with NULL values
in the constrained fields should fail validation.
"""
from tests.test_validators import UniqueConstraintNullsDistinctModel

# Create first record with NULL values
UniqueConstraintNullsDistinctModel.objects.create(
name='First',
code=None,
category=None
)

# Attempt to create second record with same NULL values
serializer = self.serializer_class(data={
'name': 'Second',
'code': None,
'category': None
})

# Should fail validation because nulls_distinct=False
assert not serializer.is_valid()

def test_nulls_distinct_false_allows_different_non_null_values(self):
"""
Non-NULL values should still work normally with uniqueness validation.
"""
from tests.test_validators import UniqueConstraintNullsDistinctModel

# Create first record with non-NULL values
UniqueConstraintNullsDistinctModel.objects.create(
name='First',
code='A',
category='X'
)

# Create second record with different values - should pass
serializer = self.serializer_class(data={
'name': 'Second',
'code': 'B',
'category': 'Y'
})
assert serializer.is_valid(), serializer.errors

def test_nulls_distinct_false_rejects_duplicate_non_null_values(self):
"""
Duplicate non-NULL values should still fail validation.
"""
from tests.test_validators import UniqueConstraintNullsDistinctModel

# Create first record
UniqueConstraintNullsDistinctModel.objects.create(
name='First',
code='A',
category='X'
)

# Attempt to create duplicate - should fail
serializer = self.serializer_class(data={
'name': 'Second',
'code': 'A',
'category': 'X'
})
assert not serializer.is_valid()

def test_unique_together_validator_nulls_distinct_equality(self):
"""
Test that UniqueTogetherValidator equality considers nulls_distinct.
"""
mock_queryset = MagicMock()
validator1 = UniqueTogetherValidator(
queryset=mock_queryset,
fields=('a', 'b'),
nulls_distinct=False
)
validator2 = UniqueTogetherValidator(
queryset=mock_queryset,
fields=('a', 'b'),
nulls_distinct=False
)
validator3 = UniqueTogetherValidator(
queryset=mock_queryset,
fields=('a', 'b'),
nulls_distinct=True
)

assert validator1 == validator2
assert validator1 != validator3
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test coverage for nulls_distinct=False is missing some important edge cases. Consider adding tests for:

  1. Update scenarios where an instance is being modified with NULL values
  2. Partial NULL scenarios (e.g., one field NULL, another non-NULL like code=None, category='X')
  3. Mixed update scenarios (updating from non-NULL to NULL values)

These scenarios would help ensure the validator correctly handles the interaction between nulls_distinct=False and the update logic in UniqueTogetherValidator.__call__().

Copilot uses AI. Check for mistakes.
"""

def setUp(self):
from tests.test_validators import UniqueConstraintNullsDistinctModel
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module 'tests.test_validators' imports itself.

Copilot uses AI. Check for mistakes.
When nulls_distinct=False, creating a second record with NULL values
in the constrained fields should fail validation.
"""
from tests.test_validators import UniqueConstraintNullsDistinctModel
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module 'tests.test_validators' imports itself.

Copilot uses AI. Check for mistakes.
"""
Non-NULL values should still work normally with uniqueness validation.
"""
from tests.test_validators import UniqueConstraintNullsDistinctModel
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module 'tests.test_validators' imports itself.

Copilot uses AI. Check for mistakes.
"""
Duplicate non-NULL values should still fail validation.
"""
from tests.test_validators import UniqueConstraintNullsDistinctModel
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module 'tests.test_validators' imports itself.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UniqueTogetherValidator doesn't enforce uniqueness of NULL values on databases that DO enforce them

1 participant