Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

---

## [1.7.0] — 2026-07-14

### Added
- **`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.

### Removed
- The "`--check-compat` is not yet supported for Django migrations" warning, now that it is supported.

---

## [1.6.0] — 2026-06-17

### Added
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@ Legacy syntax `# mrt: ignore` is still supported for backward compatibility.

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.

## What's new in v1.7.0

- **`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.

```bash
mrt check myapp/migrations/ --check-compat
```

## What's new in v1.6.0

- **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:
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ mrt check alembic/versions/ --check-compat
| `--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. |
| `--min-revision` | `None` | Skip revisions at or older than this point. Alembic: revision ID. Django: `app_label.migration_name`. Mirrors `MRTConfig.minimum_downgrade_revision`. |
| `--watch` / `-w` | `False` | Re-run automatically when migration files change. `--format table` only. Ctrl-C to stop. |
| `--check-compat` | `False` | Also run rolling-deploy compatibility checks (MRT701–MRT705). Alembic only. |
| `--check-compat` | `False` | Also run rolling-deploy compatibility checks (MRT701–MRT705). Alembic and Django (MRT705 type-change detection is Alembic-only). |

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

Expand Down
24 changes: 17 additions & 7 deletions docs/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,25 +488,35 @@ These checks are opt-in via `mrt check --check-compat`. They flag operations tha

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

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.

| Code | Alembic | Django |
|------|---------|--------|
| MRT701 | `op.drop_column` | `migrations.RemoveField` |
| MRT702 | `op.alter_column(new_column_name=…)` | `migrations.RenameField` |
| MRT703 | `op.drop_table` | `migrations.DeleteModel` / `RenameModel` / `AlterModelTable` |
| MRT704 | `op.add_column` NOT NULL w/o `server_default` | `migrations.AddField` NOT NULL w/o default |
| MRT705 | `op.alter_column(type_=…)` | — (Alembic only) |

### MRT701 — DROP COLUMN during rolling deploy (error)

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:
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:

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

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

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.
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.

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

Dropping a table crashes old app instances that query it. Remove all ORM models and direct references, deploy, then drop the table.
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.

### MRT704 — ADD NOT NULL column without server_default (error)
### MRT704 — ADD NOT NULL column without default (error)

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.
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.

### MRT705 — Column type change during rolling deploy (warning)
### MRT705 — Column type change during rolling deploy (warning, Alembic only)

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.
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).
10 changes: 5 additions & 5 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ Items are tracked as GitHub issues. This page is a high-level overview.

## Shipped

### v1.6.0 — Fine-grained step control (main, pending release)
### v1.7.0 — Django rolling-deploy compatibility (main, pending release)

- `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.

### v1.6.0 — Fine-grained step control

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

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

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()`.

### `--check-compat` Django support

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.

### Per-pattern confidence scores

Add a `confidence` field to `mrt check --format json` output. Lets downstream tooling suppress known false-positive-prone patterns without using `# noqa`.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pytest-mrt"
version = "1.6.0"
version = "1.7.0"
description = "Catch database migration rollback failures before they reach production"
readme = "README.md"
license = { text = "MIT" }
Expand Down
175 changes: 175 additions & 0 deletions pytest_mrt/adapters/django_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""
Rolling-deploy compatibility checks for Django migrations (MRT7xx).

Mirrors ``core/compat.py`` (Alembic) but operates on Django migration
operations. These answer a different question from rollback-safety checks:
"Can the OLD app version survive while the new migration is live?"

During a rolling deploy the old app and new schema coexist briefly. Operations
that break that window are flagged here.

