Fixed #22217 - ManyToManyField.through_fields fixes.

- Docs description of arguments mix up.
- Keep it from erroneously masking E332 check.
- Add checks E338 and E339, tweak message of E337.
This commit is contained in:
Akis Kesoglou 2014-03-07 13:56:28 +02:00 committed by Ramiro Morales
parent f4d91638fc
commit aaad3e27ac
5 changed files with 154 additions and 49 deletions

View File

@ -1904,21 +1904,7 @@ class ManyToManyField(RelatedField):
) )
) )
elif self.rel.through_fields is not None: else:
if not len(self.rel.through_fields) >= 2 or not (self.rel.through_fields[0] and self.rel.through_fields[1]):
errors.append(
checks.Error(
("The field is given an iterable for through_fields, "
"which does not provide the names for both link fields "
"that Django should use for the relation through model "
"'%s'.") % qualified_model_name,
hint=None,
obj=self,
id='fields.E337',
)
)
elif not isinstance(self.rel.through, six.string_types):
assert from_model is not None, \ assert from_model is not None, \
"ManyToManyField with intermediate " \ "ManyToManyField with intermediate " \
@ -2018,6 +2004,78 @@ class ManyToManyField(RelatedField):
id='fields.E336', id='fields.E336',
) )
) )
# Validate `through_fields`
if self.rel.through_fields is not None:
# Validate that we're given an iterable of at least two items
# and that none of them is "falsy"
if not (len(self.rel.through_fields) >= 2 and
self.rel.through_fields[0] and self.rel.through_fields[1]):
errors.append(
checks.Error(
("Field specifies 'through_fields' but does not "
"provide the names of the two link fields that should be "
"used for the relation through model "
"'%s'.") % qualified_model_name,
hint=("Make sure you specify 'through_fields' as "
"through_fields=('field1', 'field2')"),
obj=self,
id='fields.E337',
)
)
# Validate the given through fields -- they should be actual
# fields on the through model, and also be foreign keys to the
# expected models
else:
assert from_model is not None, \
"ManyToManyField with intermediate " \
"tables cannot be checked if you don't pass the model " \
"where the field is attached to."
source, through, target = from_model, self.rel.through, self.rel.to
source_field_name, target_field_name = self.rel.through_fields[:2]
for field_name, related_model in ((source_field_name, source),
(target_field_name, target)):
possible_field_names = []
for f in through._meta.fields:
if hasattr(f, 'rel') and getattr(f.rel, 'to', None) == related_model:
possible_field_names.append(f.name)
if possible_field_names:
hint = ("Did you mean one of the following foreign "
"keys to '%s': %s?") % (related_model._meta.object_name,
', '.join(possible_field_names))
else:
hint = None
try:
field = through._meta.get_field(field_name)
except FieldDoesNotExist:
errors.append(
checks.Error(
("The intermediary model '%s' has no field '%s'.") % (
qualified_model_name, field_name),
hint=hint,
obj=self,
id='fields.E338',
)
)
else:
if not (hasattr(field, 'rel') and
getattr(field.rel, 'to', None) == related_model):
errors.append(
checks.Error(
"'%s.%s' is not a foreign key to '%s'." % (
through._meta.object_name, field_name,
related_model._meta.object_name),
hint=hint,
obj=self,
id='fields.E339',
)
)
return errors return errors
def deconstruct(self): def deconstruct(self):
@ -2104,9 +2162,6 @@ class ManyToManyField(RelatedField):
(link_field_name is None or link_field_name == f.name): (link_field_name is None or link_field_name == f.name):
setattr(self, cache_attr, getattr(f, attr)) setattr(self, cache_attr, getattr(f, attr))
return getattr(self, cache_attr) return getattr(self, cache_attr)
# We only reach here if we're given an invalid field name via the
# `through_fields` argument
raise FieldDoesNotExist(link_field_name)
def _get_m2m_reverse_attr(self, related, attr): def _get_m2m_reverse_attr(self, related, attr):
"Function that can be curried to provide the related accessor or DB column name for the m2m table" "Function that can be curried to provide the related accessor or DB column name for the m2m table"
@ -2133,12 +2188,7 @@ class ManyToManyField(RelatedField):
elif link_field_name is None or link_field_name == f.name: elif link_field_name is None or link_field_name == f.name:
setattr(self, cache_attr, getattr(f, attr)) setattr(self, cache_attr, getattr(f, attr))
break break
try: return getattr(self, cache_attr)
return getattr(self, cache_attr)
except AttributeError:
# We only reach here if we're given an invalid reverse field
# name via the `through_fields` argument
raise FieldDoesNotExist(link_field_name)
def value_to_string(self, obj): def value_to_string(self, obj):
data = '' data = ''

View File

