Skip to content

Commit ce3dcc7

Browse files
majidkhazaeinefrobauvipy
authored
Fix UniqueConstraint validation with conditional fields (#9707) (#10021)
* chore: unique constraints with distinct condition fields use unique together validator * chore: add comments and improve tests * test: correct assertions * 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 * Apply Copilot suggestions: fix heading hierarchy and use Django's Q.referenced_base_fields * Fix all remaining issues: empty constraint.fields, preserve constraint for custom messages, recheck condition field changes - Add check for empty constraint.fields in get_unique_together_constraints - Preserve original constraint object for custom violation messages/codes - Extend UniqueTogetherValidator to recheck when condition fields change - Add test for condition field change triggering revalidation * Refactor uniqueness validation test case * Fix nulls_distinct guard and update docstring * Address review feedback: remove compat helper, fix partial update regression, cleanup style --------- Co-authored-by: nefrob <nefrob.skyguy@gmail.com> Co-authored-by: Asif Saif Uddin {"Auvi":"অভি"} <auvipy@gmail.com>
1 parent 0189828 commit ce3dcc7

5 files changed

Lines changed: 229 additions & 41 deletions

File tree

‎docs/api-guide/validators.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,28 @@ For example:
222222
extra_kwargs = {'client': {'required': False}}
223223
validators = [] # Remove a default "unique together" constraint.
224224

225+
### UniqueConstraint with conditions
226+
227+
When using Django's `UniqueConstraint` with conditions that reference other model fields, DRF will automatically use
228+
`UniqueTogetherValidator` instead of field-level `UniqueValidator`. This ensures proper validation behavior when the constraint
229+
effectively involves multiple fields.
230+
231+
For example, a single-field constraint with a condition becomes a multi-field validation when the condition references other fields.
232+
233+
class MyModel(models.Model):
234+
name = models.CharField(max_length=100)
235+
status = models.CharField(max_length=20)
236+
237+
class Meta:
238+
constraints = [
239+
models.UniqueConstraint(
240+
fields=['name'],
241+
condition=models.Q(status='active'),
242+
name='unique_active_name'
243+
)
244+
]
245+
246+
225247
### Updating nested serializers
226248

227249
When applying an update to an existing instance, uniqueness validators will

‎rest_framework/serializers.py‎

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,26 +1463,42 @@ def get_extra_kwargs(self):
14631463

14641464
def get_unique_together_constraints(self, model):
14651465
"""
1466-
Returns iterator of (fields, queryset, condition_fields, condition, nulls_distinct),
1467-
each entry describes an unique together constraint on `fields` in `queryset`
1468-
with respect of constraint's `condition` and `nulls_distinct` option.
1466+
Returns iterator of (
1467+
fields,
1468+
queryset,
1469+
condition_fields,
1470+
condition,
1471+
nulls_distinct,
1472+
constraint,
1473+
),
1474+
each entry describes a unique together constraint on `fields` in `queryset`
1475+
with respect of constraint's `condition`, `nulls_distinct` option, and
1476+
the original constraint object (for custom error messages/codes).
14691477
"""
14701478
for parent_class in [model] + list(model._meta.parents):
14711479
for unique_together in parent_class._meta.unique_together:
1472-
yield unique_together, model._default_manager, [], None, None
1480+
yield unique_together, model._default_manager, [], None, None, None
14731481
for constraint in parent_class._meta.constraints:
1474-
if isinstance(constraint, models.UniqueConstraint) and len(constraint.fields) > 1:
1482+
if isinstance(constraint, models.UniqueConstraint):
14751483
if constraint.condition is None:
14761484
condition_fields = []
14771485
else:
1478-
condition_fields = list(constraint.condition.referenced_base_fields)
1479-
yield (
1480-
constraint.fields,
1481-
model._default_manager,
1482-
condition_fields,
1483-
constraint.condition,
1484-
constraint.nulls_distinct,
1485-
)
1486+
condition_fields = list(
1487+
constraint.condition.referenced_base_fields
1488+
)
1489+
1490+
# Combine constraint fields and condition fields. If the union
1491+
# involves multiple fields, treat as unique-together validation
1492+
required_fields = {*constraint.fields, *condition_fields}
1493+
if constraint.fields and len(required_fields) > 1:
1494+
yield (
1495+
constraint.fields,
1496+
model._default_manager,
1497+
condition_fields,
1498+
constraint.condition,
1499+
constraint.nulls_distinct,
1500+
constraint,
1501+
)
14861502

14871503
def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs):
14881504
"""
@@ -1515,7 +1531,8 @@ def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs
15151531

15161532
# Include each of the `unique_together` and `UniqueConstraint` field names,
15171533
# so long as all the field names are included on the serializer.
1518-
for unique_together_list, queryset, condition_fields, condition, nulls_distinct in self.get_unique_together_constraints(model):
1534+
for unique_together_list, queryset, condition_fields, condition, nulls_distinct, unused_constraint in self.get_unique_together_constraints(
1535+
model):
15191536
unique_together_list_and_condition_fields = set(unique_together_list) | set(condition_fields)
15201537
if model_fields_names.issuperset(unique_together_list_and_condition_fields):
15211538
unique_constraint_names |= unique_together_list_and_condition_fields
@@ -1629,7 +1646,12 @@ def _get_constraint_violation_error_message(self, constraint):
16291646

16301647
def get_unique_together_validators(self):
16311648
"""
1632-
Determine a default set of validators for any unique_together constraints.
1649+
Determine a default set of validators for any unique_together constraints
1650+
and UniqueConstraint objects.
1651+
1652+
This method now preserves the original constraint object in the yielded
1653+
data from get_unique_together_constraints() to ensure custom violation
1654+
messages and error codes are correctly propagated to the validators.
16331655
"""
16341656
# The field names we're passing though here only include fields
16351657
# which may map onto a model field. Any dotted field name lookups
@@ -1653,17 +1675,11 @@ def get_unique_together_validators(self):
16531675
for name, source in field_sources.items():
16541676
source_map[source].append(name)
16551677

1656-
unique_constraint_by_fields = {
1657-
constraint.fields: constraint
1658-
for model_cls in (*self.Meta.model._meta.parents, self.Meta.model)
1659-
for constraint in model_cls._meta.constraints
1660-
if isinstance(constraint, models.UniqueConstraint)
1661-
}
1662-
16631678
# Note that we make sure to check `unique_together` both on the
16641679
# base model class, but also on any parent classes.
16651680
validators = []
1666-
for unique_together, queryset, condition_fields, condition, nulls_distinct in self.get_unique_together_constraints(self.Meta.model):
1681+
for unique_together, queryset, condition_fields, condition, nulls_distinct, constraint in self.get_unique_together_constraints(
1682+
self.Meta.model):
16671683
# Skip if serializer does not map to all unique together sources
16681684
unique_together_and_condition_fields = set(unique_together) | set(condition_fields)
16691685
if not set(source_map).issuperset(unique_together_and_condition_fields):
@@ -1687,16 +1703,17 @@ def get_unique_together_validators(self):
16871703

16881704
field_names = tuple(source_map[f][0] for f in unique_together)
16891705

1690-
constraint = unique_constraint_by_fields.get(tuple(unique_together))
1706+
# Extract custom violation message and code from the constraint if available
16911707
violation_error_message = self._get_constraint_violation_error_message(constraint) if constraint else None
1708+
violation_error_code = getattr(constraint, 'violation_error_code', None)
16921709

16931710
validator = UniqueTogetherValidator(
16941711
queryset=queryset,
16951712
fields=field_names,
16961713
condition_fields=tuple(source_map[f][0] for f in condition_fields),
16971714
condition=condition,
16981715
message=violation_error_message,
1699-
code=getattr(constraint, 'violation_error_code', None),
1716+
code=violation_error_code,
17001717
nulls_distinct=nulls_distinct,
17011718
)
17021719
validators.append(validator)

‎rest_framework/utils/field_mapping.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,18 @@ def get_unique_validators(field_name, model_field):
7979
unique_error_message = get_unique_error_message(model_field)
8080
queryset = model_field.model._default_manager
8181
for condition in conditions:
82-
yield UniqueValidator(
83-
queryset=queryset if condition is None else queryset.filter(condition),
84-
message=unique_error_message
82+
condition_fields = (
83+
set(condition.referenced_base_fields)
84+
if condition is not None
85+
else set()
8586
)
87+
# Only use UniqueValidator if the union of field and condition fields is 1
88+
# (i.e. no additional fields referenced in conditions)
89+
if len(field_set | condition_fields) == 1:
90+
yield UniqueValidator(
91+
queryset=queryset if condition is None else queryset.filter(condition),
92+
message=unique_error_message,
93+
)
8694

8795

8896
def get_field_kwargs(field_name, model_field):

‎rest_framework/validators.py‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,30 +203,52 @@ def __call__(self, attrs, serializer):
203203
queryset = self.filter_queryset(attrs, queryset, serializer)
204204
queryset = self.exclude_current_instance(attrs, queryset, serializer.instance)
205205

206-
checked_names = [
206+
# Values of constraint fields only, resolved from attrs or instance.
207+
# Used solely for the nulls_distinct guard, so condition fields
208+
# do not accidentally cause validation to be skipped.
209+
constraint_field_sources = [
207210
serializer.fields[field_name].source for field_name in self.fields
208211
]
212+
constraint_field_values = [
213+
attrs[source] if source in attrs
214+
else getattr(serializer.instance, source, None)
215+
for source in constraint_field_sources
216+
]
217+
218+
# Combine constraint fields and condition fields to detect changes
219+
# in either set of fields. This ensures that updates to condition
220+
# fields also trigger revalidation.
221+
checked_names = list(
222+
{serializer.fields[field_name].source for field_name in self.fields}
223+
| {serializer.fields[field_name].source for field_name in self.condition_fields}
224+
)
225+
209226
# Ignore validation if any field is None
210227
if serializer.instance is None:
211-
checked_values = [attrs[field_name] for field_name in checked_names]
228+
checked_values = [attrs.get(field_name) for field_name in checked_names]
212229
else:
213-
# Ignore validation if all field values are unchanged
230+
# Ignore validation if all field values are unchanged.
231+
# Omitted fields are treated as unchanged; their value comes
232+
# from the instance (as done for condition_kwargs below).
214233
checked_values = [
215234
attrs[field_name]
216235
for field_name in checked_names
217-
if attrs[field_name] != getattr(serializer.instance, field_name)
236+
if field_name in attrs and attrs[field_name] != getattr(serializer.instance, field_name, None)
218237
]
219238

220239
condition_sources = (serializer.fields[field_name].source for field_name in self.condition_fields)
221240
condition_kwargs = {
222-
source: attrs[source]
241+
source: attrs.get(source)
223242
if source in attrs
224-
else getattr(serializer.instance, source)
243+
else getattr(serializer.instance, source, None)
225244
for source in condition_sources
226245
}
246+
227247
if checked_values:
228-
# Skip validation for None values unless nulls_distinct is False
229-
if self.nulls_distinct is not False and None in checked_values:
248+
# Skip validation for None values in *constraint* fields unless
249+
# nulls_distinct is False. Condition fields are intentionally
250+
# excluded here.
251+
if self.nulls_distinct is not False and any(v is None for v in constraint_field_values):
230252
return
231253
if qs_exists_with_condition(queryset, self.condition, condition_kwargs):
232254
field_names = ', '.join(self.fields)

0 commit comments

Comments
 (0)