Fix UniqueConstraint validation with conditional fields (#9707) - #10021
Fix UniqueConstraint validation with conditional fields (#9707)#10021majidkhazaei wants to merge 16 commits into
Conversation
…ogether validator
- 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>
There was a problem hiding this comment.
Pull request overview
This PR fixes DRF’s ModelSerializer uniqueness validation for Django UniqueConstraint objects whose condition references additional model fields, ensuring DRF uses serializer-level UniqueTogetherValidator (with condition-awareness) instead of an incorrect field-level UniqueValidator.
Changes:
- Add
get_referenced_base_fields_from_q()compatibility helper and use it to detect fields referenced byUniqueConstraint.condition. - Update uniqueness validator selection so single-field constraints with distinct condition fields are validated via
UniqueTogetherValidator. - Extend validator test coverage and document the
UniqueConstraint-with-conditions behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
rest_framework/compat.py |
Adds helper to extract referenced base fields from Q conditions. |
rest_framework/utils/field_mapping.py |
Skips field-level UniqueValidator when condition references additional fields. |
rest_framework/serializers.py |
Treats certain single-field conditional UniqueConstraints as “unique-together” for serializer-level validation. |
tests/test_validators.py |
Adds/adjusts tests for conditional-field uniqueness behavior and expected validator placement. |
docs/api-guide/validators.md |
Documents how DRF handles UniqueConstraint conditions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| ] | ||
|
|
||
|
|
||
| ## Updating nested serializers |
…eferenced_base_fields
|
I find the whole conditional uniqueness stuff confusing and don't have the conceptual clarity in my head atm to give this a solid review (I'm sorry). |
|
Hi @browniebroke, just a quick follow-up on this PR. The checks have been green for a while, and the branch is up to date. Whenever you have a chance, I’d really appreciate a review. If there’s anything I can adjust or clarify, please let me know. Thanks! |
ec2a480 to
1be7064
Compare
…t 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 All 68 tests passing.
1be7064 to
51b7897
Compare
|
Hi @auvipy and @browniebroke, All three Copilot issues are now fixed:
A new test was added for the condition field scenario. All 68 tests are passing. PR is ready for final review and merge. Thanks! 🙏 |
|
tried to fix a newly emerged merge conflict |
73a6bb1 to
cbfead3
Compare
|
All conflicts resolved, 76 tests passing, pre-commit checks green. |
There was a problem hiding this comment.
🟡 Changes recommended
Validation can be skipped for omitted or nullable condition fields, and the new transition test does not exercise the regression.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Balanced
| checked_values = [attrs.get(field_name) for field_name in checked_names] | ||
| else: | ||
| # Ignore validation if all field values are unchanged | ||
| checked_values = [ | ||
| attrs[field_name] | ||
| attrs.get(field_name) | ||
| for field_name in checked_names | ||
| if attrs[field_name] != getattr(serializer.instance, field_name) | ||
| if attrs.get(field_name) != getattr(serializer.instance, field_name, None) |
There was a problem hiding this comment.
@majidkhazaei please cross check this and other open suggestions
There was a problem hiding this comment.
Thanks for flagging this. I've now fixed it. The issue was that checked_values (which includes condition fields) was being used in the nulls_distinct guard, causing validation to be skipped incorrectly for partial updates or nullable condition fields.
I've separated the logic: checked_names (union of constraint and condition fields) is still used only to detect changes, while the nulls_distinct guard now strictly checks only the values of the constraint fields (self.fields).
The fix is pushed. All 76 tests pass and pre-commit is green. Ready for another look.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
@MehrazRumman can you please try and review it? and cross check if this pr undo the changes you recently contributed to? |
MehrazRumman
left a comment
There was a problem hiding this comment.
@auvipy I checked this against #10033 and did a review pass locally (Django 5.2.17, full test suite).
Does it undo #10033? No. The PR branch (d7ba215) contains the #10033 merge commit (4f77428) in its history, and git diff 4f77428 d7ba215 on validators.py only touches the UniqueTogetherValidator.__call__ body below the _check_single_instance() call. The helper itself, its three call sites (UniqueValidator, UniqueTogetherValidator, BaseUniqueForValidator), the docs section in serializers.md, and all 9 test_many_* tests from #10033 are intact and still pass. Full suite: 1452 passed, 89 skipped.
The fix itself works and I could reproduce the bug on main. With UniqueConstraint(fields=['name'], condition=Q(status='active')):
| Scenario | main |
this PR |
|---|---|---|
create name='x', status='inactive' while an active 'x' exists (DB allows this) |
❌ rejected | ✅ accepted |
update status inactive→active while an active 'x' exists (DB would raise IntegrityError) |
✅ accepted | ❌ rejected |
custom violation_error_message / violation_error_code |
ignored | propagated |
fields=['a','b'], a=NULL unchanged, b changed to collide (DB allows, NULLs distinct) |
❌ rejected | ✅ accepted |
The last row is a nice side effect of the nulls_distinct guard now looking at all constraint-field values rather than only changed ones.
A few things I'd like to see addressed before merge:
-
get_referenced_base_fields_from_q()incompat.pyis dead code.pyproject.tomlrequiresdjango>=5.2, andQ.referenced_base_fieldshas existed since Django 5.0.mainalready callsconstraint.condition.referenced_base_fieldsdirectly. The fallback branch is also less accurate than Django's (it missesF()on the right-hand side: forQ(a__gt=F('b'))Django returns{'a','b'}, the fallback returns{'a'}). Suggest dropping the helper and usingcondition.referenced_base_fieldsin bothserializers.pyandfield_mapping.py. -
Partial updates that omit a condition field now always hit the DB. In
__call__,attrs.get(field_name)returnsNonefor an omitted condition field, which compares unequal to the instance value, sochecked_valuesis non-empty and the "all values unchanged" short-circuit never fires.ProbeSerializer(inst, data={'name': 'x'}, partial=True)with nothing changed goes from 1 query onmainto 3 here. Omitted fields should count as unchanged (their value comes from the instance, which is whatcondition_kwargsalready does):checked_values = [ attrs[field_name] for field_name in checked_names if field_name in attrs and attrs[field_name] != getattr(serializer.instance, field_name, None) ]
With that change the same scenario runs 0 queries, the inactive→active partial update is still rejected, and all 76 tests in
test_validators.pystill pass. -
This is a user-visible behaviour change and should be in the release notes. Single-field conditional constraints that reference another field now produce
non_field_errorsfromUniqueTogetherValidatorinstead of a field-level error fromUniqueValidator(see the updated assertions intest_single_field_uniq_validators). Also,get_unique_together_constraints()now yields 6-tuples instead of 5-tuples, which will break any subclass that iterates it. Both are justified, but worth a line in the notes. -
Minor: the error message reads
The fields race_name must make a unique set.for a single field. Not blocking, but the message template could pick singular/plural based onlen(self.fields). -
Minor style: the
checked_names = list({ ... } | { ... })block has unusual hanging indentation (flake8 passes, but it doesn't match the surrounding code).
Happy to re-check once (1) and (2) are in.
…ression, cleanup style
|
Hi @MehrazRumman and @auvipy, Thanks for the thorough review! I've addressed both main points:
I also cleaned up the Regarding the minor points (singular message and release notes): I left them out to keep this PR focused on the bug fix, but happy to address them in a follow-up if the maintainers prefer. All 76 tests pass and pre-commit is green. Ready for another look whenever you have a moment. Thanks! |
This is a rebased and conflict-resolved version of PR #9744.
Changes:
get_referenced_base_fields_from_qhelper torest_framework/compat.pytests/test_validators.pyare passingResolves #9707
Supersedes #9744 (with resolved conflicts)
When using Django's
UniqueConstraintwith conditions that reference other fields,DRF now correctly applies
UniqueTogetherValidatorinstead ofUniqueValidator.