Browse Source

Fixed #26760 -- Added --prune option to migrate command.

Jacob Walls 3 years ago
parent
commit
2d8232fa71

+ 52 - 0
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

+ 8 - 0
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``
 -------------
 

+ 3 - 0
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
 ~~~~~~~~~~
 

+ 7 - 0
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:
 

+ 86 - 0
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):
     """

+ 21 - 0
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)),
+            ],
+        ),
+    ]

+ 0 - 0
tests/migrations/test_migrations_squashed_no_replaces/__init__.py