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

1 participant