Refs #26421 -- Refactored Apps.lazy_model_operation() for better checks and tests

This commit is contained in:
Alex Hill 2016-05-18 23:18:40 +08:00 committed by Tim Graham
parent 0eac5535f7
commit 2ff7ef15b0
9 changed files with 239 additions and 96 deletions

View File

@ -372,29 +372,35 @@ class Apps(object):
The function passed to this method must accept exactly n models as The function passed to this method must accept exactly n models as
arguments, where n=len(model_keys). arguments, where n=len(model_keys).
""" """
# If this function depends on more than one model, we recursively turn # Base case: no arguments, just execute the function.
# it into a chain of functions that accept a single model argument and if not model_keys:
# pass each in turn to lazy_model_operation. function()
model_key, more_models = model_keys[0], model_keys[1:] # Recursive case: take the head of model_keys, wait for the
if more_models: # corresponding model class to be imported and registered, then apply
supplied_fn = function # that argument to the supplied function. Pass the resulting partial
# to lazy_model_operation() along with the remaining model args and
def function(model): # repeat until all models are loaded and all arguments are applied.
next_function = partial(supplied_fn, model)
# Annotate the function with its field for retrieval in
# migrations.state.StateApps.
if getattr(supplied_fn, 'keywords', None):
next_function.field = supplied_fn.keywords.get('field')
self.lazy_model_operation(next_function, *more_models)
# If the model is already loaded, pass it to the function immediately.
# Otherwise, delay execution until the class is prepared.
try:
model_class = self.get_registered_model(*model_key)
except LookupError:
self._pending_operations[model_key].append(function)
else: else:
function(model_class) next_model, more_models = model_keys[0], model_keys[1:]
# This will be executed after the class corresponding to next_model
# has been imported and registered. The `func` attribute provides
# duck-type compatibility with partials.
def apply_next_model(model):
next_function = partial(apply_next_model.func, model)
self.lazy_model_operation(next_function, *more_models)
apply_next_model.func = function
# If the model has already been imported and registered, partially
# apply it to the function now. If not, add it to the list of
# pending operations for the model, where it will be executed with
# the model class as its sole argument once the model is ready.
try:
model_class = self.get_registered_model(*next_model)
except LookupError:
self._pending_operations[next_model].append(apply_next_model)
else:
apply_next_model(model_class)
def do_pending_operations(self, model): def do_pending_operations(self, model):
""" """

View File

