diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index f5fdaa55ee..8ddbb5ba68 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -528,6 +528,10 @@ class ForeignObject(RelatedField): frozenset(ut) for ut in self.remote_field.model._meta.unique_together }) + unique_foreign_fields.update({ + frozenset(uc.fields) + for uc in self.remote_field.model._meta.total_unique_constraints + }) foreign_fields = {f.name for f in self.foreign_related_fields} has_unique_constraint = any(u <= foreign_fields for u in unique_foreign_fields) @@ -541,8 +545,10 @@ class ForeignObject(RelatedField): "No subset of the fields %s on model '%s' is unique." % (field_combination, model_name), hint=( - "Add unique=True on any of those fields or add at " - "least a subset of them to a unique_together constraint." + 'Mark a single field as unique=True or add a set of ' + 'fields to a unique constraint (via unique_together ' + 'or a UniqueConstraint (without condition) in the ' + 'model Meta.constraints).' ), obj=self, id='fields.E310', @@ -553,8 +559,13 @@ class ForeignObject(RelatedField): model_name = self.remote_field.model.__name__ return [ checks.Error( - "'%s.%s' must set unique=True because it is referenced by " + "'%s.%s' must be unique because it is referenced by " "a foreign key." % (model_name, field_name), + hint=( + 'Add unique=True to this field or add a ' + 'UniqueConstraint (without condition) in the model ' + 'Meta.constraints.' + ), obj=self, id='fields.E311', ) diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index ce5cf5b45a..57c7b56fa9 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -220,7 +220,7 @@ Related fields ``'__'``. * **fields.E310**: No subset of the fields ````, ````, ... on model ```` is unique. -* **fields.E311**: ```` must set ``unique=True`` because it is +* **fields.E311**: ``.`` must be unique because it is referenced by a ``ForeignKey``. * **fields.E312**: The ``to_field`` ```` doesn't exist on the related model ``.``. diff --git a/tests/invalid_models_tests/test_relative_fields.py b/tests/invalid_models_tests/test_relative_fields.py index 4211973cf3..24f27168c2 100644 --- a/tests/invalid_models_tests/test_relative_fields.py +++ b/tests/invalid_models_tests/test_relative_fields.py @@ -352,7 +352,11 @@ class RelativeFieldTests(SimpleTestCase): field = Model._meta.get_field('foreign_key') self.assertEqual(field.check(), [ Error( - "'Target.bad' must set unique=True because it is referenced by a foreign key.", + "'Target.bad' must be unique because it is referenced by a foreign key.", + hint=( + 'Add unique=True to this field or add a UniqueConstraint ' + '(without condition) in the model Meta.constraints.' + ), obj=field, id='fields.E311', ), @@ -368,12 +372,64 @@ class RelativeFieldTests(SimpleTestCase): field = Model._meta.get_field('field') self.assertEqual(field.check(), [ Error( - "'Target.bad' must set unique=True because it is referenced by a foreign key.", + "'Target.bad' must be unique because it is referenced by a foreign key.", + hint=( + 'Add unique=True to this field or add a UniqueConstraint ' + '(without condition) in the model Meta.constraints.' + ), obj=field, id='fields.E311', ), ]) + def test_foreign_key_to_partially_unique_field(self): + class Target(models.Model): + source = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['source'], + name='tfktpuf_partial_unique', + condition=models.Q(pk__gt=2), + ), + ] + + class Model(models.Model): + field = models.ForeignKey(Target, models.CASCADE, to_field='source') + + field = Model._meta.get_field('field') + self.assertEqual(field.check(), [ + Error( + "'Target.source' must be unique because it is referenced by a " + "foreign key.", + hint=( + 'Add unique=True to this field or add a UniqueConstraint ' + '(without condition) in the model Meta.constraints.' + ), + obj=field, + id='fields.E311', + ), + ]) + + def test_foreign_key_to_unique_field_with_meta_constraint(self): + class Target(models.Model): + source = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['source'], + name='tfktufwmc_unique', + ), + ] + + class Model(models.Model): + field = models.ForeignKey(Target, models.CASCADE, to_field='source') + + field = Model._meta.get_field('field') + self.assertEqual(field.check(), []) + def test_foreign_object_to_non_unique_fields(self): class Person(models.Model): # Note that both fields are not unique. @@ -396,14 +452,82 @@ class RelativeFieldTests(SimpleTestCase): Error( "No subset of the fields 'country_id', 'city_id' on model 'Person' is unique.", hint=( - "Add unique=True on any of those fields or add at least " - "a subset of them to a unique_together constraint." + 'Mark a single field as unique=True or add a set of ' + 'fields to a unique constraint (via unique_together or a ' + 'UniqueConstraint (without condition) in the model ' + 'Meta.constraints).' ), obj=field, id='fields.E310', ) ]) + def test_foreign_object_to_partially_unique_field(self): + class Person(models.Model): + country_id = models.IntegerField() + city_id = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['country_id', 'city_id'], + name='tfotpuf_partial_unique', + condition=models.Q(pk__gt=2), + ), + ] + + class MMembership(models.Model): + person_country_id = models.IntegerField() + person_city_id = models.IntegerField() + person = models.ForeignObject( + Person, + on_delete=models.CASCADE, + from_fields=['person_country_id', 'person_city_id'], + to_fields=['country_id', 'city_id'], + ) + + field = MMembership._meta.get_field('person') + self.assertEqual(field.check(), [ + Error( + "No subset of the fields 'country_id', 'city_id' on model " + "'Person' is unique.", + hint=( + 'Mark a single field as unique=True or add a set of ' + 'fields to a unique constraint (via unique_together or a ' + 'UniqueConstraint (without condition) in the model ' + 'Meta.constraints).' + ), + obj=field, + id='fields.E310', + ), + ]) + + def test_foreign_object_to_unique_field_with_meta_constraint(self): + class Person(models.Model): + country_id = models.IntegerField() + city_id = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['country_id', 'city_id'], + name='tfotpuf_unique', + ), + ] + + class MMembership(models.Model): + person_country_id = models.IntegerField() + person_city_id = models.IntegerField() + person = models.ForeignObject( + Person, + on_delete=models.CASCADE, + from_fields=['person_country_id', 'person_city_id'], + to_fields=['country_id', 'city_id'], + ) + + field = MMembership._meta.get_field('person') + self.assertEqual(field.check(), []) + def test_on_delete_set_null_on_non_nullable_field(self): class Person(models.Model): pass @@ -1453,8 +1577,10 @@ class M2mThroughFieldsTests(SimpleTestCase): Error( "No subset of the fields 'a', 'b' on model 'Parent' is unique.", hint=( - "Add unique=True on any of those fields or add at least " - "a subset of them to a unique_together constraint." + 'Mark a single field as unique=True or add a set of ' + 'fields to a unique constraint (via unique_together or a ' + 'UniqueConstraint (without condition) in the model ' + 'Meta.constraints).' ), obj=field, id='fields.E310', @@ -1489,8 +1615,10 @@ class M2mThroughFieldsTests(SimpleTestCase): Error( "No subset of the fields 'a', 'b', 'd' on model 'Parent' is unique.", hint=( - "Add unique=True on any of those fields or add at least " - "a subset of them to a unique_together constraint." + 'Mark a single field as unique=True or add a set of ' + 'fields to a unique constraint (via unique_together or a ' + 'UniqueConstraint (without condition) in the model ' + 'Meta.constraints).' ), obj=field, id='fields.E310',