Fixed #25390 -- Allowed specifying a start migration in squashmigrations

Thanks Tim Graham for the review.
This commit is contained in:
Markus Holtermann 2015-09-12 17:18:24 +10:00
parent 5aa55038ca
commit 43f2eb7ef3
5 changed files with 88 additions and 18 deletions

View File

@ -15,6 +15,8 @@ class Command(BaseCommand):
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('app_label', parser.add_argument('app_label',
help='App label of the application to squash migrations for.') 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', parser.add_argument('migration_name',
help='Migrations will be squashed until and including this migration.') help='Migrations will be squashed until and including this migration.')
parser.add_argument('--no-optimize', action='store_true', dest='no_optimize', default=False, 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.verbosity = options.get('verbosity')
self.interactive = options.get('interactive') self.interactive = options.get('interactive')
app_label = options['app_label'] app_label = options['app_label']
start_migration_name = options['start_migration_name']
migration_name = options['migration_name'] migration_name = options['migration_name']
no_optimize = options['no_optimize'] no_optimize = options['no_optimize']
@ -38,18 +41,8 @@ class Command(BaseCommand):
"App '%s' does not have migrations (so squashmigrations on " "App '%s' does not have migrations (so squashmigrations on "
"it makes no sense)" % app_label "it makes no sense)" % app_label
) )
try:
migration = loader.get_migration_by_prefix(app_label, migration_name) migration = self.find_migration(loader, 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)
)
# Work out the list of predecessor migrations # Work out the list of predecessor migrations
migrations_to_squash = [ migrations_to_squash = [
@ -58,6 +51,21 @@ class Command(BaseCommand):
if al == migration.app_label 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 # Tell them what we're doing and optionally ask if we should proceed
if self.verbosity > 0 or self.interactive: if self.verbosity > 0 or self.interactive:
self.stdout.write(self.style.MIGRATE_HEADING("Will squash the following migrations:")) self.stdout.write(self.style.MIGRATE_HEADING("Will squash the following migrations:"))
@ -81,6 +89,9 @@ class Command(BaseCommand):
# double-squashing # double-squashing
operations = [] operations = []
dependencies = set() 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: for smigration in migrations_to_squash:
if smigration.replaces: if smigration.replaces:
raise CommandError( raise CommandError(
@ -95,8 +106,9 @@ class Command(BaseCommand):
dependencies.add(("__setting__", "AUTH_USER_MODEL")) dependencies.add(("__setting__", "AUTH_USER_MODEL"))
else: else:
dependencies.add(dependency) dependencies.add(dependency)
elif dependency[0] != smigration.app_label: elif dependency[0] != smigration.app_label or first_migration:
dependencies.add(dependency) dependencies.add(dependency)
first_migration = False
if no_optimize: if no_optimize:
if self.verbosity > 0: if self.verbosity > 0:
@ -132,9 +144,12 @@ class Command(BaseCommand):
"dependencies": dependencies, "dependencies": dependencies,
"operations": new_operations, "operations": new_operations,
"replaces": replaces, "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 # Write out the new migration file
writer = MigrationWriter(new_migration) 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(" Your migrations contained functions that must be manually copied over,")
self.stdout.write(" as we could not safely copy their implementation.") 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.") 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)
)

View File

@ -1049,8 +1049,8 @@ of sync with its automatically incremented field data.
The :djadminopt:`--database` option can be used to specify the database for The :djadminopt:`--database` option can be used to specify the database for
which to print the SQL. which to print the SQL.
squashmigrations <app_label> <migration_name> squashmigrations <app_label> [<start_migration_name>] <migration_name>
--------------------------------------------- ----------------------------------------------------------------------
.. django-admin:: 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, can live alongside the unsquashed ones safely. For more information,
please read :ref:`migration-squashing`. 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 .. django-admin-option:: --no-optimize
By default, Django will try to optimize the operations in your migrations By default, Django will try to optimize the operations in your migrations

View File

@ -473,6 +473,9 @@ Migrations
applied and others are being unapplied. This was never officially supported applied and others are being unapplied. This was never officially supported
and never had a public API that supports this behavior. 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 Models
^^^^^^ ^^^^^^

View File

@ -572,7 +572,7 @@ possible depends on how closely intertwined your models are and if you have
any :class:`~django.db.migrations.operations.RunSQL` any :class:`~django.db.migrations.operations.RunSQL`
or :class:`~django.db.migrations.operations.RunPython` operations (which can't 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 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, 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 so they can coexist with the old migration files, and Django will intelligently

View File

@ -1070,3 +1070,34 @@ class SquashMigrationsTests(MigrationTestBase):
call_command("squashmigrations", "migrations", "0002", call_command("squashmigrations", "migrations", "0002",
interactive=False, verbosity=1, no_optimize=True, stdout=out) interactive=False, verbosity=1, no_optimize=True, stdout=out)
self.assertIn("Skipping optimization", force_text(out.getvalue())) 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)