@ -318,14 +318,23 @@ class GenericRelation(ForeignObject):
errors.extend(self._check_generic_foreign_key_existence()) errors.extend(self._check_generic_foreign_key_existence())
return errors return errors
def _is_matching_generic_foreign_key(self, field):
"""
Return True if field is a GenericForeignKey whose content type and
object id fields correspond to the equivalent attributes on this
GenericRelation.
"""
return (
isinstance(field, GenericForeignKey) and
field.ct_field == self.content_type_field_name and
field.fk_field == self.object_id_field_name
)
def _check_generic_foreign_key_existence(self): def _check_generic_foreign_key_existence(self):
target = self.remote_field.model target = self.remote_field.model
if isinstance(target, ModelBase): if isinstance(target, ModelBase):
fields = target._meta.private_fields fields = target._meta.private_fields
if any(isinstance(field, GenericForeignKey) and if any(self._is_matching_generic_foreign_key(field) for field in fields):
field.ct_field == self.content_type_field_name and
field.fk_field == self.object_id_field_name
for field in fields):
return [] return []
else: else:
return [ return [
@ -401,20 +410,16 @@ class GenericRelation(ForeignObject):
self.model = cls self.model = cls
setattr(cls, self.name, ReverseGenericManyToOneDescriptor(self.remote_field)) setattr(cls, self.name, ReverseGenericManyToOneDescriptor(self.remote_field))
# Add get_RELATED_order() and set_RELATED_order() methods if the model # Add get_RELATED_order() and set_RELATED_order() to the model this
# on the other end of this relation is ordered with respect to this. # field belongs to, if the model on the other end of this relation
def matching_gfk(field): # is ordered with respect to its corresponding GenericForeignKey.
return ( if not cls._meta.abstract:
isinstance(field, GenericForeignKey) and
self.content_type_field_name == field.ct_field and
self.object_id_field_name == field.fk_field
)
def make_generic_foreign_order_accessors(related_model, model): def make_generic_foreign_order_accessors(related_model, model):
if matching_gfk(model._meta.order_with_respect_to): if self._is_matching_generic_foreign_key(model._meta.order_with_respect_to):
make_foreign_order_accessors(model, related_model) make_foreign_order_accessors(model, related_model)
lazy_related_operation(make_generic_foreign_order_accessors, self.model, self.remote_field.model) lazy_related_operation(make_generic_foreign_order_accessors, self.model, self.remote_field.model)
def set_attributes_from_rel(self): def set_attributes_from_rel(self):
pass pass

View File

@ -63,3 +63,99 @@ def check_model_signals(app_configs=None, **kwargs):
) )
) )
return errors return errors
def _check_lazy_references(apps, ignore=None):
"""
Ensure all lazy (i.e. string) model references have been resolved.
Lazy references are used in various places throughout Django, primarily in
related fields and model signals. Identify those common cases and provide
more helpful error messages for them.
The ignore parameter is used by StateApps to exclude swappable models from
this check.
"""
pending_models = set(apps._pending_operations) - (ignore or set())
# Short circuit if there aren't any errors.
if not pending_models:
return []
def extract_operation(obj):
"""
Take a callable found in Apps._pending_operations and identify the
original callable passed to Apps.lazy_model_operation(). If that
callable was a partial, return the inner, non-partial function and
any arguments and keyword arguments that were supplied with it.
obj is a callback defined locally in Apps.lazy_model_operation() and
annotated there with a `func` attribute so as to imitate a partial.
"""
operation, args, keywords = obj, [], {}
while hasattr(operation, 'func'):
# The or clauses are redundant but work around a bug (#25945) in
# functools.partial in Python 3 <= 3.5.1 and Python 2 <= 2.7.11.
args.extend(getattr(operation, 'args', []) or [])
keywords.update(getattr(operation, 'keywords', {}) or {})
operation = operation.func
return operation, args, keywords
def app_model_error(model_key):
try:
apps.get_app_config(model_key[0])
model_error = "app '%s' doesn't provide model '%s'" % model_key
except LookupError:
model_error = "app '%s' isn't installed" % model_key[0]
return model_error
# Here are several functions which return CheckMessage instances for the
# most common usages of lazy operations throughout Django. These functions
# take the model that was being waited on as an (app_label, modelname)
# pair, the original lazy function, and its positional and keyword args as
# determined by extract_operation().
def field_error(model_key, func, args, keywords):
error_msg = (
"The field %(field)s was declared with a lazy reference "
"to '%(model)s', but %(model_error)s."
)
params = {
'model': '.'.join(model_key),
'field': keywords['field'],
'model_error': app_model_error(model_key),
}
return Error(error_msg % params, obj=keywords['field'], id='fields.E307')
def default_error(model_key, func, args, keywords):
error_msg = "%(op)s contains a lazy reference to %(model)s, but %(model_error)s."
params = {
'op': func,
'model': '.'.join(model_key),
'model_error': app_model_error(model_key),
}
return Error(error_msg % params, obj=func, id='models.E022')
# Maps common uses of lazy operations to corresponding error functions
# defined above. If a key maps to None, no error will be produced.
# default_error() will be used for usages that don't appear in this dict.
known_lazy = {
('django.db.models.fields.related', 'resolve_related_class'): field_error,
('django.db.models.fields.related', 'set_managed'): None,
}
def build_error(model_key, func, args, keywords):
key = (func.__module__, func.__name__)
error_fn = known_lazy.get(key, default_error)
return error_fn(model_key, func, args, keywords) if error_fn else None
return sorted(filter(None, (
build_error(model_key, *extract_operation(func))
for model_key in pending_models
for func in apps._pending_operations[model_key]
)), key=lambda error: error.msg)
@register(Tags.models)
def check_lazy_references(app_configs=None, **kwargs):
return _check_lazy_references(apps)

View File

@ -240,44 +240,11 @@ class StateApps(Apps):
self.render_multiple(list(models.values()) + self.real_models) self.render_multiple(list(models.values()) + self.real_models)
# There shouldn't be any operations pending at this point. # There shouldn't be any operations pending at this point.
pending_models = set(self._pending_operations) from django.core.checks.model_checks import _check_lazy_references
if ignore_swappable: ignore = {make_model_tuple(settings.AUTH_USER_MODEL)} if ignore_swappable else set()
pending_models -= {make_model_tuple(settings.AUTH_USER_MODEL)} errors = _check_lazy_references(self, ignore=ignore)
if pending_models: if errors:
raise ValueError(self._pending_models_error(pending_models)) raise ValueError("\n".join(error.msg for error in errors))
def _pending_models_error(self, pending_models):
"""
Almost all internal uses of lazy operations are to resolve string model
references in related fields. We can extract the fields from those
operations and use them to provide a nicer error message.
This will work for any function passed to lazy_related_operation() that
has a keyword argument called 'field'.
"""
def extract_field(operation):
# operation is annotated with the field in
# apps.registry.Apps.lazy_model_operation().
return getattr(operation, 'field', None)
def extract_field_names(operations):
return (str(field) for field in map(extract_field, operations) if field)
get_ops = self._pending_operations.__getitem__
# Ordered list of pairs of the form
# ((app_label, model_name), [field_name_1, field_name_2, ...])
models_fields = sorted(
(model_key, sorted(extract_field_names(get_ops(model_key))))
for model_key in pending_models
)
def model_text(model_key, fields):
field_list = ", ".join(fields)
field_text = " (referred to by fields: %s)" % field_list if fields else ""
return ("%s.%s" % model_key) + field_text
msg = "Unhandled pending operations for models:"
return "\n ".join([msg] + [model_text(*i) for i in models_fields])
@contextmanager @contextmanager
def bulk_update(self): def bulk_update(self):