@ -94,7 +94,9 @@ Related Fields
* **fields.E334**: The model is used as an intermediate model by ``<model>``, but it has more than one foreign key from ``<model>``, which is ambiguous. You must specify which foreign key Django should use via the through_fields keyword argument. * **fields.E334**: The model is used as an intermediate model by ``<model>``, but it has more than one foreign key from ``<model>``, which is ambiguous. You must specify which foreign key Django should use via the through_fields keyword argument.
* **fields.E335**: The model is used as an intermediate model by ``<model>``, but it has more than one foreign key to ``<model>``, which is ambiguous. You must specify which foreign key Django should use via the through_fields keyword argument. * **fields.E335**: The model is used as an intermediate model by ``<model>``, but it has more than one foreign key to ``<model>``, which is ambiguous. You must specify which foreign key Django should use via the through_fields keyword argument.
* **fields.E336**: The model is used as an intermediary model by ``<model>``, but it does not have foreign key to ``<model>`` or ``<model>``. * **fields.E336**: The model is used as an intermediary model by ``<model>``, but it does not have foreign key to ``<model>`` or ``<model>``.
* **fields.E337**: The field is given an iterable for through_fields, which does not provide the names for both link fields that Django should use for the relation through <model>. * **fields.E337**: Field specifies ``through_fields`` but does not provide the names of the two link fields that should be used for the relation through ``<model>``.
* **fields.E338**: The intermediary model ``<through model>`` has no field ``<field name>``.
* **fields.E339**: ``<model>.<field name>`` is not a foreign key to ``<model>``.
Signals Signals
~~~~~~~ ~~~~~~~

View File

@ -1353,11 +1353,11 @@ that control how the relationship functions.
class Group(models.Model): class Group(models.Model):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
members = models.ManyToManyField(Person, through='Membership', through_fields=('person', 'group')) members = models.ManyToManyField(Person, through='Membership', through_fields=('group', 'person'))
class Membership(models.Model): class Membership(models.Model):
person = models.ForeignKey(Person)
group = models.ForeignKey(Group) group = models.ForeignKey(Group)
person = models.ForeignKey(Person)
inviter = models.ForeignKey(Person, related_name="membership_invites") inviter = models.ForeignKey(Person, related_name="membership_invites")
invite_reason = models.CharField(max_length=64) invite_reason = models.CharField(max_length=64)
@ -1368,9 +1368,10 @@ that control how the relationship functions.
above. above.
``through_fields`` accepts a 2-tuple ``('field1', 'field2')``, where ``through_fields`` accepts a 2-tuple ``('field1', 'field2')``, where
``field1`` is the name of the foreign key to the target model (``person`` ``field1`` is the name of the foreign key to the model the
in this case), and ``field2`` the name of the foreign key to the model the :class:`ManyToManyField` is defined on (``group`` in this case), and
:class:`ManyToManyField` is defined on (``group`` in this case). ``field2`` the name of the foreign key to the target model (``person``
in this case).
When you have more than one foreign key on an intermediary model to any When you have more than one foreign key on an intermediary model to any
(or even both) of the models participating in a many-to-many relationship, (or even both) of the models participating in a many-to-many relationship,

View File

@ -440,12 +440,12 @@ explicit declaration defines how the two models are related.
There are a few restrictions on the intermediate model: There are a few restrictions on the intermediate model:
* Your intermediate model must contain one - and *only* one - foreign key * Your intermediate model must contain one - and *only* one - foreign key
to the target model (this would be ``Person`` in our example), or you must to the source model (this would be ``Group`` in our example), or you must
explicitly specify the foreign keys Django should use for the relationship explicitly specify the foreign keys Django should use for the relationship
using :attr:`ManyToManyField.through_fields <ManyToManyField.through_fields>`. using :attr:`ManyToManyField.through_fields <ManyToManyField.through_fields>`.
If you have more than one foreign key and ``through_fields`` is not If you have more than one foreign key and ``through_fields`` is not
specified, a validation error will be raised. A similar restriction applies specified, a validation error will be raised. A similar restriction applies
to the foreign key to the source model (this would be ``Group`` in our to the foreign key to the target model (this would be ``Person`` in our
example). example).
* For a model which has a many-to-many relationship to itself through an * For a model which has a many-to-many relationship to itself through an

View File

