Fixed #24100 -- Made the migration signals dispatch its plan and apps.

Thanks Markus for your contribution and Tim for your review.
This commit is contained in:
Simon Charette 2016-05-13 11:58:54 -04:00
parent e475e84970
commit f937c9ec97
8 changed files with 148 additions and 18 deletions

View File

@ -15,7 +15,7 @@ from django.db import DEFAULT_DB_ALIAS, connections, router, transaction
from django.db.migrations.autodetector import MigrationAutodetector from django.db.migrations.autodetector import MigrationAutodetector
from django.db.migrations.executor import MigrationExecutor from django.db.migrations.executor import MigrationExecutor
from django.db.migrations.loader import AmbiguityError from django.db.migrations.loader import AmbiguityError
from django.db.migrations.state import ProjectState from django.db.migrations.state import ModelState, ProjectState
from django.utils.module_loading import module_has_submodule from django.utils.module_loading import module_has_submodule
@ -160,7 +160,10 @@ class Command(BaseCommand):
% (targets[0][1], targets[0][0]) % (targets[0][1], targets[0][0])
) )
emit_pre_migrate_signal(self.verbosity, self.interactive, connection.alias) pre_migrate_apps = executor._create_project_state().apps
emit_pre_migrate_signal(
self.verbosity, self.interactive, connection.alias, apps=pre_migrate_apps, plan=plan,
)
# Run the syncdb phase. # Run the syncdb phase.
if run_syncdb: if run_syncdb:
@ -191,14 +194,33 @@ class Command(BaseCommand):
"migrations, and then re-run 'manage.py migrate' to " "migrations, and then re-run 'manage.py migrate' to "
"apply them." "apply them."
)) ))
post_migrate_apps = pre_migrate_apps
else: else:
fake = options['fake'] fake = options['fake']
fake_initial = options['fake_initial'] fake_initial = options['fake_initial']
executor.migrate(targets, plan, fake=fake, fake_initial=fake_initial) post_migrate_project_state = executor.migrate(
targets, plan, fake=fake, fake_initial=fake_initial
)
post_migrate_apps = post_migrate_project_state.apps
# Re-render models of real apps to include relationships now that
# we've got a final state. This wouldn't be necessary if real apps
# models were rendered with relationships in the first place.
with post_migrate_apps.bulk_update():
model_keys = []
for model_state in post_migrate_apps.real_models:
model_key = model_state.app_label, model_state.name_lower
model_keys.append(model_key)
post_migrate_apps.unregister_model(*model_key)
post_migrate_apps.render_multiple([
ModelState.from_model(apps.get_model(*model_key)) for model_key in model_keys
])
# Send the post_migrate signal, so individual apps can do whatever they need # Send the post_migrate signal, so individual apps can do whatever they need
# to do at this point. # to do at this point.
emit_post_migrate_signal(self.verbosity, self.interactive, connection.alias) emit_post_migrate_signal(
self.verbosity, self.interactive, connection.alias, apps=post_migrate_apps, plan=plan,
)
def migration_progress_callback(self, action, migration=None, fake=False): def migration_progress_callback(self, action, migration=None, fake=False):
if self.verbosity >= 1: if self.verbosity >= 1:

View File

@ -20,7 +20,7 @@ def sql_flush(style, connection, only_django=False, reset_sequences=True, allow_
return statements return statements
def emit_pre_migrate_signal(verbosity, interactive, db): def emit_pre_migrate_signal(verbosity, interactive, db, **kwargs):
# Emit the pre_migrate signal for every application. # Emit the pre_migrate signal for every application.
for app_config in apps.get_app_configs(): for app_config in apps.get_app_configs():
if app_config.models_module is None: if app_config.models_module is None:
@ -32,10 +32,12 @@ def emit_pre_migrate_signal(verbosity, interactive, db):
app_config=app_config, app_config=app_config,
verbosity=verbosity, verbosity=verbosity,
interactive=interactive, interactive=interactive,
using=db) using=db,
**kwargs
)
def emit_post_migrate_signal(verbosity, interactive, db): def emit_post_migrate_signal(verbosity, interactive, db, **kwargs):
# Emit the post_migrate signal for every application. # Emit the post_migrate signal for every application.
for app_config in apps.get_app_configs(): for app_config in apps.get_app_configs():
if app_config.models_module is None: if app_config.models_module is None:
@ -47,4 +49,6 @@ def emit_post_migrate_signal(verbosity, interactive, db):
app_config=app_config, app_config=app_config,
verbosity=verbosity, verbosity=verbosity,
interactive=interactive, interactive=interactive,
using=db) using=db,
**kwargs
)

