diff --git a/django/core/management/commands/migrate.py b/django/core/management/commands/migrate.py index 1e407831b4..299edbe421 100644 --- a/django/core/management/commands/migrate.py +++ b/django/core/management/commands/migrate.py @@ -16,6 +16,7 @@ from django.db.migrations.executor import MigrationExecutor from django.db.migrations.loader import AmbiguityError from django.db.migrations.state import ModelState, ProjectState from django.utils.module_loading import module_has_submodule +from django.utils.text import Truncator class Command(BaseCommand): @@ -50,6 +51,10 @@ class Command(BaseCommand): 'that the current database schema matches your initial migration before using this ' 'flag. Django will only check for an existing table name.', ) + parser.add_argument( + '--plan', action='store_true', + help='Shows a list of the migration actions that will be performed.', + ) parser.add_argument( '--run-syncdb', action='store_true', help='Creates tables for apps without migrations.', @@ -134,8 +139,20 @@ class Command(BaseCommand): targets = executor.loader.graph.leaf_nodes() plan = executor.migration_plan(targets) - run_syncdb = options['run_syncdb'] and executor.loader.unmigrated_apps + if options['plan']: + self.stdout.write('Planned operations:', self.style.MIGRATE_LABEL) + if not plan: + self.stdout.write(' No planned migration operations.') + for migration, backwards in plan: + self.stdout.write(str(migration), self.style.MIGRATE_HEADING) + for operation in migration.operations: + message, is_error = self.describe_operation(operation, backwards) + style = self.style.WARNING if is_error else None + self.stdout.write(' ' + message, style) + return + + run_syncdb = options['run_syncdb'] and executor.loader.unmigrated_apps # Print some useful info if self.verbosity >= 1: self.stdout.write(self.style.MIGRATE_HEADING("Operations to perform:")) @@ -309,3 +326,27 @@ class Command(BaseCommand): # Deferred SQL is executed when exiting the editor's context. if self.verbosity >= 1: self.stdout.write(" Running deferred SQL...\n") + + @staticmethod + def describe_operation(operation, backwards): + """Return a string that describes a migration operation for --plan.""" + prefix = '' + if hasattr(operation, 'code'): + code = operation.reverse_code if backwards else operation.code + action = code.__doc__ if code else '' + elif hasattr(operation, 'sql'): + action = operation.reverse_sql if backwards else operation.sql + else: + action = '' + if backwards: + prefix = 'Undo ' + if action is None: + action = 'IRREVERSIBLE' + is_error = True + else: + action = action.replace('\n', '') + is_error = False + if action: + action = ' -> ' + action + truncated = Truncator(action) + return prefix + operation.describe() + truncated.chars(40), is_error diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 4916a8c623..408c39055e 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -804,6 +804,13 @@ option does not, however, check for matching database schema beyond matching table names and so is only safe to use if you are confident that your existing schema matches what is recorded in your initial migration. +.. django-admin-option:: --plan + +.. versionadded:: 2.2 + +Shows the migration operations that will be performed for the given ``migrate`` +command. + .. django-admin-option:: --run-syncdb Allows creating tables for apps without migrations. While this isn't diff --git a/docs/releases/2.2.txt b/docs/releases/2.2.txt index 13dac6bfec..742bc893e5 100644 --- a/docs/releases/2.2.txt +++ b/docs/releases/2.2.txt @@ -175,7 +175,8 @@ Management Commands Migrations ~~~~~~~~~~ -* ... +* The new :option:`migrate --plan` option prints the list of migration + operations that will be performed. Models ~~~~~~ diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py index 8276049dc6..3bc37b6c15 100644 --- a/tests/migrations/test_commands.py +++ b/tests/migrations/test_commands.py @@ -298,6 +298,73 @@ class MigrateTests(MigrationTestBase): # Cleanup by unmigrating everything call_command("migrate", "migrations", "zero", verbosity=0) + @override_settings(MIGRATION_MODULES={'migrations': 'migrations.test_migrations_plan'}) + def test_migrate_plan(self): + """Tests migrate --plan output.""" + out = io.StringIO() + # Show the plan up to the third migration. + call_command('migrate', 'migrations', '0003', plan=True, stdout=out, no_color=True) + self.assertEqual( + 'Planned operations:\n' + 'migrations.0001_initial\n' + ' Create model Salamander\n' + ' Raw Python operation -> Grow salamander tail.\n' + 'migrations.0002_second\n' + ' Create model Book\n' + ' Raw SQL operation -> SELECT * FROM migrations_book\n' + 'migrations.0003_third\n' + ' Create model Author\n' + ' Raw SQL operation -> SELECT * FROM migrations_author\n', + out.getvalue() + ) + # Migrate to the third migration. + call_command('migrate', 'migrations', '0003', verbosity=0) + out = io.StringIO() + # Show the plan for when there is nothing to apply. + call_command('migrate', 'migrations', '0003', plan=True, stdout=out, no_color=True) + self.assertEqual( + 'Planned operations:\n' + ' No planned migration operations.\n', + out.getvalue() + ) + out = io.StringIO() + # Show the plan for reverse migration back to 0001. + call_command('migrate', 'migrations', '0001', plan=True, stdout=out, no_color=True) + self.assertEqual( + 'Planned operations:\n' + 'migrations.0003_third\n' + ' Undo Create model Author\n' + ' Raw SQL operation -> SELECT * FROM migrations_book\n' + 'migrations.0002_second\n' + ' Undo Create model Book\n' + ' Raw SQL operation -> SELECT * FROM migrations_salamander\n', + out.getvalue() + ) + out = io.StringIO() + # Show the migration plan to fourth, with truncated details. + call_command('migrate', 'migrations', '0004', plan=True, stdout=out, no_color=True) + self.assertEqual( + 'Planned operations:\n' + 'migrations.0004_fourth\n' + ' Raw SQL operation -> SELECT * FROM migrations_author W...\n', + out.getvalue() + ) + # Migrate to the fourth migration. + call_command('migrate', 'migrations', '0004', verbosity=0) + out = io.StringIO() + # Show the plan when an operation is irreversible. + call_command('migrate', 'migrations', '0003', plan=True, stdout=out, no_color=True) + self.assertEqual( + 'Planned operations:\n' + 'migrations.0004_fourth\n' + ' Raw SQL operation -> IRREVERSIBLE\n', + out.getvalue() + ) + # Cleanup by unmigrating everything: fake the irreversible, then + # migrate all to zero. + call_command('migrate', 'migrations', '0003', fake=True, verbosity=0) + call_command('migrate', 'migrations', 'zero', verbosity=0) + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_empty"}) def test_showmigrations_plan_no_migrations(self): """ diff --git a/tests/migrations/test_migrations_plan/0001_initial.py b/tests/migrations/test_migrations_plan/0001_initial.py new file mode 100644 index 0000000000..0a4001d52a --- /dev/null +++ b/tests/migrations/test_migrations_plan/0001_initial.py @@ -0,0 +1,28 @@ +from django.db import migrations, models + + +def grow_tail(x, y): + """Grow salamander tail.""" + pass + + +def shrink_tail(x, y): + """Shrink salamander tail.""" + pass + + +class Migration(migrations.Migration): + + initial = True + + operations = [ + migrations.CreateModel( + 'Salamander', + [ + ('id', models.AutoField(primary_key=True)), + ('tail', models.IntegerField(default=0)), + ('silly_field', models.BooleanField(default=False)), + ], + ), + migrations.RunPython(grow_tail, shrink_tail), + ] diff --git a/tests/migrations/test_migrations_plan/0002_second.py b/tests/migrations/test_migrations_plan/0002_second.py new file mode 100644 index 0000000000..e8aeec880b --- /dev/null +++ b/tests/migrations/test_migrations_plan/0002_second.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('migrations', '0001_initial'), + ] + + operations = [ + + migrations.CreateModel( + 'Book', + [ + ('id', models.AutoField(primary_key=True)), + ], + ), + migrations.RunSQL('SELECT * FROM migrations_book', 'SELECT * FROM migrations_salamander') + + ] diff --git a/tests/migrations/test_migrations_plan/0003_third.py b/tests/migrations/test_migrations_plan/0003_third.py new file mode 100644 index 0000000000..d045a91448 --- /dev/null +++ b/tests/migrations/test_migrations_plan/0003_third.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('migrations', '0002_second'), + ] + + operations = [ + + migrations.CreateModel( + 'Author', + [ + ('id', models.AutoField(primary_key=True)), + ], + ), + migrations.RunSQL('SELECT * FROM migrations_author', 'SELECT * FROM migrations_book') + ] diff --git a/tests/migrations/test_migrations_plan/0004_fourth.py b/tests/migrations/test_migrations_plan/0004_fourth.py new file mode 100644 index 0000000000..d3e1a54b4d --- /dev/null +++ b/tests/migrations/test_migrations_plan/0004_fourth.py @@ -0,0 +1,12 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("migrations", "0003_third"), + ] + + operations = [ + migrations.RunSQL('SELECT * FROM migrations_author WHERE id = 1') + ] diff --git a/tests/migrations/test_migrations_plan/__init__.py b/tests/migrations/test_migrations_plan/__init__.py new file mode 100644 index 0000000000..e69de29bb2