Coverage note: MRT705 (column type change) is Alembic-only. Django's
``AlterField`` always carries the full field definition with no reference to the
previous type, so a type change cannot be detected statically without comparing
against model state — out of scope for a file scanner.
"""

from __future__ import annotations

import ast

from ..core.detector import RiskWarning
from .django_detector import DjangoMigrationAST, _warn


def _check_compat_remove_field(m: DjangoMigrationAST) -> list[RiskWarning]:
"""MRT701 — RemoveField (DROP COLUMN) breaks old app instances immediately."""
warnings = []
for op in m.operations:
if not isinstance(op, ast.Call) or m.op_name(op) != "RemoveField":
continue
model = m.kwarg_str(op, "model_name") or "?"
field = m.kwarg_str(op, "name") or "?"
warnings.append(
_warn(
m,
"RemoveField during rolling deploy",
f"Removing '{model}.{field}' will break old app instances still reading it. "
"Two-step fix: (1) remove all code references and deploy, then "
"(2) drop the field in a follow-up migration.",
"error",
line=op.lineno,
code="MRT701",
)
)
return warnings


def _check_compat_rename_field(m: DjangoMigrationAST) -> list[RiskWarning]:
"""MRT702 — RenameField (RENAME COLUMN) breaks old app instances immediately."""
warnings = []
for op in m.operations:
if not isinstance(op, ast.Call) or m.op_name(op) != "RenameField":
continue
model = m.kwarg_str(op, "model_name") or "?"
old_name = m.kwarg_str(op, "old_name") or "?"
new_name = m.kwarg_str(op, "new_name") or "?"
warnings.append(
_warn(
m,
"RenameField during rolling deploy",
f"Renaming '{model}.{old_name}' to '{new_name}' breaks old app instances "
"referencing the old name. Use expand-contract: add new field, backfill, "
"update code, then drop the old field.",
"error",
line=op.lineno,
code="MRT702",
)
)
return warnings


def _check_compat_drop_table(m: DjangoMigrationAST) -> list[RiskWarning]:
"""MRT703 — DeleteModel/RenameModel/AlterModelTable breaks old app immediately."""
warnings = []
for op in m.operations:
if not isinstance(op, ast.Call):
continue
name = m.op_name(op)
if name == "DeleteModel":
model = m.kwarg_str(op, "name") or "?"
warnings.append(
_warn(
m,
"DeleteModel during rolling deploy",
f"Deleting model '{model}' drops its table and crashes old app instances "
"still querying it. Remove all ORM references and deploy first, then "
"delete the model.",
"error",
line=op.lineno,
code="MRT703",
)
)
elif name == "RenameModel":
old_name = m.kwarg_str(op, "old_name") or "?"
new_name = m.kwarg_str(op, "new_name") or "?"
warnings.append(
_warn(
m,
"RenameModel during rolling deploy",
f"Renaming model '{old_name}' to '{new_name}' renames its table and breaks "
"old app instances querying the old table. Use expand-contract with an "
"intermediate compatibility layer.",
"error",
line=op.lineno,
code="MRT703",
)
)
elif name == "AlterModelTable":
model = m.kwarg_str(op, "name") or "?"
warnings.append(
_warn(
m,
"AlterModelTable during rolling deploy",
f"Changing the db_table of '{model}' breaks old app instances querying the "
"old table name. Use expand-contract with an intermediate view or copy.",
"error",
line=op.lineno,
code="MRT703",
)
)
return warnings


def _check_compat_add_field_not_null(m: DjangoMigrationAST) -> list[RiskWarning]:
"""MRT704 — AddField NOT NULL without a default breaks old app INSERTs."""
warnings = []
for op in m.operations:
if not isinstance(op, ast.Call) or m.op_name(op) != "AddField":
continue
model = m.kwarg_str(op, "model_name") or "?"
name = m.kwarg_str(op, "name") or "?"
field_node = m.field_kwarg(op, "field")
if not isinstance(field_node, ast.Call):
continue

null_val: bool | None = None
has_default = False
for kw in field_node.keywords:
if kw.arg == "null" and isinstance(kw.value, ast.Constant):
null_val = bool(kw.value.value)
if kw.arg in ("default", "server_default"):
has_default = True

# Django defaults to null=False. Flag when explicitly or implicitly NOT NULL
# and no default is supplied.
if null_val is not True and not has_default:
warnings.append(
_warn(
m,
"AddField NOT NULL without default during rolling deploy",
f"Adding NOT NULL field '{model}.{name}' without a default causes INSERT "
"failures from old app instances that don't supply the value. "
"Add a default or make it nullable first.",
"error",
line=op.lineno,
code="MRT704",
)
)
return warnings


_DJANGO_COMPAT_CHECKS = [
_check_compat_remove_field,
_check_compat_rename_field,
_check_compat_drop_table,
_check_compat_add_field_not_null,
]


def analyze_django_compat(m: DjangoMigrationAST) -> list[RiskWarning]:
"""Run all rolling-deploy compatibility checks on a single Django migration."""
results: list[RiskWarning] = []
for check in _DJANGO_COMPAT_CHECKS:
results.extend(check(m))
return results
10 changes: 9 additions & 1 deletion pytest_mrt/adapters/django_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ def analyze_django_migrations(
migrations_dir: str,
since: str | None = None,
min_revision: str | None = None,
check_compat: bool = False,
) -> list[RiskWarning]:
"""Analyze Django migration files for rollback risk patterns.

Expand All @@ -655,6 +656,8 @@ def analyze_django_migrations(
in CI to limit analysis to the migrations added in a branch.
min_revision: If given, skip migrations at or older than this point.
The two sets are intersected when both are provided.
check_compat: If True, also run rolling-deploy compatibility checks
(MRT7xx) in addition to the rollback-safety checks.
"""
since_set: set[str] | None = None
if since is not None:
Expand Down Expand Up @@ -690,7 +693,12 @@ def analyze_django_migrations(
)
continue
source_lines = path.read_text().splitlines()
for check in _DJANGO_CHECKS:
checks = list(_DJANGO_CHECKS)
if check_compat:
from .django_compat import analyze_django_compat

checks.append(analyze_django_compat)
for check in checks:
for w in check(m):
if (
w.line is not None
Expand Down
16 changes: 6 additions & 10 deletions pytest_mrt/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def _collect_warnings(
check_compat: bool = False,
) -> list:
if is_django:
warnings = analyze_django_migrations(versions_dir, since=since, min_revision=min_revision)
warnings = analyze_django_migrations(
versions_dir, since=since, min_revision=min_revision, check_compat=check_compat
)
else:
warnings = analyze_migrations(versions_dir, since=since, min_revision=min_revision)

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

if check_compat and is_django:
console.print(
"[yellow]Warning: --check-compat is not yet supported for Django migrations. "
"Compat checks will be skipped.[/yellow]"
)
check_compat = False

if check_compat:
console.print(
"[dim]--check-compat: rolling-deploy compatibility checks enabled (MRT7xx)[/dim]"
Expand Down
5 changes: 3 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ def test_check_watch_runs_once_then_stops(tmp_path, versions_dir):
"""Watch mode runs the check once and exits cleanly on KeyboardInterrupt."""
_safe_migration(versions_dir)
from unittest.mock import patch
import time

call_count = 0

Expand Down Expand Up @@ -684,6 +683,7 @@ def test_drift_missing_alembic_ini_exits_1(tmp_path):
"""mrt drift exits 1 when alembic.ini does not exist."""
import sys
import types

import sqlalchemy as sa

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

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

import sqlalchemy as sa

ini, _, db_url = _alembic_env(tmp_path)

mod = types.ModuleType("_mrt_test_drift_models")
Expand Down
2 changes: 0 additions & 2 deletions tests/test_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

from __future__ import annotations

import pytest

from pytest_mrt.core.ast_analyzer import MigrationAST
from pytest_mrt.core.compat import analyze_compat

Expand Down
Loading