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