diff --git a/django/core/management/commands/squashmigrations.py b/django/core/management/commands/squashmigrations.py index 39225c2d75..9ad5f60d35 100644 --- a/django/core/management/commands/squashmigrations.py +++ b/django/core/management/commands/squashmigrations.py @@ -15,6 +15,8 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('app_label', help='App label of the application to squash migrations for.') + parser.add_argument('start_migration_name', default=None, nargs='?', + help='Migrations will be squashed starting from and including this migration.') parser.add_argument('migration_name', help='Migrations will be squashed until and including this migration.') parser.add_argument('--no-optimize', action='store_true', dest='no_optimize', default=False, @@ -28,6 +30,7 @@ class Command(BaseCommand): self.verbosity = options.get('verbosity') self.interactive = options.get('interactive') app_label = options['app_label'] + start_migration_name = options['start_migration_name'] migration_name = options['migration_name'] no_optimize = options['no_optimize'] @@ -38,18 +41,8 @@ class Command(BaseCommand): "App '%s' does not have migrations (so squashmigrations on " "it makes no sense)" % app_label ) - try: - migration = 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." % (migration_name, app_label) - ) - except KeyError: - raise CommandError( - "Cannot find a migration matching '%s' from app '%s'." % - (migration_name, app_label) - ) + + migration = self.find_migration(loader, app_label, migration_name) # Work out the list of predecessor migrations migrations_to_squash = [ @@ -58,6 +51,21 @@ class Command(BaseCommand): if al == migration.app_label ] + if start_migration_name: + start_migration = self.find_migration(loader, app_label, start_migration_name) + start = loader.get_migration(start_migration.app_label, start_migration.name) + try: + start_index = migrations_to_squash.index(start) + migrations_to_squash = migrations_to_squash[start_index:] + except ValueError: + raise CommandError( + "The migration '%s' cannot be found. Maybe it comes after " + "the migration '%s'?\n" + "Have a look at:\n" + " python manage.py showmigrations %s\n" + "to debug this issue." % (start_migration, 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:")) @@ -81,6 +89,9 @@ class Command(BaseCommand): # double-squashing operations = [] dependencies = set() + # We need to take all dependencies from the first migration in the list + # as it may be 0002 depending on 0001 + first_migration = True for smigration in migrations_to_squash: if smigration.replaces: raise CommandError( @@ -95,8 +106,9 @@ class Command(BaseCommand): dependencies.add(("__setting__", "AUTH_USER_MODEL")) else: dependencies.add(dependency) - elif dependency[0] != smigration.app_label: + elif dependency[0] != smigration.app_label or first_migration: dependencies.add(dependency) + first_migration = False if no_optimize: if self.verbosity > 0: @@ -132,9 +144,12 @@ class Command(BaseCommand): "dependencies": dependencies, "operations": new_operations, "replaces": replaces, - "initial": True, }) - new_migration = subclass("0001_squashed_%s" % migration.name, app_label) + if start_migration_name: + new_migration = subclass("%s_squashed_%s" % (start_migration.name, migration.name), app_label) + else: + new_migration = subclass("0001_squashed_%s" % migration.name, app_label) + new_migration.initial = True # Write out the new migration file writer = MigrationWriter(new_migration) @@ -152,3 +167,17 @@ class Command(BaseCommand): self.stdout.write(" Your migrations contained functions that must be manually copied over,") self.stdout.write(" as we could not safely copy their implementation.") self.stdout.write(" See the comment at the top of the squashed migration for details.") + + def find_migration(self, loader, app_label, name): + try: + return loader.get_migration_by_prefix(app_label, name) + except AmbiguityError: + raise CommandError( + "More than one migration matches '%s' in app '%s'. Please be " + "more specific." % (name, app_label) + ) + except KeyError: + raise CommandError( + "Cannot find a migration matching '%s' from app '%s'." % + (name, app_label) + ) diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 02ab4f0614..18e8ec1ca0 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1049,8 +1049,8 @@ of sync with its automatically incremented field data. The :djadminopt:`--database` option can be used to specify the database for which to print the SQL. -squashmigrations ---------------------------------------------- +squashmigrations [] +---------------------------------------------------------------------- .. django-admin:: squashmigrations @@ -1059,6 +1059,13 @@ down into fewer migrations, if possible. The resulting squashed migrations can live alongside the unsquashed ones safely. For more information, please read :ref:`migration-squashing`. +.. versionadded:: 1.9 + +When ``start_migration_name`` is given, Django will only include migrations +starting from and including this migration. This helps to mitigate the +squashing limitation of :class:`~django.db.migrations.operations.RunPython` and +:class:`django.db.migrations.operations.RunSQL` migration operations. + .. django-admin-option:: --no-optimize By default, Django will try to optimize the operations in your migrations diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index 58522e75fa..6bacdb1ae2 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -473,6 +473,9 @@ Migrations applied and others are being unapplied. This was never officially supported and never had a public API that supports this behavior. +* The :djadmin:`squashmigrations` command now supports specifying the starting + migration from which migrations will be squashed. + Models ^^^^^^ diff --git a/docs/topics/migrations.txt b/docs/topics/migrations.txt index 4fe3b5d329..57b6c3216a 100644 --- a/docs/topics/migrations.txt +++ b/docs/topics/migrations.txt @@ -572,7 +572,7 @@ possible depends on how closely intertwined your models are and if you have any :class:`~django.db.migrations.operations.RunSQL` or :class:`~django.db.migrations.operations.RunPython` operations (which can't be optimized through) - Django will then write it back out into a new set of -initial migration files. +migration files. These files are marked to say they replace the previously-squashed migrations, so they can coexist with the old migration files, and Django will intelligently diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py index 129a40dd2f..a0b7939a24 100644 --- a/tests/migrations/test_commands.py +++ b/tests/migrations/test_commands.py @@ -1070,3 +1070,34 @@ class SquashMigrationsTests(MigrationTestBase): call_command("squashmigrations", "migrations", "0002", interactive=False, verbosity=1, no_optimize=True, stdout=out) self.assertIn("Skipping optimization", force_text(out.getvalue())) + + def test_squashmigrations_valid_start(self): + """ + squashmigrations accepts a starting migration. + """ + out = six.StringIO() + with self.temporary_migration_module(module="migrations.test_migrations_no_changes") as migration_dir: + call_command("squashmigrations", "migrations", "0002", "0003", + interactive=False, verbosity=1, stdout=out) + + squashed_migration_file = os.path.join(migration_dir, "0002_second_squashed_0003_third.py") + with codecs.open(squashed_migration_file, "r", encoding="utf-8") as fp: + content = fp.read() + self.assertIn(" ('migrations', '0001_initial')", content) + self.assertNotIn("initial = True", content) + out = force_text(out.getvalue()) + self.assertNotIn(" - 0001_initial", out) + self.assertIn(" - 0002_second", out) + self.assertIn(" - 0003_third", out) + + def test_squashmigrations_invalid_start(self): + """ + squashmigrations doesn't accept a starting migration after the ending migration. + """ + with self.temporary_migration_module(module="migrations.test_migrations_no_changes"): + msg = ( + "The migration 'migrations.0003_third' cannot be found. Maybe " + "it comes after the migration 'migrations.0002_second'" + ) + with self.assertRaisesMessage(CommandError, msg): + call_command("squashmigrations", "migrations", "0003", "0002", interactive=False, verbosity=0)