Skip to content

Commit 0a7a82e

Browse files
committed
feat: use patched django class to replace all migrations
1 parent 016b1f3 commit 0a7a82e

File tree

5 files changed

+301
-9
lines changed

5 files changed

+301
-9
lines changed

django_replace_migrations/management/commands/makemigrations.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
from django.db.migrations.utils import get_migration_name_timestamp
2323
from django.db.migrations.writer import MigrationWriter
2424

25+
from .replace_migration_autodetector import ReplaceMigrationAutodetector
26+
from .replace_migration_loader import ReplaceMigrationLoader
27+
from .replace_migration_writer import ReplaceMigrationWriter
28+
2529

2630
class Command(BaseCommand):
2731
help = "Creates new migration(s) for apps."
@@ -143,7 +147,10 @@ def handle(self, *app_labels, **options):
143147

144148
# Load the current graph state. Pass in None for the connection so
145149
# the loader doesn't try to resolve replaced migrations from DB.
146-
loader = MigrationLoader(None, ignore_no_migrations=True)
150+
if self.replace_all:
151+
loader = ReplaceMigrationLoader(None, ignore_no_migrations=True)
152+
else:
153+
loader = MigrationLoader(None, ignore_no_migrations=True)
147154

148155
# Raise an error if any migrations are applied before their dependencies.
149156
consistency_check_labels = {config.label for config in apps.get_app_configs()}
@@ -224,19 +231,19 @@ def handle(self, *app_labels, **options):
224231
k: v for (k, v) in loader.graph.nodes.items() if k[0] not in app_labels
225232
}
226233

