mirror of
				https://github.com/django/django.git
				synced 2025-10-31 09:41:08 +00:00 
			
		
		
		
	Fixed #26760 -- Added --prune option to migrate command.
This commit is contained in:
		
				
					committed by
					
						 Mariusz Felisiak
						Mariusz Felisiak
					
				
			
			
				
	
			
			
			
						parent
						
							eeff1787b0
						
					
				
				
					commit
					2d8232fa71
				
			| @@ -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 | ||||
|   | ||||
| @@ -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`` | ||||
| ------------- | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| ~~~~~~~~~~ | ||||
|  | ||||
|   | ||||
| @@ -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: | ||||
|  | ||||
|   | ||||
| @@ -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): | ||||
|     """ | ||||
|   | ||||
| @@ -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)), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
		Reference in New Issue
	
	Block a user