From 763ac8b642bc21b5a05c0d540c962804b36454ec Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Wed, 16 Oct 2013 12:00:07 +0100 Subject: [PATCH] First pass on squashmigrations command; files are right, execution not. --- django/core/management/commands/migrate.py | 11 +- .../management/commands/squashmigrations.py | 108 ++++++++++++++++++ django/db/migrations/loader.py | 6 + django/db/migrations/writer.py | 6 +- 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 django/core/management/commands/squashmigrations.py diff --git a/django/core/management/commands/migrate.py b/django/core/management/commands/migrate.py index aa74c9c9c3..f351adaaba 100644 --- a/django/core/management/commands/migrate.py +++ b/django/core/management/commands/migrate.py @@ -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: diff --git a/django/core/management/commands/squashmigrations.py b/django/core/management/commands/squashmigrations.py new file mode 100644 index 0000000000..3282fc54ee --- /dev/null +++ b/django/core/management/commands/squashmigrations.py @@ -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.") diff --git a/django/db/migrations/loader.py b/django/db/migrations/loader.py index 846f0b8eaf..b40584981d 100644 --- a/django/db/migrations/loader.py +++ b/django/db/migrations/loader.py @@ -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) diff --git a/django/db/migrations/writer.py b/django/db/migrations/writer.py index 756bb97c04..beb1b30aba 100644 --- a/django/db/migrations/writer.py +++ b/django/db/migrations/writer.py @@ -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