@ -240,6 +240,32 @@ class RelativeFieldTests(IsolatedModelsTestCase):
] ]
self.assertEqual(errors, expected) self.assertEqual(errors, expected)
def test_symmetric_self_reference_with_intermediate_table_and_through_fields(self):
"""Using through_fields in a m2m with an intermediate model shouldn't mask its incompatibility with symmetry."""
class Person(models.Model):
# Explicit symmetrical=True.
friends = models.ManyToManyField('self',
symmetrical=True,
through="Relationship",
through_fields=('first', 'second'))
class Relationship(models.Model):
first = models.ForeignKey(Person, related_name="rel_from_set")
second = models.ForeignKey(Person, related_name="rel_to_set")
referee = models.ForeignKey(Person, related_name="referred")
field = Person._meta.get_field('friends')
errors = field.check(from_model=Person)
expected = [
Error(
'Many-to-many fields with intermediate tables must not be symmetrical.',
hint=None,
obj=field,
id='fields.E332',
),
]
self.assertEqual(errors, expected)
def test_foreign_key_to_abstract_model(self): def test_foreign_key_to_abstract_model(self):
class Model(models.Model): class Model(models.Model):
foreign_key = models.ForeignKey('AbstractModel') foreign_key = models.ForeignKey('AbstractModel')
@ -1071,43 +1097,69 @@ class M2mThroughFieldsTests(IsolatedModelsTestCase):
ValueError, 'Cannot specify through_fields without a through model', ValueError, 'Cannot specify through_fields without a through model',
models.ManyToManyField, Fan, through_fields=('f1', 'f2')) models.ManyToManyField, Fan, through_fields=('f1', 'f2'))
def test_invalid_m2m_field(self): def test_invalid_order(self):
""" """
Tests that providing invalid source field name to ManyToManyField.through_fields Tests that mixing up the order of link fields to ManyToManyField.through_fields
raises FieldDoesNotExist. triggers validation errors.
""" """
class Fan(models.Model): class Fan(models.Model):
pass pass
class Event(models.Model): class Event(models.Model):
invitees = models.ManyToManyField(Fan, through='Invitation', through_fields=('invalid_field', 'invitee')) invitees = models.ManyToManyField(Fan, through='Invitation', through_fields=('invitee', 'event'))
class Invitation(models.Model): class Invitation(models.Model):
event = models.ForeignKey(Event) event = models.ForeignKey(Event)
invitee = models.ForeignKey(Fan) invitee = models.ForeignKey(Fan)
inviter = models.ForeignKey(Fan, related_name='+') inviter = models.ForeignKey(Fan, related_name='+')
with self.assertRaisesMessage(FieldDoesNotExist, 'invalid_field'): field = Event._meta.get_field('invitees')
Event().invitees.all() errors = field.check(from_model=Event)
expected = [
Error(
("'Invitation.invitee' is not a foreign key to 'Event'."),
hint="Did you mean one of the following foreign keys to 'Event': event?",
obj=field,
id='fields.E339'),
Error(
("'Invitation.event' is not a foreign key to 'Fan'."),
hint="Did you mean one of the following foreign keys to 'Fan': invitee, inviter?",
obj=field,
id='fields.E339'),
]
self.assertEqual(expected, errors)
def test_invalid_m2m_reverse_field(self): def test_invalid_field(self):
""" """
Tests that providing invalid reverse field name to ManyToManyField.through_fields Tests that providing invalid field names to ManyToManyField.through_fields
raises FieldDoesNotExist. triggers validation errors.
""" """
class Fan(models.Model): class Fan(models.Model):
pass pass
class Event(models.Model): class Event(models.Model):
invitees = models.ManyToManyField(Fan, through='Invitation', through_fields=('event', 'invalid_field')) invitees = models.ManyToManyField(Fan, through='Invitation', through_fields=('invalid_field_1', 'invalid_field_2'))
class Invitation(models.Model): class Invitation(models.Model):
event = models.ForeignKey(Event) event = models.ForeignKey(Event)
invitee = models.ForeignKey(Fan) invitee = models.ForeignKey(Fan)
inviter = models.ForeignKey(Fan, related_name='+') inviter = models.ForeignKey(Fan, related_name='+')
with self.assertRaisesMessage(FieldDoesNotExist, 'invalid_field'): field = Event._meta.get_field('invitees')
Event().invitees.all() errors = field.check(from_model=Event)
expected = [
Error(
("The intermediary model 'invalid_models_tests.Invitation' has no field 'invalid_field_1'."),
hint="Did you mean one of the following foreign keys to 'Event': event?",
obj=field,
id='fields.E338'),
Error(
("The intermediary model 'invalid_models_tests.Invitation' has no field 'invalid_field_2'."),
hint="Did you mean one of the following foreign keys to 'Fan': invitee, inviter?",
obj=field,
id='fields.E338'),
]
self.assertEqual(expected, errors)
def test_explicit_field_names(self): def test_explicit_field_names(self):
""" """
@ -1129,11 +1181,11 @@ class M2mThroughFieldsTests(IsolatedModelsTestCase):
errors = field.check(from_model=Event) errors = field.check(from_model=Event)
expected = [ expected = [
Error( Error(
("The field is given an iterable for through_fields, " ("Field specifies 'through_fields' but does not provide the names "
"which does not provide the names for both link fields " "of the two link fields that should be used for the relation "
"that Django should use for the relation through model " "through model 'invalid_models_tests.Invitation'."),
"'invalid_models_tests.Invitation'."), hint=("Make sure you specify 'through_fields' as "
hint=None, "through_fields=('field1', 'field2')"),
obj=field, obj=field,
id='fields.E337')] id='fields.E337')]
self.assertEqual(expected, errors) self.assertEqual(expected, errors)