View File

@ -7,12 +7,18 @@ def make_model_tuple(model):
corresponding ("app_label", "modelname") tuple. If a tuple is passed in, corresponding ("app_label", "modelname") tuple. If a tuple is passed in,
it's assumed to be a valid model tuple already and returned unchanged. it's assumed to be a valid model tuple already and returned unchanged.
""" """
if isinstance(model, tuple): try:
model_tuple = model if isinstance(model, tuple):
elif isinstance(model, six.string_types): model_tuple = model
app_label, model_name = model.split(".") elif isinstance(model, six.string_types):
model_tuple = app_label, model_name.lower() app_label, model_name = model.split(".")
else: model_tuple = app_label, model_name.lower()
model_tuple = model._meta.app_label, model._meta.model_name else:
assert len(model_tuple) == 2, "Invalid model representation: %s" % model model_tuple = model._meta.app_label, model._meta.model_name
return model_tuple assert len(model_tuple) == 2
return model_tuple
except (ValueError, AssertionError):
raise ValueError(
"Invalid model reference '%s'. String model references "
"must be of the form 'app_label.ModelName'." % model
)

View File

@ -138,6 +138,9 @@ Models
* **models.E020**: The ``<model>.check()`` class method is currently overridden. * **models.E020**: The ``<model>.check()`` class method is currently overridden.
* **models.E021**: ``ordering`` and ``order_with_respect_to`` cannot be used * **models.E021**: ``ordering`` and ``order_with_respect_to`` cannot be used
together. together.
* **models.E022**: ``<function>`` contains a lazy reference to
``<app label>>.<model>``, but app ``<app label>`` isn't installed or
doesn't provide model ``<model>``.
Fields Fields
~~~~~~ ~~~~~~
@ -200,6 +203,9 @@ Related Fields
for ``<field name>``. for ``<field name>``.
* **fields.E306**: Related name must be a valid Python identifier or end with * **fields.E306**: Related name must be a valid Python identifier or end with
a ``'+'``. a ``'+'``.
* **fields.E307**: The field ``<app label>.<model>.<field name>`` was declared
with a lazy reference to ``<app label>.<model>``, but app ``<app label>``
isn't installed or doesn't provide model ``<model>``.
* **fields.E310**: No subset of the fields ``<field1>``, ``<field2>``, ... on * **fields.E310**: No subset of the fields ``<field1>``, ``<field2>``, ... on
model ``<model>`` is unique. Add ``unique=True`` on any of those fields or model ``<model>`` is unique. Add ``unique=True`` on any of those fields or
add at least a subset of them to a unique_together constraint. add at least a subset of them to a unique_together constraint.

View File

@ -449,6 +449,12 @@ class MigrateTests(MigrationTestBase):
""" """
call_command("migrate", "migrated_unapplied_app", stdout=six.StringIO()) call_command("migrate", "migrated_unapplied_app", stdout=six.StringIO())
# unmigrated_app.SillyModel has a foreign key to 'migrations.Tribble',
# but that model is only defined in a migration, so the global app
# registry never sees it and the reference is left dangling. Remove it
# to avoid problems in subsequent tests.
del apps._pending_operations[('migrations', 'tribble')]
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_squashed"}) @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_squashed"})
def test_migrate_record_replaced(self): def test_migrate_record_replaced(self):
""" """

View File

