summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Walls <jacobtylerwalls@gmail.com>2021-12-24 18:37:22 -0500
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2022-01-21 17:10:31 +0100
commit2d8232fa716f5fe46eec9f35a0e90c2d0f126279 (patch)
tree1b600a3c218b92db5077ede96ea86ca574b8a428
parenteeff1787b0aa23016e4844c0f537d5093a95a356 (diff)
Fixed #26760 -- Added --prune option to migrate command.
-rw-r--r--django/core/management/commands/migrate.py52
-rw-r--r--docs/ref/django-admin.txt8
-rw-r--r--docs/releases/4.1.txt3
-rw-r--r--docs/topics/migrations.txt7
-rw-r--r--tests/migrations/test_commands.py86
-rw-r--r--tests/migrations/test_migrations_squashed_no_replaces/0001_squashed_0002.py21
-rw-r--r--tests/migrations/test_migrations_squashed_no_replaces/__init__.py0
7 files changed, 177 insertions, 0 deletions
diff --git a/django/core/management/commands/migrate.py b/django/core/management/commands/migrate.py
index 34fcb9bc58..a4ad1f3e20 100644
--- a/django/core/management/commands/migrate.py
+++ b/django/core/management/commands/migrate.py
@@ -67,6 +67,10 @@ class Command(BaseCommand):
'--check', action='store_true', dest='check_unapplied',
help='Exits with a non-zero status if unapplied migrations exist.',
)
+ parser.add_argument(
+ '--prune', action='store_true', dest='prune',
+ help='Delete nonexistent migrations from the django_migrations table.',
+ )
@no_translations
def handle(self, *args, **options):
@@ -156,6 +160,52 @@ class Command(BaseCommand):
else:
targets = executor.loader.graph.leaf_nodes()
+ if options['prune']:
+ if not options['app_label']:
+ raise CommandError(
+ 'Migrations can be pruned only when an app is specified.'
+ )
+ if self.verbosity > 0:
+ self.stdout.write('Pruning migrations:', self.style.MIGRATE_HEADING)
+ to_prune = set(executor.loader.applied_migrations) - set(executor.loader.disk_migrations)
+ squashed_migrations_with_deleted_replaced_migrations = [
+ migration_key
+ for migration_key, migration_obj in executor.loader.replacements.items()
+ if any(replaced in to_prune for replaced in migration_obj.replaces)
+ ]
+ if squashed_migrations_with_deleted_replaced_migrations:
+ self.stdout.write(self.style.NOTICE(
+ " Cannot use --prune because the following squashed "
+ "migrations have their 'replaces' attributes and may not "
+ "be recorded as applied:"
+ ))
+ for migration in squashed_migrations_with_deleted_replaced_migrations:
+ app, name = migration
+ self.stdout.write(f' {app}.{name}')
+ self.stdout.write(self.style.NOTICE(
+ " Re-run 'manage.py migrate' if they are not marked as "
+ "applied, and remove 'replaces' attributes in their "
+ "Migration classes."
+ ))
+ else:
+ to_prune = sorted(
+ migration
+ for migration in to_prune
+ if migration[0] == app_label
+ )
+ if to_prune:
+ for migration in to_prune:
+ app, name = migration
+ if self.verbosity > 0:
+ self.stdout.write(self.style.MIGRATE_LABEL(
+ f' Pruning {app}.{name}'
+ ), ending='')
+ executor.recorder.record_unapplied(app, name)
+ if self.verbosity > 0:
+ self.stdout.write(self.style.SUCCESS(' OK'))
+ elif self.verbosity > 0:
+ self.stdout.write(' No migrations to prune.')
+
plan = executor.migration_plan(targets)
exit_dry = plan and options['check_unapplied']
@@ -174,6 +224,8 @@ class Command(BaseCommand):
return
if exit_dry:
sys.exit(1)
+ if options['prune']:
+ return
# At this point, ignore run_syncdb if there aren't any apps to sync.
run_syncdb = options['run_syncdb'] and executor.loader.unmigrated_apps
diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt
index 78770fbf72..a60c538331 100644
--- a/docs/ref/django-admin.txt
+++ b/docs/ref/django-admin.txt
@@ -908,6 +908,14 @@ content types.
Makes ``migrate`` exit with a non-zero status when unapplied migrations are
detected.
+.. django-admin-option:: --prune
+
+.. versionadded:: 4.1
+
+Deletes nonexistent migrations from the ``django_migrations`` table. This is
+useful when migration files replaced by a squashed migration have been removed.
+See :ref:`migration-squashing` for more details.
+
``runserver``
-------------
diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt
index c2a13a86b4..8a531039ef 100644
--- a/docs/releases/4.1.txt
+++ b/docs/releases/4.1.txt
@@ -215,6 +215,9 @@ Management Commands
input prompts to ``stderr``, writing only paths of generated migration files
to ``stdout``.
+* The new :option:`migrate --prune` option allows deleting nonexistent
+ migrations from the ``django_migrations`` table.
+
Migrations
~~~~~~~~~~
diff --git a/docs/topics/migrations.txt b/docs/topics/migrations.txt
index d7daa69281..73dec300e0 100644
--- a/docs/topics/migrations.txt
+++ b/docs/topics/migrations.txt
@@ -715,6 +715,13 @@ You must then transition the squashed migration to a normal migration by:
Once you've squashed a migration, you should not then re-squash that squashed
migration until you have fully transitioned it to a normal migration.
+.. admonition:: Pruning references to deleted migrations
+
+ .. versionadded:: 4.1
+
+ If it is likely that you may reuse the name of a deleted migration in the
+ future, you should remove references to it from Django’s migrations table
+ with the :option:`migrate --prune` option.
.. _migration-serializing:
diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py
index f274c8486b..5709372cbc 100644
--- a/tests/migrations/test_commands.py
+++ b/tests/migrations/test_commands.py
@@ -1043,6 +1043,92 @@ class MigrateTests(MigrationTestBase):
call_command('migrate', 'migrated_app', 'zero', verbosity=0)
call_command('migrate', 'migrated_unapplied_app', 'zero', verbosity=0)
+ @override_settings(MIGRATION_MODULES={
+ 'migrations': 'migrations.test_migrations_squashed_no_replaces',
+ })
+ def test_migrate_prune(self):
+ """
+ With prune=True, references to migration files deleted from the
+ migrations module (such as after being squashed) are removed from the
+ django_migrations table.
+ """
+ recorder = MigrationRecorder(connection)
+ recorder.record_applied('migrations', '0001_initial')
+ recorder.record_applied('migrations', '0002_second')
+ recorder.record_applied('migrations', '0001_squashed_0002')
+ out = io.StringIO()
+ try:
+ call_command('migrate', 'migrations', prune=True, stdout=out, no_color=True)
+ self.assertEqual(
+ out.getvalue(),
+ 'Pruning migrations:\n'
+ ' Pruning migrations.0001_initial OK\n'
+ ' Pruning migrations.0002_second OK\n',
+ )
+ applied_migrations = [
+ migration
+ for migration in recorder.applied_migrations()
+ if migration[0] == 'migrations'
+ ]
+ self.assertEqual(applied_migrations, [('migrations', '0001_squashed_0002')])
+ finally:
+ recorder.record_unapplied('migrations', '0001_initial')
+ recorder.record_unapplied('migrations', '0001_second')
+ recorder.record_unapplied('migrations', '0001_squashed_0002')
+
+ @override_settings(MIGRATION_MODULES={'migrations': 'migrations.test_migrations_squashed'})
+ def test_prune_deleted_squashed_migrations_in_replaces(self):
+ out = io.StringIO()
+ with self.temporary_migration_module(
+ module='migrations.test_migrations_squashed'
+ ) as migration_dir:
+ try:
+ call_command('migrate', 'migrations', verbosity=0)
+ # Delete the replaced migrations.
+ os.remove(os.path.join(migration_dir, '0001_initial.py'))
+ os.remove(os.path.join(migration_dir, '0002_second.py'))
+ # --prune cannot be used before removing the "replaces"
+ # attribute.
+ call_command(
+ 'migrate', 'migrations', prune=True, stdout=out, no_color=True,
+ )
+ self.assertEqual(
+ out.getvalue(),
+ "Pruning migrations:\n"
+ " Cannot use --prune because the following squashed "
+ "migrations have their 'replaces' attributes and may not "
+ "be recorded as applied:\n"
+ " migrations.0001_squashed_0002\n"
+ " Re-run 'manage.py migrate' if they are not marked as "
+ "applied, and remove 'replaces' attributes in their "
+ "Migration classes.\n"
+ )
+ finally:
+ # Unmigrate everything.
+ call_command('migrate', 'migrations', 'zero', verbosity=0)
+
+ @override_settings(
+ MIGRATION_MODULES={'migrations': 'migrations.test_migrations_squashed'}
+ )
+ def test_prune_no_migrations_to_prune(self):
+ out = io.StringIO()
+ call_command('migrate', 'migrations', prune=True, stdout=out, no_color=True)
+ self.assertEqual(
+ out.getvalue(),
+ 'Pruning migrations:\n'
+ ' No migrations to prune.\n',
+ )
+ out = io.StringIO()
+ call_command(
+ 'migrate', 'migrations', prune=True, stdout=out, no_color=True, verbosity=0,
+ )
+ self.assertEqual(out.getvalue(), '')
+
+ def test_prune_no_app_label(self):
+ msg = 'Migrations can be pruned only when an app is specified.'
+ with self.assertRaisesMessage(CommandError, msg):
+ call_command('migrate', prune=True)
+
class MakeMigrationsTests(MigrationTestBase):
"""
diff --git a/tests/migrations/test_migrations_squashed_no_replaces/0001_squashed_0002.py b/tests/migrations/test_migrations_squashed_no_replaces/0001_squashed_0002.py
new file mode 100644
index 0000000000..b261b859a1
--- /dev/null
+++ b/tests/migrations/test_migrations_squashed_no_replaces/0001_squashed_0002.py
@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ operations = [
+ migrations.CreateModel(
+ "Author",
+ [
+ ("id", models.AutoField(primary_key=True)),
+ ("name", models.CharField(max_length=255)),
+ ],
+ ),
+ migrations.CreateModel(
+ "Book",
+ [
+ ("id", models.AutoField(primary_key=True)),
+ ("author", models.ForeignKey("migrations.Author", models.SET_NULL, null=True)),
+ ],
+ ),
+ ]
diff --git a/tests/migrations/test_migrations_squashed_no_replaces/__init__.py b/tests/migrations/test_migrations_squashed_no_replaces/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/tests/migrations/test_migrations_squashed_no_replaces/__init__.py