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

This commit is contained in:
Calvin DeBoer 2018-05-19 11:38:02 -04:00 committed by Tim Graham
parent 1160a97596
commit 058d33f3ed
9 changed files with 197 additions and 2 deletions

View File

@ -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

View File

@ -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

View File

@ -175,7 +175,8 @@ Management Commands
Migrations
~~~~~~~~~~
* ...
* The new :option:`migrate --plan` option prints the list of migration
operations that will be performed.
Models
~~~~~~

View File

@ -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):
"""

View File

@ -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),
]

View File

@ -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')
]

View File

@ -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')
]

View File

@ -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')
]