@ -661,9 +661,10 @@ class StateTests(SimpleTestCase):
project_state = ProjectState() project_state = ProjectState()
project_state.add_model(ModelState.from_model(Book)) project_state.add_model(ModelState.from_model(Book))
msg = ( msg = (
"Unhandled pending operations for models:\n" "The field migrations.Book.author was declared with a lazy reference "
" migrations.author (referred to by fields: migrations.Book.author)\n" "to 'migrations.author', but app 'migrations' doesn't provide model 'author'.\n"
" migrations.publisher (referred to by fields: migrations.Book.publisher)" "The field migrations.Book.publisher was declared with a lazy reference "
"to 'migrations.publisher', but app 'migrations' doesn't provide model 'publisher'."
) )
with self.assertRaisesMessage(ValueError, msg): with self.assertRaisesMessage(ValueError, msg):
project_state.apps project_state.apps
@ -672,9 +673,10 @@ class StateTests(SimpleTestCase):
project_state = ProjectState() project_state = ProjectState()
project_state.add_model(ModelState.from_model(Magazine)) project_state.add_model(ModelState.from_model(Magazine))
msg = ( msg = (
"Unhandled pending operations for models:\n" "The field migrations.Magazine.authors was declared with a lazy reference "
" migrations.author (referred to by fields: " "to 'migrations.author\', but app 'migrations' doesn't provide model 'author'.\n"
"migrations.Magazine.authors, migrations.Magazine_authors.author)" "The field migrations.Magazine_authors.author was declared with a lazy reference "
"to \'migrations.author\', but app 'migrations' doesn't provide model 'author'."
) )
with self.assertRaisesMessage(ValueError, msg): with self.assertRaisesMessage(ValueError, msg):
project_state.apps project_state.apps
@ -682,10 +684,14 @@ class StateTests(SimpleTestCase):
# And now with multiple models and multiple fields. # And now with multiple models and multiple fields.
project_state.add_model(ModelState.from_model(Book)) project_state.add_model(ModelState.from_model(Book))
msg = ( msg = (
"Unhandled pending operations for models:\n" "The field migrations.Book.author was declared with a lazy reference "
" migrations.author (referred to by fields: migrations.Book.author, " "to 'migrations.author', but app 'migrations' doesn't provide model 'author'.\n"
"migrations.Magazine.authors, migrations.Magazine_authors.author)\n" "The field migrations.Book.publisher was declared with a lazy reference "
" migrations.publisher (referred to by fields: migrations.Book.publisher)" "to 'migrations.publisher', but app 'migrations' doesn't provide model 'publisher'.\n"
"The field migrations.Magazine.authors was declared with a lazy reference "
"to 'migrations.author', but app 'migrations' doesn't provide model 'author'.\n"
"The field migrations.Magazine_authors.author was declared with a lazy reference "
"to 'migrations.author', but app 'migrations' doesn't provide model 'author'."
) )
with self.assertRaisesMessage(ValueError, msg): with self.assertRaisesMessage(ValueError, msg):
project_state.apps project_state.apps

View File

@ -1,8 +1,10 @@
from django.core import management from django.core import management
from django.core.checks import Error, run_checks from django.core.checks import Error, run_checks
from django.core.checks.model_checks import _check_lazy_references
from django.db import models
from django.db.models.signals import post_init from django.db.models.signals import post_init
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.test.utils import override_settings from django.test.utils import isolate_apps, override_settings
from django.utils import six from django.utils import six
@ -15,6 +17,10 @@ def on_post_init(**kwargs):
pass pass
def dummy_function(model):
pass
@override_settings( @override_settings(
INSTALLED_APPS=['django.contrib.auth', 'django.contrib.contenttypes'], INSTALLED_APPS=['django.contrib.auth', 'django.contrib.contenttypes'],
SILENCED_SYSTEM_CHECKS=['fields.W342'], # ForeignKey(unique=True) SILENCED_SYSTEM_CHECKS=['fields.W342'], # ForeignKey(unique=True)
@ -54,3 +60,42 @@ class ModelValidationTest(SimpleTestCase):
self.assertEqual(errors, expected) self.assertEqual(errors, expected)
post_init.unresolved_references = unresolved_references post_init.unresolved_references = unresolved_references
@isolate_apps('django.contrib.auth', kwarg_name='apps')
def test_lazy_reference_checks(self, apps):
class DummyModel(models.Model):
author = models.ForeignKey('Author', models.CASCADE)
class Meta:
app_label = "model_validation"
apps.lazy_model_operation(dummy_function, ('auth', 'imaginarymodel'))
apps.lazy_model_operation(dummy_function, ('fanciful_app', 'imaginarymodel'))
errors = _check_lazy_references(apps)
expected = [
Error(
"%r contains a lazy reference to auth.imaginarymodel, "
"but app 'auth' doesn't provide model 'imaginarymodel'." % dummy_function,
obj=dummy_function,
id='models.E022',
),
Error(
"%r contains a lazy reference to fanciful_app.imaginarymodel, "
"but app 'fanciful_app' isn't installed." % dummy_function,
obj=dummy_function,
id='models.E022',
),
Error(
"The field model_validation.DummyModel.author was declared "
"with a lazy reference to 'model_validation.author', but app "
"'model_validation' isn't installed.",
hint=None,
obj=DummyModel.author.field,
id='fields.E307',
),
]
self.assertEqual(errors, expected)