View File

@ -63,6 +63,9 @@ class MigrationExecutor(object):
applied.add(migration) applied.add(migration)
return plan return plan
def _create_project_state(self):
return ProjectState(real_apps=list(self.loader.unmigrated_apps))
def migrate(self, targets, plan=None, fake=False, fake_initial=False): def migrate(self, targets, plan=None, fake=False, fake_initial=False):
""" """
Migrates the database up to the given targets. Migrates the database up to the given targets.
@ -79,7 +82,9 @@ class MigrationExecutor(object):
all_backwards = all(backwards for mig, backwards in plan) all_backwards = all(backwards for mig, backwards in plan)
if not plan: if not plan:
pass # Nothing to do for an empty plan # Nothing to do for an empty plan, except for building the post
# migrate project state
state = self._create_project_state()
elif all_forwards == all_backwards: elif all_forwards == all_backwards:
# This should only happen if there's a mixed plan # This should only happen if there's a mixed plan
raise InvalidMigrationPlan( raise InvalidMigrationPlan(
@ -89,21 +94,27 @@ class MigrationExecutor(object):
plan plan
) )
elif all_forwards: elif all_forwards:
self._migrate_all_forwards(plan, full_plan, fake=fake, fake_initial=fake_initial) state = self._migrate_all_forwards(plan, full_plan, fake=fake, fake_initial=fake_initial)
else: else:
# No need to check for `elif all_backwards` here, as that condition # No need to check for `elif all_backwards` here, as that condition
# would always evaluate to true. # would always evaluate to true.
self._migrate_all_backwards(plan, full_plan, fake=fake) state = self._migrate_all_backwards(plan, full_plan, fake=fake)
self.check_replacements() self.check_replacements()
return state
def _migrate_all_forwards(self, plan, full_plan, fake, fake_initial): def _migrate_all_forwards(self, plan, full_plan, fake, fake_initial):
""" """
Take a list of 2-tuples of the form (migration instance, False) and Take a list of 2-tuples of the form (migration instance, False) and
apply them in the order they occur in the full_plan. apply them in the order they occur in the full_plan.
""" """
migrations_to_run = {m[0] for m in plan} migrations_to_run = {m[0] for m in plan}
state = ProjectState(real_apps=list(self.loader.unmigrated_apps)) state = self._create_project_state()
applied_migrations = {
self.loader.graph.nodes[key] for key in self.loader.applied_migrations
if key in self.loader.graph.nodes
}
for migration, _ in full_plan: for migration, _ in full_plan:
if not migrations_to_run: if not migrations_to_run:
# We remove every migration that we applied from this set so # We remove every migration that we applied from this set so
@ -120,9 +131,14 @@ class MigrationExecutor(object):
self.progress_callback("render_success") self.progress_callback("render_success")
state = self.apply_migration(state, migration, fake=fake, fake_initial=fake_initial) state = self.apply_migration(state, migration, fake=fake, fake_initial=fake_initial)
migrations_to_run.remove(migration) migrations_to_run.remove(migration)
else: elif migration in applied_migrations:
# Only mutate the state if the migration is actually applied
# to make sure the resulting state doesn't include changes
# from unrelated migrations.
migration.mutate_state(state, preserve=False) migration.mutate_state(state, preserve=False)
return state
def _migrate_all_backwards(self, plan, full_plan, fake): def _migrate_all_backwards(self, plan, full_plan, fake):
""" """
Take a list of 2-tuples of the form (migration instance, True) and Take a list of 2-tuples of the form (migration instance, True) and
@ -136,7 +152,11 @@ class MigrationExecutor(object):
migrations_to_run = {m[0] for m in plan} migrations_to_run = {m[0] for m in plan}
# Holds all migration states prior to the migrations being unapplied # Holds all migration states prior to the migrations being unapplied
states = {} states = {}
state = ProjectState(real_apps=list(self.loader.unmigrated_apps)) state = self._create_project_state()
applied_migrations = {
self.loader.graph.nodes[key] for key in self.loader.applied_migrations
if key in self.loader.graph.nodes
}
if self.progress_callback: if self.progress_callback:
self.progress_callback("render_start") self.progress_callback("render_start")
for migration, _ in full_plan: for migration, _ in full_plan:
@ -154,13 +174,31 @@ class MigrationExecutor(object):
# The old state keeps as-is, we continue with the new state # The old state keeps as-is, we continue with the new state
state = migration.mutate_state(state, preserve=True) state = migration.mutate_state(state, preserve=True)
migrations_to_run.remove(migration) migrations_to_run.remove(migration)
else: elif migration in applied_migrations:
# Only mutate the state if the migration is actually applied
# to make sure the resulting state doesn't include changes
# from unrelated migrations.
migration.mutate_state(state, preserve=False) migration.mutate_state(state, preserve=False)
if self.progress_callback: if self.progress_callback:
self.progress_callback("render_success") self.progress_callback("render_success")
for migration, _ in plan: for migration, _ in plan:
self.unapply_migration(states[migration], migration, fake=fake) self.unapply_migration(states[migration], migration, fake=fake)
applied_migrations.remove(migration)
# Generate the post migration state by starting from the state before
# the last migration is unapplied and mutating it to include all the
# remaining applied migrations.
last_unapplied_migration = plan[-1][0]
state = states[last_unapplied_migration]
for index, (migration, _) in enumerate(full_plan):
if migration == last_unapplied_migration:
for migration, _ in full_plan[index:]:
if migration in applied_migrations:
migration.mutate_state(state, preserve=False)
break
return state
def collect_sql(self, plan): def collect_sql(self, plan):
""" """

