First pass on squashmigrations command; files are right, execution not.

This commit is contained in:
Andrew Godwin 2013-10-16 12:00:07 +01:00
parent 42f8666f6a
commit 763ac8b642
4 changed files with 127 additions and 4 deletions

View File

@ -76,7 +76,7 @@ class Command(BaseCommand):
except AmbiguityError:
raise CommandError("More than one migration matches '%s' in app '%s'. Please be more specific." % (app_label, migration_name))
except KeyError:
raise CommandError("Cannot find a migration matching '%s' from app '%s'. Is it in INSTALLED_APPS?" % (app_label, migration_name))
raise CommandError("Cannot find a migration matching '%s' from app '%s'." % (app_label, migration_name))
targets = [(app_label, migration.name)]
target_app_labels_only = False
elif len(args) == 1:
@ -279,10 +279,15 @@ class Command(BaseCommand):
for node in graph.leaf_nodes(app):
for plan_node in graph.forwards_plan(node):
if plan_node not in shown and plan_node[0] == app:
# Give it a nice title if it's a squashed one
title = plan_node[1]
if graph.nodes[plan_node].replaces:
title += " (%s squashed migrations)" % len(graph.nodes[plan_node].replaces)
# Mark it as applied/unapplied
if plan_node in loader.applied_migrations:
self.stdout.write(" [X] %s" % plan_node[1])
self.stdout.write(" [X] %s" % title)
else:
self.stdout.write(" [ ] %s" % plan_node[1])
self.stdout.write(" [ ] %s" % title)
shown.add(plan_node)
# If we didn't print anything, then a small message
if not shown:

View File

@ -0,0 +1,108 @@
import sys
import os
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
from django.core.exceptions import ImproperlyConfigured
from django.utils import six
from django.db import connections, DEFAULT_DB_ALIAS, migrations
from django.db.migrations.loader import MigrationLoader, AmbiguityError
from django.db.migrations.autodetector import MigrationAutodetector, InteractiveMigrationQuestioner
from django.db.migrations.executor import MigrationExecutor
from django.db.migrations.writer import MigrationWriter
from django.db.models.loading import cache
from django.db.migrations.optimizer import MigrationOptimizer
class Command(BaseCommand):
option_list = BaseCommand.option_list + (
make_option('--no-optimize', action='store_true', dest='no_optimize', default=False,
help='Do not try to optimize the squashed operations.'),
make_option('--noinput', action='store_false', dest='interactive', default=True,
help='Tells Django to NOT prompt the user for input of any kind.'),
)
help = "Squashes an existing set of migrations (from first until specified) into a single new one."
usage_str = "Usage: ./manage.py squashmigrations app migration_name"
def handle(self, app_label=None, migration_name=None, **options):
self.verbosity = int(options.get('verbosity'))
self.interactive = options.get('interactive')
if app_label is None or migration_name is None:
self.stderr.write(self.usage_str)
sys.exit(1)
# Load the current graph state, check the app and migration they asked for exists
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
if app_label not in executor.loader.migrated_apps:
raise CommandError("App '%s' does not have migrations (so squashmigrations on it makes no sense)" % app_label)
try:
migration = executor.loader.get_migration_by_prefix(app_label, migration_name)
except AmbiguityError:
raise CommandError("More than one migration matches '%s' in app '%s'. Please be more specific." % (app_label, migration_name))
except KeyError:
raise CommandError("Cannot find a migration matching '%s' from app '%s'." % (app_label, migration_name))
# Work out the list of predecessor migrations
migrations_to_squash = [
executor.loader.get_migration(al, mn)
for al, mn in executor.loader.graph.forwards_plan((migration.app_label, migration.name))
if al == migration.app_label
]
# Tell them what we're doing and optionally ask if we should proceed
if self.verbosity > 0 or self.interactive:
self.stdout.write(self.style.MIGRATE_HEADING("Will squash the following migrations:"))
for migration in migrations_to_squash:
self.stdout.write(" - %s" % migration.name)
if self.interactive:
answer = None
while not answer or answer not in "yn":
answer = six.moves.input("Do you wish to proceed? [yN] ")
if not answer:
answer = "n"
break
else:
answer = answer[0].lower()
if answer != "y":
return
# Load the operations from all those migrations and concat together
operations = []
for smigration in migrations_to_squash:
operations.extend(smigration.operations)
if self.verbosity > 0:
self.stdout.write(self.style.MIGRATE_HEADING("Optimizing..."))
optimizer = MigrationOptimizer()
new_operations = optimizer.optimize(operations, migration.app_label)
if self.verbosity > 0:
if len(new_operations) == len(operations):
self.stdout.write(" No optimizations possible.")
else:
self.stdout.write(" Optimized from %s operations to %s operations." % (len(operations), len(new_operations)))
# Make a new migration with those operations
subclass = type("Migration", (migrations.Migration, ), {
"dependencies": [],
"operations": new_operations,
"replaces": [(m.app_label, m.name) for m in migrations_to_squash],
})
new_migration = subclass("0001_squashed_%s" % migration.name, app_label)
# Write out the new migration file
writer = MigrationWriter(new_migration)
with open(writer.path, "wb") as fh:
fh.write(writer.as_string())
if self.verbosity > 0:
self.stdout.write(self.style.MIGRATE_HEADING("Created new squashed migration %s" % writer.path))
self.stdout.write(" You should commit this migration but leave the old ones in place;")
self.stdout.write(" the new migration will be used for new installs. Once you are sure")
self.stdout.write(" all instances of the codebase have applied the migrations you squashed,")
self.stdout.write(" you can delete them.")

View File

@ -101,6 +101,10 @@ class MigrationLoader(object):
if south_style_migrations:
self.unmigrated_apps.add(app_label)
def get_migration(self, app_label, name_prefix):
"Gets the migration exactly named, or raises KeyError"
return self.graph.nodes[app_label, name_prefix]
def get_migration_by_prefix(self, app_label, name_prefix):
"Returns the migration(s) which match the given app label and name _prefix_"
# Make sure we have the disk data
@ -160,6 +164,8 @@ class MigrationLoader(object):
# and remove, repointing dependencies if needs be.
for replaced in migration.replaces:
if replaced in normal:
# We don't care if the replaced migration doesn't exist;
# the usage pattern here is to delete things after a while.
del normal[replaced]
for child_key in reverse_dependencies.get(replaced, set()):
normal[child_key].dependencies.remove(replaced)

View File

@ -26,6 +26,7 @@ class MigrationWriter(object):
"""
items = {
"dependencies": repr(self.migration.dependencies),
"replaces_str": "",
}
imports = set()
# Deconstruct operations
@ -49,6 +50,9 @@ class MigrationWriter(object):
items["imports"] = ""
else:
items["imports"] = "\n".join(imports) + "\n"
# If there's a replaces, make a string for it
if self.migration.replaces:
items['replaces_str'] = "\n replaces = %s\n" % repr(self.migration.replaces)
return (MIGRATION_TEMPLATE % items).encode("utf8")
@property
@ -186,7 +190,7 @@ from django.db import models, migrations
%(imports)s
class Migration(migrations.Migration):
%(replaces_str)s
dependencies = %(dependencies)s
operations = %(operations)s