Skip to content

Commit e45b496

Browse files
authored
fix: use backwards_plan to build exact rollback plan in downgrade() (#50)
* fix: use backwards_plan to build exact rollback plan in downgrade() The previous implementation targeted same_app_parents[0] as the migration target, which caused Django's executor to find all same-app children of that parent and roll them all back. In a merge-migration graph: 0001 -> 0002a -> 0003_merge -> 0002b / Calling downgrade('myapp', '0002b') would target 0001, causing Django to roll back 0003_merge + 0002a + 0002b — leaving only 0001 applied. 0002a should have been left untouched. Fix: use graph.backwards_plan(key) to compute the exact set of migrations to unapply (the target + anything that depends on it), then pass the result directly to executor.migrate([], plan=...). This rolls back only what is necessary and leaves sibling branches intact. Fixes #45 * ci: skip unchanged jobs using dorny/paths-filter Add a changes job that detects which paths were modified. Each job now only runs when relevant files change: - lint, test (unit): pytest_mrt/** or pyproject.toml - test-django: above + pytest_mrt/adapters/django*.py or tests/test_django*.py - test-postgres/mysql/oracle/mssql: above + their respective test files - ci.yml changes trigger all jobs Docs-only and README changes no longer trigger any CI jobs. * style: ruff format django_runner.py
1 parent 9df7aff commit e45b496

3 files changed

Lines changed: 147 additions & 33 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,42 @@ on:
77
branches: [main]
88

99
jobs:
10+
changes:
11+
runs-on: ubuntu-latest
12+
outputs:
13+
src: ${{ steps.filter.outputs.src }}
14+
django: ${{ steps.filter.outputs.django }}
15+
postgres: ${{ steps.filter.outputs.postgres }}
16+
mysql: ${{ steps.filter.outputs.mysql }}
17+
oracle: ${{ steps.filter.outputs.oracle }}
18+
mssql: ${{ steps.filter.outputs.mssql }}
19+
ci: ${{ steps.filter.outputs.ci }}
20+
steps:
21+
- uses: actions/checkout@v6
22+
- uses: dorny/paths-filter@v3
23+
id: filter
24+
with:
25+
filters: |
26+
src:
27+
- 'pytest_mrt/**'
28+
- 'pyproject.toml'
29+
django:
30+
- 'pytest_mrt/adapters/django*.py'
31+
- 'tests/test_django*.py'
32+
postgres:
33+
- 'tests/test_postgres.py'
34+
mysql:
35+
- 'tests/test_mysql.py'
36+
oracle:
37+
- 'tests/test_oracle.py'
38+
mssql:
39+
- 'tests/test_mssql.py'
40+
ci:
41+
- '.github/workflows/ci.yml'
42+
1043
lint:
44+
needs: changes
45+
if: ${{ needs.changes.outputs.src == 'true' || needs.changes.outputs.ci == 'true' }}
1146
runs-on: ubuntu-latest
1247
steps:
1348
- uses: actions/checkout@v6
@@ -29,6 +64,8 @@ jobs:
2964
run: mypy pytest_mrt/ --ignore-missing-imports --no-strict-optional
3065

3166
test:
67+
needs: changes
68+
if: ${{ needs.changes.outputs.src == 'true' || needs.changes.outputs.ci == 'true' }}
3269
runs-on: ubuntu-latest
3370
strategy:
3471
fail-fast: false
@@ -61,6 +98,8 @@ jobs:
6198
fail_ci_if_error: false
6299

63100
test-postgres:
101+
needs: changes
102+
if: ${{ needs.changes.outputs.src == 'true' || needs.changes.outputs.postgres == 'true' || needs.changes.outputs.ci == 'true' }}
64103
runs-on: ubuntu-latest
65104

66105
services:
@@ -94,6 +133,8 @@ jobs:
94133
run: coverage run -m pytest tests/test_postgres.py -v
95134

96135
test-mysql:
136+
needs: changes
137+
if: ${{ needs.changes.outputs.src == 'true' || needs.changes.outputs.mysql == 'true' || needs.changes.outputs.ci == 'true' }}
97138
runs-on: ubuntu-latest
98139

99140
services:
@@ -128,6 +169,8 @@ jobs:
128169
run: coverage run -m pytest tests/test_mysql.py -v
129170

130171
test-django:
172+
needs: changes
173+
if: ${{ needs.changes.outputs.src == 'true' || needs.changes.outputs.django == 'true' || needs.changes.outputs.ci == 'true' }}
131174
runs-on: ubuntu-latest
132175
strategy:
133176
fail-fast: false
@@ -148,6 +191,8 @@ jobs:
148191
run: pytest tests/test_django_dynamic.py -v
149192

150193
test-oracle:
194+
needs: changes
195+
if: ${{ needs.changes.outputs.src == 'true' || needs.changes.outputs.oracle == 'true' || needs.changes.outputs.ci == 'true' }}
151196
runs-on: ubuntu-latest
152197

153198
services:
@@ -181,6 +226,8 @@ jobs:
181226
run: coverage run -m pytest tests/test_oracle.py -v
182227

183228
test-mssql:
229+
needs: changes
230+
if: ${{ needs.changes.outputs.src == 'true' || needs.changes.outputs.mssql == 'true' || needs.changes.outputs.ci == 'true' }}
184231
runs-on: ubuntu-latest
185232

186233
services:

pytest_mrt/adapters/django_runner.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,25 +157,29 @@ def upgrade(self, app_label: str, migration_name: str) -> None:
157157
executor.migrate([(app_label, migration_name)])
158158

159159
def downgrade(self, app_label: str, migration_name: str) -> None:
160-
"""Roll back to just before migration_name within app_label."""
160+
"""Roll back to just before migration_name within app_label.
161+
162+
Uses backwards_plan() to build an exact rollback plan so that only
163+
the target migration and its dependents are unapplied. This correctly
164+
handles branch migrations in a merge-migration graph: sibling branches
165+
that do not depend on the target are left untouched.
166+
"""
161167
executor = self._executor()
162168
loader = executor.loader
163169
key = (app_label, migration_name)
164170
graph = loader.graph
165171

166-
# node_map holds MigrationNode objects which have .parents/.children
167-
node = graph.node_map.get(key)
168-
if node is None:
172+
if graph.node_map.get(key) is None:
169173
raise KeyError(f"Migration not found: {app_label}/{migration_name}")
170174

171-
same_app_parents = [p.key for p in node.parents if p.key[0] == app_label]
172-
173-
if same_app_parents:
174-
target = [same_app_parents[0]]
175-
else:
176-
target = [(app_label, None)]
175+
# backwards_plan(key) returns [key] + all migrations that depend on it,
176+
# in the order they must be unapplied. Filter to only those currently
177+
# applied so we never try to unapply something that was already rolled back.
178+
applied = set(loader.applied_migrations.keys())
179+
plan = [(loader.graph.nodes[m], True) for m in graph.backwards_plan(key) if m in applied]
177180

178-
executor.migrate(target)
181+
if plan:
182+
executor.migrate([], plan=plan)
179183

180184
def downgrade_app_zero(self, app_label: str) -> None:
181185
executor = self._executor()

tests/test_django_runner_unit.py

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -225,37 +225,100 @@ def test_runner_upgrade(runner):
225225
mock_exec.migrate.assert_called_once_with([("myapp", "0001_initial")])
226226

227227

228-
def test_runner_downgrade_to_parent(runner):
229-
"""downgrade() targets the first same-app parent."""
230-
mock_parent = mock.MagicMock()
231-
mock_parent.key = ("myapp", "0000_squashed")
228+
def _setup_downgrade_mock(runner, *, backwards_plan_keys, applied_keys):
229+
"""
230+
Wire up a mock executor for downgrade() tests.
231+
232+
backwards_plan_keys: list of (app, name) tuples returned by graph.backwards_plan()
233+
applied_keys: set of (app, name) tuples in loader.applied_migrations
234+
Returns the mock executor.
235+
"""
236+
mock_exec = mock.MagicMock()
237+
mock_exec.loader.applied_migrations = {k: mock.MagicMock() for k in applied_keys}
238+
239+
# graph.node_map.get returns a non-None sentinel so KeyError is not raised
240+
mock_exec.loader.graph.node_map.get.return_value = mock.MagicMock()
232241

233-
mock_exec = _attach_executor(runner, node_parent=mock_parent)
234-
mock_exec.loader.graph.node_map.get.return_value.parents = [mock_parent]
242+
# graph.nodes[key] returns a distinct mock per key
243+
node_mocks = {k: mock.MagicMock() for k in backwards_plan_keys}
244+
mock_exec.loader.graph.nodes.__getitem__ = lambda _, k: node_mocks[k]
245+
mock_exec.loader.graph.backwards_plan.return_value = backwards_plan_keys
235246

236-
runner.downgrade("myapp", "0001_initial")
237-
mock_exec.migrate.assert_called_once_with([("myapp", "0000_squashed")])
247+
runner._executor = mock.MagicMock(return_value=mock_exec)
248+
return mock_exec, node_mocks
238249

239250

240-
def test_runner_downgrade_to_zero_when_no_same_app_parent(runner):
241-
"""downgrade() targets (app, None) when parent is from a different app."""
242-
mock_parent = mock.MagicMock()
243-
mock_parent.key = ("otherapp", "0001_dep")
251+
def test_runner_downgrade_single_migration(runner):
252+
"""downgrade() rolls back only the target when it has no dependents."""
253+
key = ("myapp", "0002_add_field")
254+
mock_exec, node_mocks = _setup_downgrade_mock(
255+
runner,
256+
backwards_plan_keys=[key],
257+
applied_keys={("myapp", "0001_initial"), key},
258+
)
244259

245-
mock_exec = _attach_executor(runner, node_parent=mock_parent)
246-
mock_exec.loader.graph.node_map.get.return_value.parents = [mock_parent]
260+
runner.downgrade("myapp", "0002_add_field")
247261

248-
runner.downgrade("myapp", "0001_initial")
249-
mock_exec.migrate.assert_called_once_with([("myapp", None)])
262+
mock_exec.migrate.assert_called_once_with([], plan=[(node_mocks[key], True)])
250263

251264

252-
def test_runner_downgrade_no_parents_at_all(runner):
253-
"""downgrade() targets (app, None) when the migration has no parents."""
254-
mock_exec = _attach_executor(runner) # node_parent=None → parents=[]
255-
mock_exec.loader.graph.node_map.get.return_value.parents = []
265+
def test_runner_downgrade_branch_with_merge_child(runner):
266+
"""downgrade() rolls back the merge child before the branch migration.
256267
257-
runner.downgrade("myapp", "0001_initial")
258-
mock_exec.migrate.assert_called_once_with([("myapp", None)])
268+
Graph: 0001 -> 0002a -> 0003_merge
269+
-> 0002b /
270+
271+
downgrade("myapp", "0002b") must roll back 0003_merge first, then 0002b,
272+
leaving 0002a untouched.
273+
"""
274+
key_0002b = ("myapp", "0002b")
275+
key_0003 = ("myapp", "0003_merge")
276+
applied = {("myapp", "0001_initial"), ("myapp", "0002a"), key_0002b, key_0003}
277+
278+
mock_exec, node_mocks = _setup_downgrade_mock(
279+
runner,
280+
backwards_plan_keys=[key_0003, key_0002b], # Django order: dependents first
281+
applied_keys=applied,
282+
)
283+
284+
runner.downgrade("myapp", "0002b")
285+
286+
mock_exec.migrate.assert_called_once_with(
287+
[], plan=[(node_mocks[key_0003], True), (node_mocks[key_0002b], True)]
288+
)
289+
290+
291+
def test_runner_downgrade_skips_already_unapplied(runner):
292+
"""downgrade() omits migrations not in applied_migrations from the plan."""
293+
key = ("myapp", "0002_add_field")
294+
# 0002 is NOT in applied_keys — already rolled back
295+
mock_exec, _ = _setup_downgrade_mock(
296+
runner,
297+
backwards_plan_keys=[key],
298+
applied_keys={("myapp", "0001_initial")},
299+
)
300+
301+
runner.downgrade("myapp", "0002_add_field")
302+
303+
mock_exec.migrate.assert_not_called()
304+
305+
306+
def test_runner_downgrade_merge_migration(runner):
307+
"""downgrade() on a merge migration rolls back only the merge node."""
308+
key_0002a = ("myapp", "0002a")
309+
key_0002b = ("myapp", "0002b")
310+
key_0003 = ("myapp", "0003_merge")
311+
applied = {("myapp", "0001_initial"), key_0002a, key_0002b, key_0003}
312+
313+
mock_exec, node_mocks = _setup_downgrade_mock(
314+
runner,
315+
backwards_plan_keys=[key_0003],
316+
applied_keys=applied,
317+
)
318+
319+
runner.downgrade("myapp", "0003_merge")
320+
321+
mock_exec.migrate.assert_called_once_with([], plan=[(node_mocks[key_0003], True)])
259322

260323

261324
def test_runner_downgrade_migration_not_found(runner):

0 commit comments

Comments
 (0)