Fixed #24513 -- Made sure a model is only rendered once during reloads

This also prevents state modifications from corrupting previous states.
Previously, when a model defining a relation was unregistered first,
clearing the cache would cause its related models' _meta to be cleared
and would result in the old models losing track of their relations.
This commit is contained in:
Patryk Zawadzki 2015-04-03 23:36:35 +02:00 committed by Markus Holtermann
parent 7a7c797234
commit 0385dad073
3 changed files with 67 additions and 15 deletions

View File

@ -83,6 +83,9 @@ class ProjectState(object):
del self.models[app_label, model_name] del self.models[app_label, model_name]
if 'apps' in self.__dict__: # hasattr would cache the property if 'apps' in self.__dict__: # hasattr would cache the property
self.apps.unregister_model(app_label, model_name) self.apps.unregister_model(app_label, model_name)
# Need to do this explicitly since unregister_model() doesn't clear
# the cache automatically (#24513)
self.apps.clear_cache()
def reload_model(self, app_label, model_name): def reload_model(self, app_label, model_name):
if 'apps' in self.__dict__: # hasattr would cache the property if 'apps' in self.__dict__: # hasattr would cache the property
@ -104,29 +107,25 @@ class ProjectState(object):
rel_app_label, rel_model_name = _get_app_label_and_model_name(field.remote_field.model, app_label) rel_app_label, rel_model_name = _get_app_label_and_model_name(field.remote_field.model, app_label)
related_models.add((rel_app_label, rel_model_name.lower())) related_models.add((rel_app_label, rel_model_name.lower()))
# Include the model itself
related_models.add((app_label, model_name))
# Unregister all related models # Unregister all related models
for rel_app_label, rel_model_name in related_models: for rel_app_label, rel_model_name in related_models:
self.apps.unregister_model(rel_app_label, rel_model_name) self.apps.unregister_model(rel_app_label, rel_model_name)
# Need to do it once all models are unregistered to avoid corrupting
# existing models' _meta
self.apps.clear_cache()
# Unregister the current model states_to_be_rendered = []
self.apps.unregister_model(app_label, model_name)
# Gather all models states of those models that will be rerendered. # Gather all models states of those models that will be rerendered.
# This includes: # This includes:
# 1. The current model # 1. All related models of unmigrated apps
try:
model_state = self.models[app_label, model_name]
except KeyError:
states_to_be_rendered = []
else:
states_to_be_rendered = [model_state]
# 2. All related models of unmigrated apps
for model_state in self.apps.real_models: for model_state in self.apps.real_models:
if (model_state.app_label, model_state.name_lower) in related_models: if (model_state.app_label, model_state.name_lower) in related_models:
states_to_be_rendered.append(model_state) states_to_be_rendered.append(model_state)
# 3. All related models of migrated apps # 2. All related models of migrated apps
for rel_app_label, rel_model_name in related_models: for rel_app_label, rel_model_name in related_models:
try: try:
model_state = self.models[rel_app_label, rel_model_name] model_state = self.models[rel_app_label, rel_model_name]
@ -282,7 +281,6 @@ class StateApps(Apps):
del self.app_configs[app_label].models[model_name] del self.app_configs[app_label].models[model_name]
except KeyError: except KeyError:
pass pass
self.clear_cache()
class ModelState(object): class ModelState(object):

View File

@ -25,3 +25,6 @@ Bugfixes
* Stripped microseconds from ``datetime`` values when using an older version of * Stripped microseconds from ``datetime`` values when using an older version of
the MySQLdb DB API driver as it does not support fractional seconds the MySQLdb DB API driver as it does not support fractional seconds
(:ticket:`24584`). (:ticket:`24584`).
* Fixed a migration crash when altering
:class:`~django.db.models.ManyToManyField`\s (:ticket:`24513`).

View File

@ -1,6 +1,8 @@
from django.apps.registry import Apps from django.apps.registry import Apps
from django.db import models from django.db import models
from django.db.migrations.operations import DeleteModel, RemoveField from django.db.migrations.operations import (
AlterField, DeleteModel, RemoveField,
)
from django.db.migrations.state import ( from django.db.migrations.state import (
InvalidBasesError, ModelState, ProjectState, get_related_models_recursive, InvalidBasesError, ModelState, ProjectState, get_related_models_recursive,
) )
@ -413,6 +415,55 @@ class StateTests(TestCase):
self.assertEqual(len(model_a_old._meta.related_objects), 1) self.assertEqual(len(model_a_old._meta.related_objects), 1)
self.assertEqual(len(model_a_new._meta.related_objects), 0) self.assertEqual(len(model_a_new._meta.related_objects), 0)
def test_self_relation(self):
"""
#24513 - Modifying an object pointing to itself would cause it to be
rendered twice and thus breaking its related M2M through objects.
"""
class A(models.Model):
to_a = models.ManyToManyField('something.A', symmetrical=False)
class Meta:
app_label = "something"
def get_model_a(state):
return [mod for mod in state.apps.get_models() if mod._meta.model_name == 'a'][0]
project_state = ProjectState()
project_state.add_model((ModelState.from_model(A)))
self.assertEqual(len(get_model_a(project_state)._meta.related_objects), 1)
old_state = project_state.clone()
operation = AlterField(
model_name="a",
name="to_a",
field=models.ManyToManyField("something.A", symmetrical=False, blank=True)
)
# At this point the model would be rendered twice causing its related
# M2M through objects to point to an old copy and thus breaking their
# attribute lookup.
operation.state_forwards("something", project_state)
model_a_old = get_model_a(old_state)
model_a_new = get_model_a(project_state)
self.assertIsNot(model_a_old, model_a_new)
# Tests that the old model's _meta is still consistent
field_to_a_old = model_a_old._meta.get_field("to_a")
self.assertEqual(field_to_a_old.m2m_field_name(), "from_a")
self.assertEqual(field_to_a_old.m2m_reverse_field_name(), "to_a")
self.assertIs(field_to_a_old.related_model, model_a_old)
self.assertIs(field_to_a_old.remote_field.through._meta.get_field('to_a').related_model, model_a_old)
self.assertIs(field_to_a_old.remote_field.through._meta.get_field('from_a').related_model, model_a_old)
# Tests that the new model's _meta is still consistent
field_to_a_new = model_a_new._meta.get_field("to_a")
self.assertEqual(field_to_a_new.m2m_field_name(), "from_a")
self.assertEqual(field_to_a_new.m2m_reverse_field_name(), "to_a")
self.assertIs(field_to_a_new.related_model, model_a_new)
self.assertIs(field_to_a_new.remote_field.through._meta.get_field('to_a').related_model, model_a_new)
self.assertIs(field_to_a_new.remote_field.through._meta.get_field('from_a').related_model, model_a_new)
def test_equality(self): def test_equality(self):
""" """
Tests that == and != are implemented correctly. Tests that == and != are implemented correctly.