227-
autodetector = MigrationAutodetector(
228-
loader.project_state(),
229-
ProjectState.from_apps(apps),
230-
questioner,
234+
autodetector = ReplaceMigrationAutodetector(
235+
from_state=loader.project_state(),
236+
to_state=ProjectState.from_apps(apps),
237+
questioner=questioner,
231238
)
232239

233240
loader.graph.nodes = temp_nodes
234241

235242
else:
236243
autodetector = MigrationAutodetector(
237-
loader.project_state(),
238-
ProjectState.from_apps(apps),
239-
questioner,
244+
from_state=loader.project_state(),
245+
to_state=ProjectState.from_apps(apps),
246+
questioner=questioner,
240247
)
241248

242249
# If they want to make an empty migration, make one for each app
@@ -378,7 +385,7 @@ def write_migration_files(self, changes, update_previous_migration_paths=None):
378385
self.log(self.style.MIGRATE_HEADING("Migrations for '%s':" % app_label))
379386
for migration in app_migrations:
380387
# Describe the migration
381-
writer = MigrationWriter(migration, self.include_header)
388+
writer = ReplaceMigrationWriter(migration, self.include_header)
382389
if self.verbosity >= 1:
383390
# Display a relative path if it's below the current working
384391
# directory, or an absolute path otherwise.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import re
2+
3+
from django.db.migrations.autodetector import MigrationAutodetector
4+
5+
6+
class ReplaceMigrationAutodetector(MigrationAutodetector):
7+
@classmethod
8+
def parse_number(cls, name):
9+
"""
10+
Given a migration name, try to extract a number from the beginning of
11+
it. For a squashed migration such as '0001_squashed_0004…', return the
12+
second number. If no number is found, return None.
13+
"""
14+
if squashed_match := re.search(r"(\d+)_squashed_.*", name):
15+
return int(squashed_match[1])
16+
return None
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.db.migrations.graph import MigrationGraph
2+
from django.db.migrations.state import ProjectState
3+
4+
5+
class ReplaceMigrationGraph(MigrationGraph):
6+
def make_state(self, nodes=None, at_end=True, real_apps=None):
7+
"""
8+
Given a migration node or nodes, return a complete ProjectState for it.
9+
If at_end is False, return the state before the migration has run.
10+
If nodes is not provided, return the overall most current project state.
11+
"""
12+
if nodes is None:
13+
nodes = list(self.leaf_nodes())
14+
if not nodes:
15+
return ProjectState()
16+
if not isinstance(nodes[0], tuple):
17+
nodes = [nodes]
18+
plan = self._generate_plan(nodes, at_end)
19+
project_state = ProjectState(real_apps=real_apps)
20+
for node in plan:
21+
# We have dependencies between the contrib and our migrations
22+
# if a node is not found, do not link / use it
23+
if node not in self.nodes:
24+
continue
25+
project_state = self.nodes[node].mutate_state(project_state, preserve=False)
26+
return project_state
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from django.db.migrations.exceptions import NodeNotFoundError
2+
from django.db.migrations.loader import MigrationLoader
3+
from django.db.migrations.recorder import MigrationRecorder
4+
5+
from .replace_migration_graph import ReplaceMigrationGraph
6+
7+
8+
class ReplaceMigrationLoader(MigrationLoader):
9+
def build_graph(self):
10+
"""
11+
Build a migration dependency graph using both the disk and database.
12+
You'll need to rebuild the graph if you apply migrations. This isn't
13+
usually a problem as generally migration stuff runs in a one-shot process.
14+
"""
15+
# Load disk data
16+
self.load_disk()
17+
# Load database data
18+
if self.connection is None:
19+
self.applied_migrations = {}
20+
else:
21+
recorder = MigrationRecorder(self.connection)
22+
self.applied_migrations = recorder.applied_migrations()
23+
# To start, populate the migration graph with nodes for ALL migrations
24+
# and their dependencies. Also make note of replacing migrations at this step.
25+
self.graph = ReplaceMigrationGraph()
26+
self.replacements = {}
27+
for key, migration in self.disk_migrations.items():
28+
self.graph.add_node(key, migration)
29+
# Replacing migrations.
30+
if migration.replaces:
31+
self.replacements[key] = migration
32+
for key, migration in self.disk_migrations.items():
33+
# Internal (same app) dependencies.
34+
self.add_internal_dependencies(key, migration)
35+
# Add external dependencies now that the internal ones have been resolved.
36+
for key, migration in self.disk_migrations.items():
37+
self.add_external_dependencies(key, migration)
38+
# Carry out replacements where possible and if enabled.
39+
if self.replace_migrations:
40+
for key, migration in self.replacements.items():
41+
# Get applied status of each of this migration's replacement
42+
# targets.
43+
applied_statuses = [
44+
(target in self.applied_migrations) for target in migration.replaces
45+
]
46+
# The replacing migration is only marked as applied if all of
47+
# its replacement targets are.
48+
if all(applied_statuses):
49+
self.applied_migrations[key] = migration
50+
else:
51+
self.applied_migrations.pop(key, None)
52+
# A replacing migration can be used if either all or none of
53+
# its replacement targets have been applied.
54+
if all(applied_statuses) or (not any(applied_statuses)):
55+
self.graph.remove_replaced_nodes(key, migration.replaces)
56+
else:
57+
# This replacing migration cannot be used because it is
58+
# partially applied. Remove it from the graph and remap
59+
# dependencies to it (#25945).
60+
self.graph.remove_replacement_node(key, migration.replaces)
61+
# Ensure the graph is consistent.
62+
try:
63+
self.graph.validate_consistency()
64+
except NodeNotFoundError as exc:
65+
# Check if the missing node could have been replaced by any squash
66+
# migration but wasn't because the squash migration was partially
67+
# applied before. In that case raise a more understandable exception
68+
# (#23556).
69+
# Get reverse replacements.
70+
reverse_replacements = {}
71+
for key, migration in self.replacements.items():
72+
for replaced in migration.replaces:
73+
reverse_replacements.setdefault(replaced, set()).add(key)
74+
# Try to reraise exception with more detail.
75+
if exc.node in reverse_replacements:
76+
candidates = reverse_replacements.get(exc.node, set())
77+
is_replaced = any(
78+
candidate in self.graph.nodes for candidate in candidates
79+
)
80+
if not is_replaced:
81+
tries = ", ".join(
82+
f"{key}.{migration}" for key, migration in candidates
83+
)
84+
raise NodeNotFoundError(
85+
"Migration {0} depends on nonexistent node ('{1}', '{2}'). "
86+
"Django tried to replace migration {1}.{2} with any of [{3}] "
87+
"but wasn't able to because some of the replaced migrations "
88+
"are already applied.".format(
89+
exc.origin, exc.node[0], exc.node[1], tries
90+
),
91+
exc.node,
92+
) from exc
93+
raise
94+
self.graph.ensure_not_cyclic()
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import re
2+
3+
from django import get_version
4+
from django.db.migrations.operations.special import (
5+
RunPython,
6+
RunSQL,
7+
SeparateDatabaseAndState,
8+
)
9+
from django.db.migrations.writer import MigrationWriter, OperationWriter
10+
from django.utils.timezone import now
11+
12+
13+
class ReplaceMigrationWriter(MigrationWriter):
14+
def as_string(self):
15+
"""Return a string of the file contents."""
16+
items = {
17+
"replaces_str": "",
18+
"initial_str": "",
19+
}
20+
21+
imports = set()
22+
23+
# Deconstruct operations
24+
operations = []
25+
for operation in self.migration.operations:
26+
if isinstance(operation, (RunPython, RunSQL, SeparateDatabaseAndState)):
27+
continue
28+
operation_string, operation_imports = OperationWriter(operation).serialize()
29+
imports.update(operation_imports)
30+
operations.append(operation_string)
31+
items["operations"] = "\n".join(operations) + "\n" if operations else ""
32+
33+
# Deconstruct special_operations
34+
special_operations = []
35+
for special_op in self.migration.operations:
36+
if (
37+
isinstance(special_op, (RunPython, RunSQL, SeparateDatabaseAndState))
38+
and not special_op.elidable
39+
):
40+
operation_string, operation_imports = OperationWriter(
41+
special_op
42+
).serialize()
43+
imports.update(operation_imports)
44+
special_operations.append(operation_string)
45+
special_ops = (
46+
"\n".join(special_operations) + "\n" if special_operations else None
47+
)
48+
items["special_operations"] = (
49+
"\n # /!\\ PRINT ALL THE SPECIAL OPERATIONS\n"
50+
+ " # /!\\ MUST BE MANUALLY REVIEWED\n\n"
51+
+ " special_operations = [\n"
52+
+ special_ops
53+
+ " ]\n"
54+
if special_ops
55+
else ""
56+
)
57+
58+
# Format dependencies and write out swappable dependencies right
59+
dependencies = []
60+
for dependency in self.migration.dependencies:
61+
if dependency[0] == "__setting__":
62+
dependencies.append(
63+
f" migrations.swappable_dependency(settings.{dependency[1]}),"
64+
)
65+
imports.add("from django.conf import settings")
66+
else:
67+
dependencies.append(f" {self.serialize(dependency)[0]},")
68+
items["dependencies"] = (
69+
"\n".join(sorted(dependencies)) + "\n" if dependencies else ""
70+
)
71+
72+
# Format imports nicely, swapping imports of functions from migration files
73+
# for comments
74+
migration_imports = set()
75+
for line in list(imports):
76+
if re.match(r"^import (.*)\.\d+[^\s]*$", line):
77+
migration_imports.add(line.split("import")[1].strip())
78+
imports.remove(line)
79+
self.needs_manual_porting = True
80+
81+
# django.db.migrations is always used, but models import may not be.
82+
# If models import exists, merge it with migrations import.
83+
if "from django.db import models" in imports:
84+
imports.discard("from django.db import models")
85+
imports.add("from django.db import migrations, models")
86+
else:
87+
imports.add("from django.db import migrations")
88+
89+
# Sort imports by the package / module to be imported (the part after
90+
# "from" in "from ... import ..." or after "import" in "import ...").
91+
sorted_imports = sorted(imports, key=lambda i: i.split()[1])
92+
items["imports"] = "\n".join(sorted_imports) + "\n" if imports else ""
93+
if migration_imports:
94+
items["imports"] += (
95+
"\n\n# Functions from the following migrations need manual "
96+
"copying.\n# Move them and any dependencies into this file, "
97+
"then update the\n# RunPython operations to refer to the local "
98+
"versions:\n# %s"
99+
) % "\n# ".join(sorted(migration_imports))
100+
# If there's a replaces, make a string for it
101+
if self.migration.replaces:
102+
items[
103+
"replaces_str"
104+
] = f"\n replaces = {self.serialize(sorted(self.migration.replaces))[0]}\n"
105+
# Hinting that goes into comment
106+
if self.include_header:
107+
items["migration_header"] = MIGRATION_HEADER_TEMPLATE % {
108+
"version": get_version(),
109+
"timestamp": now().strftime("%Y-%m-%d %H:%M"),
110+
}
111+
else:
112+
items["migration_header"] = ""
113+
114+
if self.migration.initial:
115+
items["initial_str"] = "\n initial = True\n"
116+
117+
return MIGRATION_TEMPLATE % items
118+
119+
120+
MIGRATION_HEADER_TEMPLATE = """\
121+
# Generated by Django %(version)s on %(timestamp)s
122+
123+
"""
124+
125+
126+
MIGRATION_TEMPLATE = """\
127+
%(migration_header)s%(imports)s
128+
129+
from phased_migrations.constants import DeployPhase
130+
131+
132+
class Migration(migrations.Migration):
133+
# Note: deploy_phase was added to ensure consistency with no down time
134+
# it is possible that this migration in not really compatible with pre-deploy
135+
deploy_phase = DeployPhase.pre_deploy
136+
137+
squashed_with_gg_script = True
138+
139+
%(replaces_str)s%(initial_str)s
140+
dependencies = [
141+
%(dependencies)s\
142+
]
143+
144+
operations = [
145+
%(operations)s\
146+
]
147+
148+
%(special_operations)s
149+
"""

0 commit comments

Comments
 (0)