1
0
mirror of https://github.com/django/django.git synced 2025-04-08 07:26:41 +00:00

Fixed #24529 -- Allowed double squashing of migrations.

Co-authored-by: Raphael Gaschignard <raphael@rtpg.co>
This commit is contained in:
Georgi Yanchev 2025-01-20 09:12:09 +01:00 committed by Sarah Boyce
parent 322785b08c
commit 64b1ac7292
14 changed files with 291 additions and 22 deletions

View File

@ -5,12 +5,11 @@ from django.apps import apps
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.core.management.utils import run_formatters
from django.db import DEFAULT_DB_ALIAS, connections, migrations
from django.db import migrations
from django.db.migrations.loader import AmbiguityError, MigrationLoader
from django.db.migrations.migration import SwappableTuple
from django.db.migrations.optimizer import MigrationOptimizer
from django.db.migrations.writer import MigrationWriter
from django.utils.version import get_docs_version
class Command(BaseCommand):
@ -75,7 +74,7 @@ class Command(BaseCommand):
raise CommandError(str(err))
# Load the current graph state, check the app and migration they asked
# for exists.
loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])
loader = MigrationLoader(None)
if app_label not in loader.migrated_apps:
raise CommandError(
"App '%s' does not have migrations (so squashmigrations on "
@ -141,12 +140,6 @@ class Command(BaseCommand):
# as it may be 0002 depending on 0001
first_migration = True
for smigration in migrations_to_squash:
if smigration.replaces:
raise CommandError(
"You cannot squash squashed migrations! Please transition it to a "
"normal migration first: https://docs.djangoproject.com/en/%s/"
"topics/migrations/#squashing-migrations" % get_docs_version()
)
operations.extend(smigration.operations)
for dependency in smigration.dependencies:
if isinstance(dependency, SwappableTuple):
@ -180,14 +173,7 @@ class Command(BaseCommand):
% (len(operations), len(new_operations))
)
# Work out the value of replaces (any squashed ones we're re-squashing)
# need to feed their replaces into ours
replaces = []
for migration in migrations_to_squash:
if migration.replaces:
replaces.extend(migration.replaces)
else:
replaces.append((migration.app_label, migration.name))
replaces = [(m.app_label, m.name) for m in migrations_to_squash]
# Make a new migration with those operations
subclass = type(

View File

@ -4,6 +4,7 @@ from importlib import import_module, reload
from django.apps import apps
from django.conf import settings
from django.core.management import CommandError
from django.db.migrations.graph import MigrationGraph
from django.db.migrations.recorder import MigrationRecorder
@ -219,11 +220,37 @@ class MigrationLoader:
if child is not None:
self.graph.add_dependency(migration, child, key, skip_validation=True)
def _resolve_replaced_migration_keys(self, migration):
resolved_keys = set()
for migration_key in set(migration.replaces):
migration_entry = self.disk_migrations.get(migration_key)
if migration_entry and migration_entry.replaces:
replace_keys = self._resolve_replaced_migration_keys(migration_entry)
resolved_keys.update(replace_keys)
else:
resolved_keys.add(migration_key)
return resolved_keys
def replace_migration(self, migration_key):
if completed_replacement := self.replacements_progress.get(migration_key, None):
return
elif completed_replacement is False:
# Called before but not finished the replacement, this means there
# is a circular dependency.
raise CommandError(
f"Cyclical squash replacement found, starting at {migration_key}"
)
self.replacements_progress[migration_key] = False
migration = self.replacements[migration_key]
# Process potential squashed migrations that the migration replaces.
for replace_migration_key in migration.replaces:
if replace_migration_key in self.replacements:
self.replace_migration(replace_migration_key)
replaced_keys = self._resolve_replaced_migration_keys(migration)
# Get applied status of each found replacement target.
applied_statuses = [
(target in self.applied_migrations) for target in migration.replaces
(target in self.applied_migrations) for target in replaced_keys
]
# The replacing migration is only marked as applied if all of its
# replacement targets are applied.
@ -241,6 +268,8 @@ class MigrationLoader:
# dependencies to it (#25945).
self.graph.remove_replacement_node(migration_key, migration.replaces)
self.replacements_progress[migration_key] = True
def build_graph(self):
"""
Build a migration dependency graph using both the disk and database.
@ -272,6 +301,7 @@ class MigrationLoader:
self.add_external_dependencies(key, migration)
# Carry out replacements where possible and if enabled.
if self.replace_migrations:
self.replacements_progress = {}
for migration_key in self.replacements.keys():
self.replace_migration(migration_key)
# Ensure the graph is consistent.

View File

@ -169,7 +169,8 @@ Management Commands
Migrations
~~~~~~~~~~
* ...
* Squashed migrations can now themselves be squashed before being transitioned
to normal migrations.
Models
~~~~~~

View File

@ -733,7 +733,7 @@ migrations it replaces and distribute this change to all running instances
of your application, making sure that they run ``migrate`` to store the change
in their database.
You must then transition the squashed migration to a normal migration by:
You can then transition the squashed migration to a normal migration by:
- Deleting all the migration files it replaces.
- Updating all migrations that depend on the deleted migrations to depend on
@ -742,8 +742,11 @@ You must then transition the squashed migration to a normal migration by:
squashed migration (this is how Django tells that it is a squashed migration).
.. note::
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.
You can squash squashed migrations themselves without transitioning to
normal migrations, which might be useful for situations where every
environment has not yet run the original squashed migration set. But in
general it is better to transition squashed migrations to normal migrations
to be able to clean up older migration files.
.. admonition:: Pruning references to deleted migrations
@ -751,6 +754,10 @@ You must then transition the squashed migration to a normal migration by:
future, you should remove references to it from Djangos migrations table
with the :option:`migrate --prune` option.
.. versionchanged:: 6.0
Support for squashing squashed migrations was added.
.. _migration-serializing:
Serializing values

View File

@ -2,6 +2,7 @@ import datetime
import importlib
import io
import os
import re
import shutil
import sys
from pathlib import Path
@ -28,7 +29,9 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.backends.utils import truncate_name
from django.db.migrations.autodetector import MigrationAutodetector
from django.db.migrations.exceptions import InconsistentMigrationHistory
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.recorder import MigrationRecorder
from django.db.migrations.writer import MigrationWriter
from django.test import TestCase, override_settings, skipUnlessDBFeature
from django.test.utils import captured_stdout, extend_sys_path, isolate_apps
from django.utils import timezone
@ -2939,6 +2942,137 @@ class SquashMigrationsTests(MigrationTestBase):
" you can delete them.\n" % squashed_migration_file,
)
def test_squashmigrations_replacement_cycle(self):
out = io.StringIO()
with self.temporary_migration_module(
module="migrations.test_migrations_squashed_loop"
):
# Hits a squash replacement cycle check error, but the actual failure is
# dependent on the order in which the files are read on disk.
with self.assertRaisesRegex(
CommandError,
r"Cyclical squash replacement found, starting at"
r" \('migrations', '2_(squashed|auto)'\)",
):
call_command(
"migrate", "migrations", "--plan", interactive=False, stdout=out
)
def test_squashmigrations_squashes_already_squashed(self):
out = io.StringIO()
with self.temporary_migration_module(
module="migrations.test_migrations_squashed_complex"
):
call_command(
"squashmigrations",
"migrations",
"3_squashed_5",
"--squashed-name",
"double_squash",
stdout=out,
interactive=False,
)
loader = MigrationLoader(connection)
migration = loader.disk_migrations[("migrations", "0001_double_squash")]
# Confirm the replaces mechanism holds the squashed migration
# (and not what it squashes, as the squash operations are what
# end up being used).
self.assertEqual(
migration.replaces,
[
("migrations", "1_auto"),
("migrations", "2_auto"),
("migrations", "3_squashed_5"),
],
)
out = io.StringIO()
call_command(
"migrate", "migrations", "--plan", interactive=False, stdout=out
)
migration_plan = re.findall("migrations.(.+)\n", out.getvalue())
self.assertEqual(migration_plan, ["0001_double_squash", "6_auto", "7_auto"])
def test_squash_partially_applied(self):
"""
Replacement migrations are partially applied. Then we squash again and
verify that only unapplied migrations will be applied by "migrate".
"""
out = io.StringIO()
with self.temporary_migration_module(
module="migrations.test_migrations_squashed_partially_applied"
):
# Apply first 2 migrations.
call_command("migrate", "migrations", "0002", interactive=False, stdout=out)
# Squash the 2 migrations, that we just applied + 1 more.
call_command(
"squashmigrations",
"migrations",
"0001",
"0003",
"--squashed-name",
"squashed_0001_0003",
stdout=out,
interactive=False,
)
# Update the 4th migration to depend on the squash(replacement) migration.
loader = MigrationLoader(connection)
migration = loader.disk_migrations[
("migrations", "0004_remove_mymodel1_field_1_mymodel1_field_3_and_more")
]
migration.dependencies = [("migrations", "0001_squashed_0001_0003")]
writer = MigrationWriter(migration)
with open(writer.path, "w", encoding="utf-8") as fh:
fh.write(writer.as_string())
# Squash the squash(replacement) migration with the 4th migration.
call_command(
"squashmigrations",
"migrations",
"0001_squashed_0001_0003",
"0004",
"--squashed-name",
"squashed_0001_0004",
stdout=out,
interactive=False,
)
loader = MigrationLoader(connection)
migration = loader.disk_migrations[
("migrations", "0001_squashed_0001_0004")
]
self.assertEqual(
migration.replaces,
[
("migrations", "0001_squashed_0001_0003"),
(
"migrations",
"0004_remove_mymodel1_field_1_mymodel1_field_3_and_more",
),
],
)
# Verify that only unapplied migrations will be applied.
out = io.StringIO()
call_command(
"migrate", "migrations", "--plan", interactive=False, stdout=out
)
migration_plan = re.findall("migrations.(.+)\n", out.getvalue())
self.assertEqual(
migration_plan,
[
"0003_alter_mymodel2_unique_together",
"0004_remove_mymodel1_field_1_mymodel1_field_3_and_more",
],
)
def test_squashmigrations_initial_attribute(self):
with self.temporary_migration_module(
module="migrations.test_migrations"

View File

@ -0,0 +1,5 @@
from django.db import migrations
class Migration(migrations.Migration):
operations = [migrations.RunPython(migrations.RunPython.noop)]

View File

@ -0,0 +1,7 @@
from django.db import migrations
class Migration(migrations.Migration):
replaces = [("migrations", "2_squashed")]
dependencies = [("migrations", "1_auto")]
operations = [migrations.RunPython(migrations.RunPython.noop)]

View File

@ -0,0 +1,7 @@
from django.db import migrations
class Migration(migrations.Migration):
replaces = [("migrations", "2_auto")]
dependencies = [("migrations", "1_auto")]
operations = [migrations.RunPython(migrations.RunPython.noop)]

View File

@ -0,0 +1,35 @@
from django.db import migrations, models
class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name="MyModel1",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
],
),
migrations.CreateModel(
name="MyModel2",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("field_1", models.IntegerField()),
],
),
]

View File

@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("migrations", "0001_initial")]
operations = [
migrations.AddField(
model_name="mymodel1",
name="field_1",
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name="mymodel2",
name="field_2",
field=models.IntegerField(null=True),
),
migrations.AlterField(
model_name="mymodel2",
name="field_1",
field=models.IntegerField(null=True),
),
]

View File

@ -0,0 +1,12 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("migrations", "0002_mymodel1_field_1_mymodel2_field_2_and_more")]
operations = [
migrations.AlterUniqueTogether(
name="mymodel2",
unique_together={("field_1", "field_2")},
),
]

View File

@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("migrations", "0003_alter_mymodel2_unique_together")]
operations = [
migrations.RemoveField(
model_name="mymodel1",
name="field_1",
),
migrations.AddField(
model_name="mymodel1",
name="field_3",
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name="mymodel1",
name="field_4",
field=models.IntegerField(null=True),
),
]