Skip to content

Commit 0217b9a

Browse files
authored
feat: --check-compat Django support (v1.7.0) (#96)
Rolling-deploy compatibility checks (MRT7xx) were Alembic-only. Map Django operations to the same patterns via a new adapters/django_compat.py: RemoveField -> MRT701 (DROP COLUMN) RenameField -> MRT702 (RENAME COLUMN) DeleteModel / RenameModel / AlterModelTable -> MRT703 (DROP/RENAME TABLE) AddField NOT NULL without default -> MRT704 MRT705 (column type change) stays Alembic-only: Django's AlterField carries the full field definition with no reference to the previous type, so a type change cannot be detected statically. Wire check_compat through analyze_django_migrations; per-line noqa suppression applies via the existing central loop. Remove the 'not yet supported for Django' warning from the check command. Adds 9 tests. Also drops a few unused imports flagged by ruff in adjacent test files. Docs: patterns.md backend mapping table, api.md, README, roadmap, CHANGELOG. Bump version to 1.7.0.
1 parent ff26649 commit 0217b9a

13 files changed

Lines changed: 418 additions & 31 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
77

88
---
99

10+
## [1.7.0] — 2026-07-14
11+
12+
### Added
13+
- **`mrt check --check-compat` now supports Django migrations.** Rolling-deploy compatibility checks (MRT7xx) were previously Alembic-only. Django operations are now mapped to the same patterns: `RemoveField` → MRT701 (DROP COLUMN), `RenameField` → MRT702 (RENAME COLUMN), `DeleteModel`/`RenameModel`/`AlterModelTable` → MRT703 (DROP/RENAME TABLE), and `AddField` NOT NULL without a default → MRT704. Per-line `# noqa: MRTxxx` suppression works for these too. MRT705 (column type change) remains Alembic-only — Django's `AlterField` carries the full field with no reference to the previous type, so a type change cannot be detected statically.
14+
15+
### Removed
16+
- The "`--check-compat` is not yet supported for Django migrations" warning, now that it is supported.
17+
18+
---
19+
1020
## [1.6.0] — 2026-06-17
1121

1222
### Added

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,14 @@ Legacy syntax `# mrt: ignore` is still supported for backward compatibility.
263263

264264
The key difference from pytest-alembic: pytest-mrt seeds actual rows before each rollback and verifies they survive. A migration that reverses the schema cleanly but silently destroys data will pass pytest-alembic and fail pytest-mrt.
265265

266+
## What's new in v1.7.0
267+
268+
- **`mrt check --check-compat` now works for Django migrations.** Rolling-deploy compatibility checks (MRT7xx) were Alembic-only; Django operations now map to the same patterns — `RemoveField` (MRT701), `RenameField` (MRT702), `DeleteModel`/`RenameModel`/`AlterModelTable` (MRT703), and `AddField` NOT NULL without a default (MRT704). MRT705 (type change) stays Alembic-only.
269+
270+
```bash
271+
mrt check myapp/migrations/ --check-compat
272+
```
273+
266274
## What's new in v1.6.0
267275

268276
- **Fine-grained migration step control** — `upgrade_to()`, `upgrade_one()`, `downgrade_one()`, `downgrade_to()`, `current_revision()` let you test data migration logic at any point in the chain:

docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ mrt check alembic/versions/ --check-compat
379379
| `--since` | `None` | Only check migrations added after this revision. Alembic: revision ID. Django: `app_label.migration_name`. Graph checks (orphan, data-hole detection) are skipped when `--since` is active. |
380380
| `--min-revision` | `None` | Skip revisions at or older than this point. Alembic: revision ID. Django: `app_label.migration_name`. Mirrors `MRTConfig.minimum_downgrade_revision`. |
381381
| `--watch` / `-w` | `False` | Re-run automatically when migration files change. `--format table` only. Ctrl-C to stop. |
382-
| `--check-compat` | `False` | Also run rolling-deploy compatibility checks (MRT701–MRT705). Alembic only. |
382+
| `--check-compat` | `False` | Also run rolling-deploy compatibility checks (MRT701–MRT705). Alembic and Django (MRT705 type-change detection is Alembic-only). |
383383

384384
**Exit codes:** `0` = no findings, `1` = warnings only, `2` = one or more errors (or warnings with `--strict`)
385385

docs/patterns.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -488,25 +488,35 @@ These checks are opt-in via `mrt check --check-compat`. They flag operations tha
488488

489489
Run rollback-safety checks (`mrt check`) always. Add `--check-compat` when your deployment strategy involves rolling restarts rather than a full stop/start.
490490

491+
MRT701–MRT704 are supported for **both Alembic and Django**. The table below maps each code to the operation that triggers it in each backend. MRT705 (type change) is Alembic-only — Django's `AlterField` carries the full field definition with no reference to the previous type, so a type change cannot be detected statically.
492+
493+
| Code | Alembic | Django |
494+
|------|---------|--------|
495+
| MRT701 | `op.drop_column` | `migrations.RemoveField` |
496+
| MRT702 | `op.alter_column(new_column_name=…)` | `migrations.RenameField` |
497+
| MRT703 | `op.drop_table` | `migrations.DeleteModel` / `RenameModel` / `AlterModelTable` |
498+
| MRT704 | `op.add_column` NOT NULL w/o `server_default` | `migrations.AddField` NOT NULL w/o default |
499+
| MRT705 | `op.alter_column(type_=…)` | — (Alembic only) |
500+
491501
### MRT701 — DROP COLUMN during rolling deploy (error)
492502

493-
Dropping a column immediately breaks any old app instance that still references it via ORM or raw SQL. The fix is a two-step process:
503+
Dropping a column (Alembic `op.drop_column`, Django `RemoveField`) immediately breaks any old app instance that still references it via ORM or raw SQL. The fix is a two-step process:
494504

495505
1. Remove all code references to the column, then deploy the code change.
496506
2. Drop the column in a follow-up migration after all instances are on the new code.
497507

498508
### MRT702 — RENAME COLUMN during rolling deploy (error)
499509

500-
Renaming a column breaks old app instances referencing the old name. Use the expand-contract pattern: add the new column, backfill data, update code to use the new name, deploy, then drop the old column.
510+
Renaming a column (Alembic `alter_column(new_column_name=…)`, Django `RenameField`) breaks old app instances referencing the old name. Use the expand-contract pattern: add the new column, backfill data, update code to use the new name, deploy, then drop the old column.
501511

502512
### MRT703 — DROP TABLE during rolling deploy (error)
503513

504-
Dropping a table crashes old app instances that query it. Remove all ORM models and direct references, deploy, then drop the table.
514+
Dropping a table (Alembic `drop_table`, Django `DeleteModel`) crashes old app instances that query it. Remove all ORM models and direct references, deploy, then drop the table. For Django, `RenameModel` and `AlterModelTable` also fire MRT703 because they change the table name under the old app.
505515

506-
### MRT704 — ADD NOT NULL column without server_default (error)
516+
### MRT704 — ADD NOT NULL column without default (error)
507517

508-
Adding a NOT NULL column without a `server_default` causes INSERT failures from old app instances that don't know about the new column. Add a `server_default` to handle inserts from the old app, or make the column nullable initially.
518+
Adding a NOT NULL column without a default (Alembic `server_default`, Django `default`) causes INSERT failures from old app instances that don't know about the new column. Add a default to handle inserts from the old app, or make the column nullable initially.
509519

510-
### MRT705 — Column type change during rolling deploy (warning)
520+
### MRT705 — Column type change during rolling deploy (warning, Alembic only)
511521

512-
Changing a column's type may cause type errors in old app instances. VARCHAR widening (e.g. VARCHAR(100) -> VARCHAR(255)) is generally safe. Type narrowing, kind changes (e.g. INTEGER -> BIGINT on some databases), or precision changes are not.
522+
Changing a column's type (Alembic `alter_column(type_=…)`) may cause type errors in old app instances. VARCHAR widening (e.g. VARCHAR(100) -> VARCHAR(255)) is generally safe. Type narrowing, kind changes (e.g. INTEGER -> BIGINT on some databases), or precision changes are not. Not detectable for Django (see note above).

docs/roadmap.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ Items are tracked as GitHub issues. This page is a high-level overview.
66

77
## Shipped
88

9-
### v1.6.0 — Fine-grained step control (main, pending release)
9+
### v1.7.0 — Django rolling-deploy compatibility (main, pending release)
10+
11+
- `mrt check --check-compat` now supports Django migrations (MRT701–MRT704), mapping `RemoveField`/`RenameField`/`DeleteModel`/`RenameModel`/`AlterModelTable`/`AddField` to the same rolling-deploy patterns as Alembic. MRT705 (type change) remains Alembic-only.
12+
13+
### v1.6.0 — Fine-grained step control
1014

1115
- `upgrade_to()`, `upgrade_one()`, `downgrade_one()`, `downgrade_to()`, `current_revision()` — test data migration logic at any intermediate point in the migration chain
1216

@@ -49,10 +53,6 @@ Items are tracked as GitHub issues. This page is a high-level overview.
4953

5054
Static detection of squashmigrations is already in v1.4.0 (MRT601/MRT602). The next step is dynamic verification: run the rollback plan through the squashed migration graph and verify it succeeds. Currently skipped in `check_all()`.
5155

52-
### `--check-compat` Django support
53-
54-
Rolling-deploy compatibility checks (`--check-compat`, MRT7xx) are currently Alembic-only. Extending to Django migrations requires mapping Django operation types to the same compat patterns.
55-
5656
### Per-pattern confidence scores
5757

5858
Add a `confidence` field to `mrt check --format json` output. Lets downstream tooling suppress known false-positive-prone patterns without using `# noqa`.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pytest-mrt"
7-
version = "1.6.0"
7+
version = "1.7.0"
88
description = "Catch database migration rollback failures before they reach production"
99
readme = "README.md"
1010
license = { text = "MIT" }
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""
2+
Rolling-deploy compatibility checks for Django migrations (MRT7xx).
3+
4+
Mirrors ``core/compat.py`` (Alembic) but operates on Django migration
5+
operations. These answer a different question from rollback-safety checks:
6+
"Can the OLD app version survive while the new migration is live?"
7+
8+
During a rolling deploy the old app and new schema coexist briefly. Operations
9+
that break that window are flagged here.
10+
11+
Coverage note: MRT705 (column type change) is Alembic-only. Django's
12+
``AlterField`` always carries the full field definition with no reference to the
13+
previous type, so a type change cannot be detected statically without comparing
14+
against model state — out of scope for a file scanner.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import ast
20+
21+
from ..core.detector import RiskWarning
22+
from .django_detector import DjangoMigrationAST, _warn
23+
24+
25+
def _check_compat_remove_field(m: DjangoMigrationAST) -> list[RiskWarning]:
26+
"""MRT701 — RemoveField (DROP COLUMN) breaks old app instances immediately."""
27+
warnings = []
28+
for op in m.operations:
29+
if not isinstance(op, ast.Call) or m.op_name(op) != "RemoveField":
30+
continue
31+
model = m.kwarg_str(op, "model_name") or "?"
32+
field = m.kwarg_str(op, "name") or "?"
33+
warnings.append(
34+
_warn(
35+
m,
36+
"RemoveField during rolling deploy",
37+
f"Removing '{model}.{field}' will break old app instances still reading it. "
38+
"Two-step fix: (1) remove all code references and deploy, then "
39+
"(2) drop the field in a follow-up migration.",
40+
"error",
41+
line=op.lineno,
42+
code="MRT701",
43+
)
44+
)
45+
return warnings
46+
47+
48+
def _check_compat_rename_field(m: DjangoMigrationAST) -> list[RiskWarning]:
49+
"""MRT702 — RenameField (RENAME COLUMN) breaks old app instances immediately."""
50+
warnings = []
51+
for op in m.operations:
52+
if not isinstance(op, ast.Call) or m.op_name(op) != "RenameField":
53+
continue
54+
model = m.kwarg_str(op, "model_name") or "?"
55+
old_name = m.kwarg_str(op, "old_name") or "?"
56+
new_name = m.kwarg_str(op, "new_name") or "?"
57+
warnings.append(
58+
_warn(
59+
m,
60+
"RenameField during rolling deploy",
61+
f"Renaming '{model}.{old_name}' to '{new_name}' breaks old app instances "
62+
"referencing the old name. Use expand-contract: add new field, backfill, "
63+
"update code, then drop the old field.",
64+
"error",
65+
line=op.lineno,
66+
code="MRT702",
67+
)
68+
)
69+
return warnings
70+
71+
72+
def _check_compat_drop_table(m: DjangoMigrationAST) -> list[RiskWarning]:
73+
"""MRT703 — DeleteModel/RenameModel/AlterModelTable breaks old app immediately."""
74+
warnings = []
75+
for op in m.operations:
76+
if not isinstance(op, ast.Call):
77+
continue
78+
name = m.op_name(op)
79+
if name == "DeleteModel":
80+
model = m.kwarg_str(op, "name") or "?"
81+
warnings.append(
82+
_warn(
83+
m,
84+
"DeleteModel during rolling deploy",
85+
f"Deleting model '{model}' drops its table and crashes old app instances "
86+
"still querying it. Remove all ORM references and deploy first, then "
87+
"delete the model.",
88+
"error",
89+
line=op.lineno,
90+
code="MRT703",
91+
)
92+
)
93+
elif name == "RenameModel":
94+
old_name = m.kwarg_str(op, "old_name") or "?"
95+
new_name = m.kwarg_str(op, "new_name") or "?"
96+
warnings.append(
97+
_warn(
98+
m,
99+
"RenameModel during rolling deploy",
100+
f"Renaming model '{old_name}' to '{new_name}' renames its table and breaks "
101+
"old app instances querying the old table. Use expand-contract with an "
102+
"intermediate compatibility layer.",
103+
"error",
104+
line=op.lineno,
105+
code="MRT703",
106+
)
107+
)
108+
elif name == "AlterModelTable":
109+
model = m.kwarg_str(op, "name") or "?"
110+
warnings.append(
111+
_warn(
112+
m,
113+
"AlterModelTable during rolling deploy",
114+
f"Changing the db_table of '{model}' breaks old app instances querying the "
115+
"old table name. Use expand-contract with an intermediate view or copy.",
116+
"error",
117+
line=op.lineno,
118+
code="MRT703",
119+
)
120+
)
121+
return warnings
122+
123+
124+
def _check_compat_add_field_not_null(m: DjangoMigrationAST) -> list[RiskWarning]:
125+
"""MRT704 — AddField NOT NULL without a default breaks old app INSERTs."""
126+
warnings = []
127+
for op in m.operations:
128+
if not isinstance(op, ast.Call) or m.op_name(op) != "AddField":
129+
continue
130+
model = m.kwarg_str(op, "model_name") or "?"
131+
name = m.kwarg_str(op, "name") or "?"
132+
field_node = m.field_kwarg(op, "field")
133+
if not isinstance(field_node, ast.Call):
134+
continue
135+
136+
null_val: bool | None = None
137+
has_default = False
138+
for kw in field_node.keywords:
139+
if kw.arg == "null" and isinstance(kw.value, ast.Constant):
140+
null_val = bool(kw.value.value)
141+
if kw.arg in ("default", "server_default"):
142+
has_default = True
143+
144+
# Django defaults to null=False. Flag when explicitly or implicitly NOT NULL
145+
# and no default is supplied.
146+
if null_val is not True and not has_default:
147+
warnings.append(
148+
_warn(
149+
m,
150+
"AddField NOT NULL without default during rolling deploy",
151+
f"Adding NOT NULL field '{model}.{name}' without a default causes INSERT "
152+
"failures from old app instances that don't supply the value. "
153+
"Add a default or make it nullable first.",
154+
"error",
155+
line=op.lineno,
156+
code="MRT704",
157+
)
158+
)
159+
return warnings
160+
161+
162+
_DJANGO_COMPAT_CHECKS = [
163+
_check_compat_remove_field,
164+
_check_compat_rename_field,
165+
_check_compat_drop_table,
166+
_check_compat_add_field_not_null,
167+
]
168+
169+
170+
def analyze_django_compat(m: DjangoMigrationAST) -> list[RiskWarning]:
171+
"""Run all rolling-deploy compatibility checks on a single Django migration."""
172+
results: list[RiskWarning] = []
173+
for check in _DJANGO_COMPAT_CHECKS:
174+
results.extend(check(m))
175+
return results

pytest_mrt/adapters/django_detector.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,7 @@ def analyze_django_migrations(
645645
migrations_dir: str,
646646
since: str | None = None,
647647
min_revision: str | None = None,
648+
check_compat: bool = False,
648649
) -> list[RiskWarning]:
649650
"""Analyze Django migration files for rollback risk patterns.
650651
@@ -655,6 +656,8 @@ def analyze_django_migrations(
655656
in CI to limit analysis to the migrations added in a branch.
656657
min_revision: If given, skip migrations at or older than this point.
657658
The two sets are intersected when both are provided.
659+
check_compat: If True, also run rolling-deploy compatibility checks
660+
(MRT7xx) in addition to the rollback-safety checks.
658661
"""
659662
since_set: set[str] | None = None
660663
if since is not None:
@@ -690,7 +693,12 @@ def analyze_django_migrations(
690693
)
691694
continue
692695
source_lines = path.read_text().splitlines()
693-
for check in _DJANGO_CHECKS:
696+
checks = list(_DJANGO_CHECKS)
697+
if check_compat:
698+
from .django_compat import analyze_django_compat
699+
700+
checks.append(analyze_django_compat)
701+
for check in checks:
694702
for w in check(m):
695703
if (
696704
w.line is not None

pytest_mrt/commands/check.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def _collect_warnings(
2323
check_compat: bool = False,
2424
) -> list:
2525
if is_django:
26-
warnings = analyze_django_migrations(versions_dir, since=since, min_revision=min_revision)
26+
warnings = analyze_django_migrations(
27+
versions_dir, since=since, min_revision=min_revision, check_compat=check_compat
28+
)
2729
else:
2830
warnings = analyze_migrations(versions_dir, since=since, min_revision=min_revision)
2931

@@ -123,8 +125,9 @@ def check(
123125
help=(
124126
"Also run rolling-deploy compatibility checks (MRT7xx). "
125127
"Flags operations that break the old app during a rolling deploy: "
126-
"DROP COLUMN, RENAME COLUMN, DROP TABLE, ADD NOT NULL without server_default. "
127-
"Alembic only."
128+
"DROP/RENAME COLUMN, DROP/RENAME TABLE, ADD NOT NULL without default. "
129+
"Supported for both Alembic and Django (Django MRT705 type-change "
130+
"detection is Alembic-only)."
128131
),
129132
),
130133
) -> None:
@@ -191,13 +194,6 @@ def check(
191194
f"[dim]--min-revision {min_revision}: checking {len(min_set)} newer migration(s), older ones skipped[/dim]"
192195
)
193196

194-
if check_compat and is_django:
195-
console.print(
196-
"[yellow]Warning: --check-compat is not yet supported for Django migrations. "
197-
"Compat checks will be skipped.[/yellow]"
198-
)
199-
check_compat = False
200-
201197
if check_compat:
202198
console.print(
203199
"[dim]--check-compat: rolling-deploy compatibility checks enabled (MRT7xx)[/dim]"

tests/test_cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,6 @@ def test_check_watch_runs_once_then_stops(tmp_path, versions_dir):
160160
"""Watch mode runs the check once and exits cleanly on KeyboardInterrupt."""
161161
_safe_migration(versions_dir)
162162
from unittest.mock import patch
163-
import time
164163

165164
call_count = 0
166165

@@ -684,6 +683,7 @@ def test_drift_missing_alembic_ini_exits_1(tmp_path):
684683
"""mrt drift exits 1 when alembic.ini does not exist."""
685684
import sys
686685
import types
686+
687687
import sqlalchemy as sa
688688

689689
# Register a valid metadata module so metadata loading succeeds,
@@ -739,10 +739,11 @@ def test_drift_no_drift_exits_0(tmp_path):
739739

740740
def test_drift_with_drift_exits_1(tmp_path):
741741
"""mrt drift exits 1 and lists differences when the model has an extra column."""
742-
import sqlalchemy as sa
743742
import sys
744743
import types
745744

745+
import sqlalchemy as sa
746+
746747
ini, _, db_url = _alembic_env(tmp_path)
747748

748749
mod = types.ModuleType("_mrt_test_drift_models")

0 commit comments

Comments
 (0)