View File

@ -65,5 +65,5 @@ m2m_changed = ModelSignal(
use_caching=True, use_caching=True,
) )
pre_migrate = Signal(providing_args=["app_config", "verbosity", "interactive", "using"]) pre_migrate = Signal(providing_args=["app_config", "verbosity", "interactive", "using", "apps", "plan"])
post_migrate = Signal(providing_args=["app_config", "verbosity", "interactive", "using"]) post_migrate = Signal(providing_args=["app_config", "verbosity", "interactive", "using", "apps", "plan"])

View File

@ -406,6 +406,23 @@ Arguments sent with this signal:
``using`` ``using``
The alias of database on which a command will operate. The alias of database on which a command will operate.
``plan``
.. versionadded:: 1.10
The migration plan that is going to be used for the migration run. While
the plan is not public API, this allows for the rare cases when it is
necessary to know the plan. A plan is a list of two-tuples with the first
item being the instance of a migration class and the second item showing
if the migration was rolled back (``True``) or applied (``False``).
``apps``
.. versionadded:: 1.10
An instance of :data:`Apps <django.apps>` containing the state of the
project before the migration run. It should be used instead of the global
:attr:`apps <django.apps.apps>` registry to retrieve the models you
want to perform operations on.
``post_migrate`` ``post_migrate``
---------------- ----------------
@ -448,6 +465,23 @@ Arguments sent with this signal:
The database alias used for synchronization. Defaults to the ``default`` The database alias used for synchronization. Defaults to the ``default``
database. database.
``plan``
.. versionadded:: 1.10
The migration plan that was used for the migration run. While the plan is
not public API, this allows for the rare cases when it is necessary to
know the plan. A plan is a list of two-tuples with the first item being
the instance of a migration class and the second item showing if the
migration was rolled back (``True``) or applied (``False``).
``apps``
.. versionadded:: 1.10
An instance of :data:`Apps <django.apps.apps>` containing the state of the
project after the migration run. It should be used instead of the global
:attr:`apps <django.apps.apps>` registry to retrieve the models you
want to perform operations on.
For example, you could register a callback in an For example, you could register a callback in an
:class:`~django.apps.AppConfig` like this:: :class:`~django.apps.AppConfig` like this::

