Fixed #26760 -- Added --prune option to migrate command.
This commit is contained in:
parent
eeff1787b0
commit
2d8232fa71
|
@ -67,6 +67,10 @@ class Command(BaseCommand):
|
||||||
'--check', action='store_true', dest='check_unapplied',
|
'--check', action='store_true', dest='check_unapplied',
|
||||||
help='Exits with a non-zero status if unapplied migrations exist.',
|
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
|
@no_translations
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
@ -156,6 +160,52 @@ class Command(BaseCommand):
|
||||||
else:
|
else:
|
||||||
targets = executor.loader.graph.leaf_nodes()
|
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)
|
plan = executor.migration_plan(targets)
|
||||||
exit_dry = plan and options['check_unapplied']
|
exit_dry = plan and options['check_unapplied']
|
||||||
|
|
||||||
|
@ -174,6 +224,8 @@ class Command(BaseCommand):
|
||||||
return
|
return
|
||||||
if exit_dry:
|
if exit_dry:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
if options['prune']:
|
||||||
|
return
|
||||||
|
|
||||||
# At this point, ignore run_syncdb if there aren't any apps to sync.
|
# At this point, ignore run_syncdb if there aren't any apps to sync.
|
||||||
run_syncdb = options['run_syncdb'] and executor.loader.unmigrated_apps
|
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
|
Makes ``migrate`` exit with a non-zero status when unapplied migrations are
|
||||||
detected.
|
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``
|
``runserver``
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
|
|
@ -215,6 +215,9 @@ Management Commands
|
||||||
input prompts to ``stderr``, writing only paths of generated migration files
|
input prompts to ``stderr``, writing only paths of generated migration files
|
||||||
to ``stdout``.
|
to ``stdout``.
|
||||||
|
|
||||||
|
* The new :option:`migrate --prune` option allows deleting nonexistent
|
||||||
|
migrations from the ``django_migrations`` table.
|
||||||
|
|
||||||
Migrations
|
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
|
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.
|
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:
|
.. _migration-serializing:
|
||||||
|
|
||||||
|
|
|
@ -1043,6 +1043,92 @@ class MigrateTests(MigrationTestBase):
|
||||||
call_command('migrate', 'migrated_app', 'zero', verbosity=0)
|
call_command('migrate', 'migrated_app', 'zero', verbosity=0)
|
||||||
call_command('migrate', 'migrated_unapplied_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):
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
Loading…
Reference in New Issue