Browse Source

Fixed #29198 -- Added migrate --plan option.

Calvin DeBoer 6 years ago
parent
commit
058d33f3ed

+ 42 - 1
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

+ 7 - 0
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

+ 2 - 1
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
 ~~~~~~

+ 67 - 0
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):
         """

+ 28 - 0
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),
+    ]

+ 20 - 0
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')
+
+    ]

+ 19 - 0
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')
+    ]

+ 12 - 0
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')
+    ]

+ 0 - 0
tests/migrations/test_migrations_plan/__init__.py