View File

@ -368,6 +368,10 @@ Migrations
migration history. If they find some unapplied dependencies of an applied migration history. If they find some unapplied dependencies of an applied
migration, ``InconsistentMigrationHistory`` is raised. migration, ``InconsistentMigrationHistory`` is raised.
* The :func:`~django.db.models.signals.pre_migrate` and
:func:`~django.db.models.signals.post_migrate` signals now dispatch their
migration ``plan`` and ``apps``.
Models Models
~~~~~~ ~~~~~~

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
"Signal",
[
("id", models.AutoField(primary_key=True)),
],
),
]

View File

@ -1,11 +1,12 @@
from django.apps import apps from django.apps import apps
from django.core import management from django.core import management
from django.db import migrations
from django.db.models import signals from django.db.models import signals
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils import six from django.utils import six
APP_CONFIG = apps.get_app_config('migrate_signals') APP_CONFIG = apps.get_app_config('migrate_signals')
SIGNAL_ARGS = ['app_config', 'verbosity', 'interactive', 'using'] SIGNAL_ARGS = ['app_config', 'verbosity', 'interactive', 'using', 'plan', 'apps']
MIGRATE_DATABASE = 'default' MIGRATE_DATABASE = 'default'
MIGRATE_VERBOSITY = 1 MIGRATE_VERBOSITY = 1
MIGRATE_INTERACTIVE = False MIGRATE_INTERACTIVE = False
@ -80,6 +81,8 @@ class MigrateSignalTests(TestCase):
self.assertEqual(args['verbosity'], MIGRATE_VERBOSITY) self.assertEqual(args['verbosity'], MIGRATE_VERBOSITY)
self.assertEqual(args['interactive'], MIGRATE_INTERACTIVE) self.assertEqual(args['interactive'], MIGRATE_INTERACTIVE)
self.assertEqual(args['using'], 'default') self.assertEqual(args['using'], 'default')
self.assertEqual(args['plan'], [])
self.assertIsInstance(args['apps'], migrations.state.StateApps)
@override_settings(MIGRATION_MODULES={'migrate_signals': 'migrate_signals.custom_migrations'}) @override_settings(MIGRATION_MODULES={'migrate_signals': 'migrate_signals.custom_migrations'})
def test_migrations_only(self): def test_migrations_only(self):
@ -101,3 +104,12 @@ class MigrateSignalTests(TestCase):
self.assertEqual(args['verbosity'], MIGRATE_VERBOSITY) self.assertEqual(args['verbosity'], MIGRATE_VERBOSITY)
self.assertEqual(args['interactive'], MIGRATE_INTERACTIVE) self.assertEqual(args['interactive'], MIGRATE_INTERACTIVE)
self.assertEqual(args['using'], 'default') self.assertEqual(args['using'], 'default')
self.assertIsInstance(args['plan'][0][0], migrations.Migration)
# The migration isn't applied backward.
self.assertFalse(args['plan'][0][1])
self.assertIsInstance(args['apps'], migrations.state.StateApps)
self.assertEqual(pre_migrate_receiver.call_args['apps'].get_models(), [])
self.assertEqual(
[model._meta.label for model in post_migrate_receiver.call_args['apps'].get_models()],
['migrate_signals.Signal']
)