pytest-mrt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_mrt/__init__.py +4 -0
- pytest_mrt/adapters/__init__.py +0 -0
- pytest_mrt/cli.py +63 -0
- pytest_mrt/config.py +8 -0
- pytest_mrt/core/__init__.py +0 -0
- pytest_mrt/core/detector.py +259 -0
- pytest_mrt/core/runner.py +39 -0
- pytest_mrt/core/schema.py +120 -0
- pytest_mrt/core/seeder.py +152 -0
- pytest_mrt/core/verifier.py +72 -0
- pytest_mrt/plugin.py +77 -0
- pytest_mrt/reporter.py +55 -0
- pytest_mrt-0.1.0.dist-info/METADATA +335 -0
- pytest_mrt-0.1.0.dist-info/RECORD +17 -0
- pytest_mrt-0.1.0.dist-info/WHEEL +4 -0
- pytest_mrt-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_mrt-0.1.0.dist-info/licenses/LICENSE +21 -0
pytest_mrt/__init__.py
ADDED
|
File without changes
|
pytest_mrt/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich import box
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .core.detector import analyze_migrations
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="mrt",
|
|
11
|
+
help="MRT — Migration Rollback Tester",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("version")
|
|
18
|
+
def version_cmd() -> None:
|
|
19
|
+
"""Show version."""
|
|
20
|
+
console.print(f"pytest-mrt {__version__}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _severity_color(s: str) -> str:
|
|
24
|
+
return "red" if s == "error" else "yellow"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("check")
|
|
28
|
+
def check(
|
|
29
|
+
versions_dir: str = typer.Argument(help="Path to Alembic versions directory"),
|
|
30
|
+
strict: bool = typer.Option(False, "--strict", help="Exit 1 on warnings too"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Statically analyze migrations for rollback risk patterns."""
|
|
33
|
+
warnings = analyze_migrations(versions_dir)
|
|
34
|
+
|
|
35
|
+
if not warnings:
|
|
36
|
+
console.print("[green]✓ No rollback risks detected.[/green]")
|
|
37
|
+
raise typer.Exit(0)
|
|
38
|
+
|
|
39
|
+
errors = [w for w in warnings if w.severity == "error"]
|
|
40
|
+
warns = [w for w in warnings if w.severity == "warning"]
|
|
41
|
+
|
|
42
|
+
table = Table(box=box.ROUNDED, title="Rollback Risk Analysis", show_lines=True)
|
|
43
|
+
table.add_column("Revision", style="cyan", no_wrap=True)
|
|
44
|
+
table.add_column("Pattern", no_wrap=True)
|
|
45
|
+
table.add_column("Sev", no_wrap=True)
|
|
46
|
+
table.add_column("Message")
|
|
47
|
+
|
|
48
|
+
for w in warnings:
|
|
49
|
+
c = _severity_color(w.severity)
|
|
50
|
+
table.add_row(w.revision, w.pattern, f"[{c}]{w.severity}[/{c}]", w.message)
|
|
51
|
+
|
|
52
|
+
console.print(table)
|
|
53
|
+
console.print()
|
|
54
|
+
|
|
55
|
+
if errors:
|
|
56
|
+
console.print(f"[red]{len(errors)} error(s)[/red], [yellow]{len(warns)} warning(s)[/yellow]")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
elif warns and strict:
|
|
59
|
+
console.print(f"[yellow]{len(warns)} warning(s)[/yellow] (--strict mode)")
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
else:
|
|
62
|
+
console.print(f"[yellow]{len(warns)} warning(s)[/yellow] — review before deploying")
|
|
63
|
+
raise typer.Exit(0)
|
pytest_mrt/config.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class RiskWarning:
|
|
9
|
+
revision: str
|
|
10
|
+
file: str
|
|
11
|
+
pattern: str
|
|
12
|
+
message: str
|
|
13
|
+
severity: str # "error" | "warning"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ──────────────────────────────────────────────
|
|
17
|
+
# helpers
|
|
18
|
+
# ──────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
def _fn_body(source: str, fn_name: str) -> str:
|
|
21
|
+
"""Extract the body of a top-level function."""
|
|
22
|
+
m = re.search(
|
|
23
|
+
rf"def {fn_name}\s*\([^)]*\)\s*(?:->.*?)?\s*:\s*\n((?:[ \t]+[^\n]*\n?)*)",
|
|
24
|
+
source,
|
|
25
|
+
)
|
|
26
|
+
return m.group(1) if m else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _upgrade_body(source: str) -> str:
|
|
30
|
+
return _fn_body(source, "upgrade")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _downgrade_body(source: str) -> str:
|
|
34
|
+
return _fn_body(source, "downgrade")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_noop(body: str) -> bool:
|
|
38
|
+
stripped = body.strip()
|
|
39
|
+
return stripped in ("", "pass") or stripped.startswith("pass\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ──────────────────────────────────────────────
|
|
43
|
+
# individual checks
|
|
44
|
+
# ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def _check_downgrade_exists(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
47
|
+
if not re.search(r"def downgrade\s*\(", source):
|
|
48
|
+
return [RiskWarning(rev, fname, "Missing downgrade",
|
|
49
|
+
"No downgrade() function — migration is permanently irreversible", "error")]
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_noop_downgrade(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
54
|
+
body = _downgrade_body(source)
|
|
55
|
+
if body and _is_noop(body):
|
|
56
|
+
return [RiskWarning(rev, fname, "No-op downgrade",
|
|
57
|
+
"downgrade() body is `pass` — migration is irreversible", "error")]
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _check_drop_column_in_upgrade(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
62
|
+
if re.search(r"op\.drop_column\s*\(", _upgrade_body(source)):
|
|
63
|
+
return [RiskWarning(rev, fname, "DROP COLUMN in upgrade",
|
|
64
|
+
"Column dropped in upgrade — data is permanently lost on rollback", "error")]
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_drop_table_in_upgrade(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
69
|
+
if re.search(r"op\.drop_table\s*\(", _upgrade_body(source)):
|
|
70
|
+
return [RiskWarning(rev, fname, "DROP TABLE in upgrade",
|
|
71
|
+
"Table dropped in upgrade — all data is permanently lost on rollback", "error")]
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_truncate(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
76
|
+
body = _upgrade_body(source)
|
|
77
|
+
if re.search(r"TRUNCATE\s+", body, re.IGNORECASE):
|
|
78
|
+
return [RiskWarning(rev, fname, "TRUNCATE",
|
|
79
|
+
"TRUNCATE in upgrade destroys data — cannot be rolled back", "error")]
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_run_python_no_reverse(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
84
|
+
warnings = []
|
|
85
|
+
for m in re.finditer(r"op\.execute\s*\(.*?run_module|op\.run_async\s*\([^)]+\)", source, re.DOTALL):
|
|
86
|
+
if "reverse_func" not in m.group(0):
|
|
87
|
+
warnings.append(RiskWarning(rev, fname, "RunPython without reverse",
|
|
88
|
+
"Data transformation has no reverse_func — cannot undo", "error"))
|
|
89
|
+
break
|
|
90
|
+
# Also check bulk execute_if patterns
|
|
91
|
+
return warnings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_not_null_no_default(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
95
|
+
warnings = []
|
|
96
|
+
body = _upgrade_body(source)
|
|
97
|
+
for m in re.finditer(r"op\.add_column\s*\(([^)]+)\)", body, re.DOTALL):
|
|
98
|
+
col_expr = m.group(1)
|
|
99
|
+
has_nullable_false = re.search(r"nullable\s*=\s*False", col_expr)
|
|
100
|
+
has_server_default = re.search(r"server_default\s*=", col_expr)
|
|
101
|
+
if has_nullable_false and not has_server_default:
|
|
102
|
+
warnings.append(RiskWarning(
|
|
103
|
+
rev, fname, "NOT NULL without default",
|
|
104
|
+
"ADD NOT NULL column without server_default — will fail on non-empty tables "
|
|
105
|
+
"and rollback may leave column in invalid state",
|
|
106
|
+
"warning",
|
|
107
|
+
))
|
|
108
|
+
return warnings
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_column_type_change(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
112
|
+
warnings = []
|
|
113
|
+
body = _upgrade_body(source)
|
|
114
|
+
for m in re.finditer(r"op\.alter_column\s*\([^)]+type_\s*=", body, re.DOTALL):
|
|
115
|
+
warnings.append(RiskWarning(
|
|
116
|
+
rev, fname, "Column type change",
|
|
117
|
+
"Type change may be destructive — verify downgrade restores original type "
|
|
118
|
+
"and existing data survives conversion",
|
|
119
|
+
"warning",
|
|
120
|
+
))
|
|
121
|
+
break
|
|
122
|
+
return warnings
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check_column_size_shrink(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
126
|
+
body = _upgrade_body(source)
|
|
127
|
+
# Look for VARCHAR/String with smaller length
|
|
128
|
+
for m in re.finditer(
|
|
129
|
+
r"op\.alter_column\s*\([^)]+(?:VARCHAR|String)\s*\(\s*(\d+)\s*\)", body, re.DOTALL | re.IGNORECASE
|
|
130
|
+
):
|
|
131
|
+
return [RiskWarning(
|
|
132
|
+
rev, fname, "Column size change",
|
|
133
|
+
f"Column resized to {m.group(1)} chars — data exceeding new limit will be truncated on rollback",
|
|
134
|
+
"warning",
|
|
135
|
+
)]
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_raw_execute(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
140
|
+
body = _upgrade_body(source)
|
|
141
|
+
if re.search(r"op\.execute\s*\(", body):
|
|
142
|
+
return [RiskWarning(
|
|
143
|
+
rev, fname, "Raw SQL (op.execute)",
|
|
144
|
+
"op.execute() with raw SQL — manually verify downgrade correctly reverses this",
|
|
145
|
+
"warning",
|
|
146
|
+
)]
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_data_migration_no_reverse(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
151
|
+
"""Detect bulk data transforms (UPDATE ... SET) in upgrade without reverse."""
|
|
152
|
+
body = _upgrade_body(source)
|
|
153
|
+
dg_body = _downgrade_body(source)
|
|
154
|
+
if re.search(r"UPDATE\s+\w+\s+SET", body, re.IGNORECASE):
|
|
155
|
+
if not re.search(r"UPDATE\s+\w+\s+SET", dg_body, re.IGNORECASE):
|
|
156
|
+
return [RiskWarning(
|
|
157
|
+
rev, fname, "Data transform without reverse",
|
|
158
|
+
"Bulk UPDATE in upgrade but not in downgrade — data transformation is one-way",
|
|
159
|
+
"warning",
|
|
160
|
+
)]
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _check_cascade_delete(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
165
|
+
if re.search(r"ondelete\s*=\s*['\"]CASCADE['\"]|ON DELETE CASCADE", _upgrade_body(source), re.IGNORECASE):
|
|
166
|
+
return [RiskWarning(
|
|
167
|
+
rev, fname, "CASCADE DELETE",
|
|
168
|
+
"FK with ON DELETE CASCADE added — child rows will be silently deleted if parent is deleted",
|
|
169
|
+
"warning",
|
|
170
|
+
)]
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _check_index_without_concurrently(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
175
|
+
"""PostgreSQL: CREATE INDEX without CONCURRENTLY locks the table."""
|
|
176
|
+
body = _upgrade_body(source)
|
|
177
|
+
if re.search(r"op\.create_index\s*\(", body):
|
|
178
|
+
if not re.search(r"postgresql_concurrently\s*=\s*True", body):
|
|
179
|
+
return [RiskWarning(
|
|
180
|
+
rev, fname, "INDEX without CONCURRENTLY",
|
|
181
|
+
"CREATE INDEX without postgresql_concurrently=True — locks table during index build",
|
|
182
|
+
"warning",
|
|
183
|
+
)]
|
|
184
|
+
return []
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_add_column_with_default_on_large_table(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
188
|
+
"""PostgreSQL < 11: ADD COLUMN with DEFAULT rewrites entire table."""
|
|
189
|
+
body = _upgrade_body(source)
|
|
190
|
+
for m in re.finditer(r"op\.add_column\s*\([^)]+\)", body, re.DOTALL):
|
|
191
|
+
col_expr = m.group(0)
|
|
192
|
+
if re.search(r"server_default\s*=", col_expr) or re.search(r"default\s*=", col_expr):
|
|
193
|
+
return [RiskWarning(
|
|
194
|
+
rev, fname, "ADD COLUMN with DEFAULT",
|
|
195
|
+
"Adding column with DEFAULT may rewrite the entire table on PostgreSQL < 11 — "
|
|
196
|
+
"causes long lock on large tables",
|
|
197
|
+
"warning",
|
|
198
|
+
)]
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _check_unique_constraint_on_existing(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
203
|
+
body = _upgrade_body(source)
|
|
204
|
+
if re.search(r"op\.create_unique_constraint\s*\(", body):
|
|
205
|
+
return [RiskWarning(
|
|
206
|
+
rev, fname, "UNIQUE constraint on existing data",
|
|
207
|
+
"Adding UNIQUE constraint — will fail if existing rows have duplicate values",
|
|
208
|
+
"warning",
|
|
209
|
+
)]
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_drop_not_null(source: str, rev: str, fname: str) -> list[RiskWarning]:
|
|
214
|
+
"""Making column NOT NULL when downgrade re-adds NULL values."""
|
|
215
|
+
up = _upgrade_body(source)
|
|
216
|
+
if re.search(r"op\.alter_column\s*\([^)]+nullable\s*=\s*False", up, re.DOTALL):
|
|
217
|
+
down = _downgrade_body(source)
|
|
218
|
+
if not re.search(r"op\.alter_column\s*\([^)]+nullable\s*=\s*True", down, re.DOTALL):
|
|
219
|
+
return [RiskWarning(
|
|
220
|
+
rev, fname, "NOT NULL without reverting nullable",
|
|
221
|
+
"Column set to NOT NULL but downgrade does not restore nullable=True",
|
|
222
|
+
"warning",
|
|
223
|
+
)]
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ──────────────────────────────────────────────
|
|
228
|
+
# public API
|
|
229
|
+
# ──────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
_CHECKS = [
|
|
232
|
+
_check_downgrade_exists,
|
|
233
|
+
_check_noop_downgrade,
|
|
234
|
+
_check_drop_column_in_upgrade,
|
|
235
|
+
_check_drop_table_in_upgrade,
|
|
236
|
+
_check_truncate,
|
|
237
|
+
_check_run_python_no_reverse,
|
|
238
|
+
_check_not_null_no_default,
|
|
239
|
+
_check_column_type_change,
|
|
240
|
+
_check_column_size_shrink,
|
|
241
|
+
_check_raw_execute,
|
|
242
|
+
_check_data_migration_no_reverse,
|
|
243
|
+
_check_cascade_delete,
|
|
244
|
+
_check_index_without_concurrently,
|
|
245
|
+
_check_add_column_with_default_on_large_table,
|
|
246
|
+
_check_unique_constraint_on_existing,
|
|
247
|
+
_check_drop_not_null,
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def analyze_migrations(versions_dir: str) -> list[RiskWarning]:
|
|
252
|
+
warnings: list[RiskWarning] = []
|
|
253
|
+
for path in sorted(Path(versions_dir).glob("*.py")):
|
|
254
|
+
source = path.read_text()
|
|
255
|
+
m = re.search(r'revision\s*=\s*["\']([^"\']+)["\']', source)
|
|
256
|
+
revision = m.group(1) if m else path.stem
|
|
257
|
+
for check in _CHECKS:
|
|
258
|
+
warnings.extend(check(source, revision, path.name))
|
|
259
|
+
return warnings
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from alembic import command
|
|
4
|
+
from alembic.config import Config as AlembicConfig
|
|
5
|
+
from alembic.script import ScriptDirectory
|
|
6
|
+
from sqlalchemy import create_engine
|
|
7
|
+
from sqlalchemy.engine import Engine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MigrationRunner:
|
|
11
|
+
def __init__(self, alembic_ini: str, db_url: str):
|
|
12
|
+
self.db_url = db_url
|
|
13
|
+
self.alembic_cfg = AlembicConfig(alembic_ini)
|
|
14
|
+
self.alembic_cfg.set_main_option("sqlalchemy.url", db_url)
|
|
15
|
+
self.engine: Engine = create_engine(db_url)
|
|
16
|
+
|
|
17
|
+
def upgrade(self, revision: str = "head") -> None:
|
|
18
|
+
command.upgrade(self.alembic_cfg, revision)
|
|
19
|
+
|
|
20
|
+
def downgrade(self, revision: str = "-1") -> None:
|
|
21
|
+
command.downgrade(self.alembic_cfg, revision)
|
|
22
|
+
|
|
23
|
+
def downgrade_base(self) -> None:
|
|
24
|
+
command.downgrade(self.alembic_cfg, "base")
|
|
25
|
+
|
|
26
|
+
def current_revision(self) -> str | None:
|
|
27
|
+
from alembic.runtime.migration import MigrationContext
|
|
28
|
+
with self.engine.connect() as conn:
|
|
29
|
+
ctx = MigrationContext.configure(conn)
|
|
30
|
+
return ctx.get_current_revision()
|
|
31
|
+
|
|
32
|
+
def get_revisions(self) -> list:
|
|
33
|
+
"""All revisions in upgrade order (oldest → newest)."""
|
|
34
|
+
script = ScriptDirectory.from_config(self.alembic_cfg)
|
|
35
|
+
return list(reversed(list(script.walk_revisions("base", "heads"))))
|
|
36
|
+
|
|
37
|
+
def get_versions_dir(self) -> str:
|
|
38
|
+
script = ScriptDirectory.from_config(self.alembic_cfg)
|
|
39
|
+
return script.dir
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from sqlalchemy import inspect
|
|
4
|
+
from sqlalchemy.engine import Engine
|
|
5
|
+
|
|
6
|
+
_INTERNAL_TABLES = {"alembic_version"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ColumnInfo:
|
|
11
|
+
name: str
|
|
12
|
+
type_str: str
|
|
13
|
+
nullable: bool
|
|
14
|
+
default: str | None = None
|
|
15
|
+
primary_key: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TableInfo:
|
|
20
|
+
name: str
|
|
21
|
+
columns: dict[str, ColumnInfo] = field(default_factory=dict)
|
|
22
|
+
pk_cols: list[str] = field(default_factory=list)
|
|
23
|
+
fk_tables: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SchemaSnapshot:
|
|
28
|
+
tables: dict[str, TableInfo] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def capture(cls, engine: Engine) -> SchemaSnapshot:
|
|
32
|
+
snap = cls()
|
|
33
|
+
with engine.connect() as conn:
|
|
34
|
+
insp = inspect(conn)
|
|
35
|
+
for tname in insp.get_table_names():
|
|
36
|
+
if tname in _INTERNAL_TABLES:
|
|
37
|
+
continue
|
|
38
|
+
ti = TableInfo(name=tname)
|
|
39
|
+
pk_info = insp.get_pk_constraint(tname)
|
|
40
|
+
ti.pk_cols = pk_info.get("constrained_columns", [])
|
|
41
|
+
ti.fk_tables = list({
|
|
42
|
+
fk["referred_table"]
|
|
43
|
+
for fk in insp.get_foreign_keys(tname)
|
|
44
|
+
})
|
|
45
|
+
for col in insp.get_columns(tname):
|
|
46
|
+
ti.columns[col["name"]] = ColumnInfo(
|
|
47
|
+
name=col["name"],
|
|
48
|
+
type_str=str(col["type"]),
|
|
49
|
+
nullable=col.get("nullable", True),
|
|
50
|
+
default=str(col["default"]) if col.get("default") is not None else None,
|
|
51
|
+
primary_key=col["name"] in ti.pk_cols,
|
|
52
|
+
)
|
|
53
|
+
snap.tables[tname] = ti
|
|
54
|
+
return snap
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SchemaIssue:
|
|
59
|
+
table: str
|
|
60
|
+
message: str
|
|
61
|
+
severity: str # "error" | "warning"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class SchemaDiff:
|
|
66
|
+
dropped_tables: list[str] = field(default_factory=list)
|
|
67
|
+
added_tables: list[str] = field(default_factory=list)
|
|
68
|
+
dropped_columns: dict[str, list[str]] = field(default_factory=dict)
|
|
69
|
+
added_columns: dict[str, list[str]] = field(default_factory=dict)
|
|
70
|
+
type_changed: dict[str, list[tuple[str, str, str]]] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def compute(cls, before: SchemaSnapshot, after: SchemaSnapshot) -> SchemaDiff:
|
|
74
|
+
diff = cls()
|
|
75
|
+
before_t = set(before.tables)
|
|
76
|
+
after_t = set(after.tables)
|
|
77
|
+
diff.dropped_tables = sorted(before_t - after_t)
|
|
78
|
+
diff.added_tables = sorted(after_t - before_t)
|
|
79
|
+
for t in before_t & after_t:
|
|
80
|
+
before_c = set(before.tables[t].columns)
|
|
81
|
+
after_c = set(after.tables[t].columns)
|
|
82
|
+
dropped = sorted(before_c - after_c)
|
|
83
|
+
added = sorted(after_c - before_c)
|
|
84
|
+
if dropped:
|
|
85
|
+
diff.dropped_columns[t] = dropped
|
|
86
|
+
if added:
|
|
87
|
+
diff.added_columns[t] = added
|
|
88
|
+
for col in before_c & after_c:
|
|
89
|
+
bt = before.tables[t].columns[col].type_str
|
|
90
|
+
at = after.tables[t].columns[col].type_str
|
|
91
|
+
if bt != at:
|
|
92
|
+
diff.type_changed.setdefault(t, []).append((col, bt, at))
|
|
93
|
+
return diff
|
|
94
|
+
|
|
95
|
+
def verify_restored(
|
|
96
|
+
self, before: SchemaSnapshot, after_rollback: SchemaSnapshot
|
|
97
|
+
) -> list[SchemaIssue]:
|
|
98
|
+
issues = []
|
|
99
|
+
before_t = set(before.tables)
|
|
100
|
+
restored_t = set(after_rollback.tables)
|
|
101
|
+
|
|
102
|
+
for t in before_t - restored_t:
|
|
103
|
+
issues.append(SchemaIssue(t, f"Table '{t}' missing after rollback", "error"))
|
|
104
|
+
|
|
105
|
+
for t in restored_t - before_t:
|
|
106
|
+
issues.append(
|
|
107
|
+
SchemaIssue(t, f"Table '{t}' still exists after rollback — downgrade is incomplete", "error")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
for t in before_t & restored_t:
|
|
111
|
+
before_c = set(before.tables[t].columns)
|
|
112
|
+
restored_c = set(after_rollback.tables[t].columns)
|
|
113
|
+
for col in before_c - restored_c:
|
|
114
|
+
issues.append(SchemaIssue(t, f"Column '{t}.{col}' missing after rollback", "error"))
|
|
115
|
+
for col in restored_c - before_c:
|
|
116
|
+
issues.append(
|
|
117
|
+
SchemaIssue(t, f"Column '{t}.{col}' still present after rollback — downgrade is incomplete", "warning")
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return issues
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import date, datetime, time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import inspect, text
|
|
8
|
+
from sqlalchemy.engine import Engine
|
|
9
|
+
|
|
10
|
+
from .schema import ColumnInfo, TableInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _generate_value(col: ColumnInfo, index: int) -> Any:
|
|
14
|
+
t = col.type_str.upper()
|
|
15
|
+
if any(x in t for x in ("INT", "SERIAL", "BIGINT", "SMALLINT")):
|
|
16
|
+
return index * 1000 + 1
|
|
17
|
+
if any(x in t for x in ("FLOAT", "DOUBLE", "REAL", "NUMERIC", "DECIMAL")):
|
|
18
|
+
return float(index * 1000 + 1)
|
|
19
|
+
if "BOOL" in t:
|
|
20
|
+
return True
|
|
21
|
+
if "UUID" in t:
|
|
22
|
+
return str(uuid.UUID(int=index + 10**12))
|
|
23
|
+
if "JSON" in t:
|
|
24
|
+
return '{"mrt": true}'
|
|
25
|
+
if any(x in t for x in ("BYTEA", "BLOB", "BINARY")):
|
|
26
|
+
return b"mrt_seed"
|
|
27
|
+
if "TIMESTAMP" in t or "DATETIME" in t:
|
|
28
|
+
return datetime(2024, 1, 1, index % 24, 0, 0)
|
|
29
|
+
if "DATE" in t:
|
|
30
|
+
return date(2024, 1, index % 28 + 1)
|
|
31
|
+
if "TIME" in t:
|
|
32
|
+
return time(index % 24, 0, 0)
|
|
33
|
+
if any(x in t for x in ("VARCHAR", "TEXT", "CHAR", "STRING", "CLOB")):
|
|
34
|
+
m = re.search(r"\((\d+)\)", t)
|
|
35
|
+
limit = int(m.group(1)) if m else 255
|
|
36
|
+
val = f"mrt_seed_{index:05d}"
|
|
37
|
+
return val[:limit]
|
|
38
|
+
return f"mrt_{index}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _topological_order(tables: dict[str, TableInfo]) -> list[str]:
|
|
42
|
+
"""Return table names ordered so FK parents come before children."""
|
|
43
|
+
order: list[str] = []
|
|
44
|
+
visited: set[str] = set()
|
|
45
|
+
|
|
46
|
+
def visit(name: str) -> None:
|
|
47
|
+
if name in visited:
|
|
48
|
+
return
|
|
49
|
+
visited.add(name)
|
|
50
|
+
for parent in tables.get(name, TableInfo(name)).fk_tables:
|
|
51
|
+
if parent in tables:
|
|
52
|
+
visit(parent)
|
|
53
|
+
order.append(name)
|
|
54
|
+
|
|
55
|
+
for name in tables:
|
|
56
|
+
visit(name)
|
|
57
|
+
return order
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SmartSeeder:
|
|
61
|
+
def __init__(self, engine: Engine):
|
|
62
|
+
self.engine = engine
|
|
63
|
+
# table_name -> list of (pk_col, pk_val)
|
|
64
|
+
self._seeded: dict[str, list[Any]] = {}
|
|
65
|
+
|
|
66
|
+
def seed_all(self, tables: dict[str, TableInfo], count: int = 3) -> None:
|
|
67
|
+
for tname in _topological_order(tables):
|
|
68
|
+
self.seed_table(tables[tname], count)
|
|
69
|
+
|
|
70
|
+
def seed_table(self, table: TableInfo, count: int = 3) -> None:
|
|
71
|
+
if not table.pk_cols:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
pk_col = table.pk_cols[0]
|
|
75
|
+
inserted_pks: list[Any] = []
|
|
76
|
+
|
|
77
|
+
for i in range(count):
|
|
78
|
+
row: dict[str, Any] = {}
|
|
79
|
+
for col_name, col_info in table.columns.items():
|
|
80
|
+
if col_info.primary_key and any(
|
|
81
|
+
x in col_info.type_str.upper() for x in ("SERIAL", "AUTOINCREMENT")
|
|
82
|
+
):
|
|
83
|
+
continue
|
|
84
|
+
if not col_info.nullable and col_info.default is None:
|
|
85
|
+
row[col_name] = _generate_value(col_info, i + len(self._seeded) * 100)
|
|
86
|
+
elif not col_info.nullable:
|
|
87
|
+
row[col_name] = _generate_value(col_info, i + len(self._seeded) * 100)
|
|
88
|
+
|
|
89
|
+
if not row:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
cols = ", ".join(f'"{c}"' for c in row)
|
|
93
|
+
placeholders = ", ".join(f":{c}" for c in row)
|
|
94
|
+
stmt = text(f'INSERT INTO "{table.name}" ({cols}) VALUES ({placeholders})')
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
with self.engine.begin() as conn:
|
|
98
|
+
conn.execute(stmt, row)
|
|
99
|
+
if pk_col in row:
|
|
100
|
+
inserted_pks.append(row[pk_col])
|
|
101
|
+
else:
|
|
102
|
+
# Auto-generated PK — fetch last inserted
|
|
103
|
+
result = conn.execute(
|
|
104
|
+
text(f'SELECT "{pk_col}" FROM "{table.name}" ORDER BY "{pk_col}" DESC LIMIT 1')
|
|
105
|
+
)
|
|
106
|
+
val = result.scalar()
|
|
107
|
+
if val is not None:
|
|
108
|
+
inserted_pks.append(val)
|
|
109
|
+
except Exception:
|
|
110
|
+
# FK or constraint we can't satisfy — skip this table
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
if inserted_pks:
|
|
114
|
+
self._seeded.setdefault(table.name, []).extend(inserted_pks)
|
|
115
|
+
|
|
116
|
+
def verify(self) -> list[str]:
|
|
117
|
+
failures: list[str] = []
|
|
118
|
+
with self.engine.connect() as conn:
|
|
119
|
+
insp = inspect(conn)
|
|
120
|
+
existing = set(insp.get_table_names())
|
|
121
|
+
|
|
122
|
+
for tname, pk_vals in self._seeded.items():
|
|
123
|
+
if tname not in existing:
|
|
124
|
+
failures.append(f"Table '{tname}' no longer exists after rollback — all data lost")
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
with self.engine.connect() as conn:
|
|
128
|
+
insp = inspect(conn)
|
|
129
|
+
pk_info = insp.get_pk_constraint(tname)
|
|
130
|
+
pk_cols = pk_info.get("constrained_columns", [])
|
|
131
|
+
if not pk_cols:
|
|
132
|
+
continue
|
|
133
|
+
pk_col = pk_cols[0]
|
|
134
|
+
|
|
135
|
+
placeholders = ", ".join(f":v{i}" for i in range(len(pk_vals)))
|
|
136
|
+
params = {f"v{i}": v for i, v in enumerate(pk_vals)}
|
|
137
|
+
result = conn.execute(
|
|
138
|
+
text(f'SELECT COUNT(*) FROM "{tname}" WHERE "{pk_col}" IN ({placeholders})'),
|
|
139
|
+
params,
|
|
140
|
+
)
|
|
141
|
+
found = result.scalar() or 0
|
|
142
|
+
|
|
143
|
+
if found < len(pk_vals):
|
|
144
|
+
lost = len(pk_vals) - found
|
|
145
|
+
failures.append(
|
|
146
|
+
f"Table '{tname}': {lost}/{len(pk_vals)} row(s) lost after rollback"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return failures
|
|
150
|
+
|
|
151
|
+
def reset(self) -> None:
|
|
152
|
+
self._seeded.clear()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from .runner import MigrationRunner
|
|
5
|
+
from .schema import SchemaSnapshot, SchemaDiff
|
|
6
|
+
from .seeder import SmartSeeder
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RevisionResult:
|
|
11
|
+
revision: str
|
|
12
|
+
passed: bool
|
|
13
|
+
failures: list[str] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
def failure_summary(self) -> str:
|
|
16
|
+
return "\n".join(f" - {f}" for f in self.failures)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RollbackVerifier:
|
|
20
|
+
"""
|
|
21
|
+
For each revision under test:
|
|
22
|
+
1. Seed data into tables that exist BEFORE this migration (pre-upgrade state)
|
|
23
|
+
2. Upgrade to the revision
|
|
24
|
+
3. Downgrade one step
|
|
25
|
+
4. Verify schema is exactly restored + seeded data survived
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, runner: MigrationRunner):
|
|
29
|
+
self.runner = runner
|
|
30
|
+
|
|
31
|
+
def check_revision(self, revision: str) -> RevisionResult:
|
|
32
|
+
seeder = SmartSeeder(self.runner.engine)
|
|
33
|
+
failures: list[str] = []
|
|
34
|
+
|
|
35
|
+
# Capture state before this migration
|
|
36
|
+
schema_before = SchemaSnapshot.capture(self.runner.engine)
|
|
37
|
+
|
|
38
|
+
# Seed into pre-existing tables — these rows must survive rollback
|
|
39
|
+
seeder.seed_all(schema_before.tables)
|
|
40
|
+
|
|
41
|
+
# Apply and then revert the migration
|
|
42
|
+
self.runner.upgrade(revision)
|
|
43
|
+
self.runner.downgrade()
|
|
44
|
+
|
|
45
|
+
schema_restored = SchemaSnapshot.capture(self.runner.engine)
|
|
46
|
+
|
|
47
|
+
# Schema must be exactly as before (no missing tables, no leftover tables)
|
|
48
|
+
diff = SchemaDiff()
|
|
49
|
+
for issue in diff.verify_restored(schema_before, schema_restored):
|
|
50
|
+
failures.append(issue.message)
|
|
51
|
+
|
|
52
|
+
# Data seeded before upgrade must survive the round-trip
|
|
53
|
+
failures.extend(seeder.verify())
|
|
54
|
+
|
|
55
|
+
return RevisionResult(
|
|
56
|
+
revision=revision,
|
|
57
|
+
passed=len(failures) == 0,
|
|
58
|
+
failures=failures,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def check_all(self) -> list[RevisionResult]:
|
|
62
|
+
"""Test every revision independently in sequence."""
|
|
63
|
+
results: list[RevisionResult] = []
|
|
64
|
+
self.runner.downgrade_base()
|
|
65
|
+
|
|
66
|
+
for rev in self.runner.get_revisions():
|
|
67
|
+
result = self.check_revision(rev.revision)
|
|
68
|
+
results.append(result)
|
|
69
|
+
# Advance to this revision so the next check starts from correct state
|
|
70
|
+
self.runner.upgrade(rev.revision)
|
|
71
|
+
|
|
72
|
+
return results
|
pytest_mrt/plugin.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from .config import MRTConfig
|
|
5
|
+
from .core.runner import MigrationRunner
|
|
6
|
+
from .core.seeder import SmartSeeder
|
|
7
|
+
from .core.verifier import RevisionResult, RollbackVerifier
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MRTFixture:
|
|
11
|
+
def __init__(self, config: MRTConfig):
|
|
12
|
+
self._runner = MigrationRunner(config.alembic_ini, config.db_url)
|
|
13
|
+
self._seeder = SmartSeeder(self._runner.engine)
|
|
14
|
+
self._verifier = RollbackVerifier(self._runner)
|
|
15
|
+
|
|
16
|
+
# ── migration control ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
def upgrade(self, revision: str = "head") -> None:
|
|
19
|
+
self._runner.upgrade(revision)
|
|
20
|
+
|
|
21
|
+
def downgrade(self, revision: str = "-1") -> None:
|
|
22
|
+
self._runner.downgrade(revision)
|
|
23
|
+
|
|
24
|
+
# ── manual seeding ────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def seed(self, table: str, rows: list[dict], pk_col: str = "id") -> None:
|
|
27
|
+
from .core.schema import SchemaSnapshot
|
|
28
|
+
snap = SchemaSnapshot.capture(self._runner.engine)
|
|
29
|
+
if table in snap.tables:
|
|
30
|
+
self._seeder.seed_table(snap.tables[table])
|
|
31
|
+
else:
|
|
32
|
+
raise ValueError(f"Table '{table}' not found in current schema")
|
|
33
|
+
|
|
34
|
+
# ── assertions ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def assert_data_intact(self) -> None:
|
|
37
|
+
failures = self._seeder.verify()
|
|
38
|
+
if failures:
|
|
39
|
+
pytest.fail("Rollback caused data loss:\n" + "\n".join(f" - {f}" for f in failures))
|
|
40
|
+
|
|
41
|
+
def check_revision(self, revision: str) -> RevisionResult:
|
|
42
|
+
return self._verifier.check_revision(revision)
|
|
43
|
+
|
|
44
|
+
def check_all(self) -> list[RevisionResult]:
|
|
45
|
+
return self._verifier.check_all()
|
|
46
|
+
|
|
47
|
+
def assert_reversible(self, revision: str = "head") -> None:
|
|
48
|
+
result = self._verifier.check_revision(revision)
|
|
49
|
+
if not result.passed:
|
|
50
|
+
pytest.fail(f"Migration {revision} is not safely reversible:\n{result.failure_summary()}")
|
|
51
|
+
|
|
52
|
+
def assert_all_reversible(self) -> None:
|
|
53
|
+
from .reporter import print_check_all_summary
|
|
54
|
+
results = self._verifier.check_all()
|
|
55
|
+
print_check_all_summary(results)
|
|
56
|
+
failed = [r for r in results if not r.passed]
|
|
57
|
+
if failed:
|
|
58
|
+
lines = []
|
|
59
|
+
for r in failed:
|
|
60
|
+
lines.append(f" revision {r.revision}:")
|
|
61
|
+
lines.append(r.failure_summary())
|
|
62
|
+
pytest.fail("Some migrations are not safely reversible:\n" + "\n".join(lines))
|
|
63
|
+
|
|
64
|
+
def reset(self) -> None:
|
|
65
|
+
self._seeder.reset()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
69
|
+
config.addinivalue_line("markers", "mrt: migration rollback test")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def mrt(request: pytest.FixtureRequest) -> MRTFixture:
|
|
74
|
+
cfg: MRTConfig = getattr(request.config, "_mrt_config", None) or MRTConfig()
|
|
75
|
+
fixture = MRTFixture(cfg)
|
|
76
|
+
yield fixture
|
|
77
|
+
fixture.reset()
|
pytest_mrt/reporter.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from rich import box
|
|
7
|
+
|
|
8
|
+
from .core.verifier import RevisionResult
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def print_revision_result(result: RevisionResult) -> None:
|
|
14
|
+
if result.passed:
|
|
15
|
+
console.print(f" [green]✓[/green] [bold]{result.revision}[/bold] [dim]reversible[/dim]")
|
|
16
|
+
else:
|
|
17
|
+
console.print(f" [red]✗[/red] [bold]{result.revision}[/bold] [red]data loss detected[/red]")
|
|
18
|
+
for f in result.failures:
|
|
19
|
+
console.print(f" [dim]└─[/dim] [red]{f}[/red]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_check_all_summary(results: list[RevisionResult]) -> None:
|
|
23
|
+
passed = [r for r in results if r.passed]
|
|
24
|
+
failed = [r for r in results if not r.passed]
|
|
25
|
+
|
|
26
|
+
console.print()
|
|
27
|
+
console.rule("[bold]MRT — Migration Rollback Test[/bold]", style="dim")
|
|
28
|
+
console.print()
|
|
29
|
+
|
|
30
|
+
for result in results:
|
|
31
|
+
print_revision_result(result)
|
|
32
|
+
|
|
33
|
+
console.print()
|
|
34
|
+
|
|
35
|
+
if not failed:
|
|
36
|
+
console.print(Panel(
|
|
37
|
+
f"[green]All {len(results)} migration(s) are safely reversible.[/green]",
|
|
38
|
+
border_style="green",
|
|
39
|
+
))
|
|
40
|
+
else:
|
|
41
|
+
lines = Text()
|
|
42
|
+
lines.append(f"{len(failed)} migration(s) will cause data loss on rollback.\n\n", style="red bold")
|
|
43
|
+
for r in failed:
|
|
44
|
+
lines.append(f" {r.revision}\n", style="red")
|
|
45
|
+
for f in r.failures:
|
|
46
|
+
lines.append(f" └─ {f}\n", style="dim")
|
|
47
|
+
console.print(Panel(lines, border_style="red", title="[red]Rollback Unsafe[/red]"))
|
|
48
|
+
|
|
49
|
+
console.print()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_static_check_header(versions_dir: str) -> None:
|
|
53
|
+
console.print()
|
|
54
|
+
console.rule(f"[bold]MRT static analysis[/bold] [dim]{versions_dir}[/dim]", style="dim")
|
|
55
|
+
console.print()
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-mrt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Catch database migration rollback failures before they reach production
|
|
5
|
+
Project-URL: Homepage, https://github.com/croc100/pytest-mrt
|
|
6
|
+
Project-URL: Repository, https://github.com/croc100/pytest-mrt
|
|
7
|
+
Project-URL: Issues, https://github.com/croc100/pytest-mrt/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/croc100/pytest-mrt/releases
|
|
9
|
+
Author: croc100
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 croc100
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: alembic,database,migrations,pytest,rollback,sqlalchemy,testing
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Framework :: Pytest
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Topic :: Database
|
|
43
|
+
Classifier: Topic :: Software Development :: Testing
|
|
44
|
+
Requires-Python: >=3.10
|
|
45
|
+
Requires-Dist: alembic>=1.9
|
|
46
|
+
Requires-Dist: pytest>=7.0
|
|
47
|
+
Requires-Dist: rich>=13.0
|
|
48
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
49
|
+
Requires-Dist: typer>=0.9
|
|
50
|
+
Provides-Extra: asyncpg
|
|
51
|
+
Requires-Dist: asyncpg; extra == 'asyncpg'
|
|
52
|
+
Provides-Extra: dev
|
|
53
|
+
Requires-Dist: hatch; extra == 'dev'
|
|
54
|
+
Requires-Dist: psycopg2-binary; extra == 'dev'
|
|
55
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
56
|
+
Provides-Extra: postgres
|
|
57
|
+
Requires-Dist: psycopg2-binary; extra == 'postgres'
|
|
58
|
+
Description-Content-Type: text/markdown
|
|
59
|
+
|
|
60
|
+
# pytest-mrt
|
|
61
|
+
|
|
62
|
+
<p align="center">
|
|
63
|
+
<strong>Migration Rollback Tester</strong><br>
|
|
64
|
+
Catch database migration disasters before they reach production.
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
<p align="center">
|
|
68
|
+
<a href="https://pypi.org/project/pytest-mrt"><img src="https://img.shields.io/pypi/v/pytest-mrt?color=blue" alt="PyPI"></a>
|
|
69
|
+
<a href="https://github.com/croc100/pytest-mrt/actions"><img src="https://img.shields.io/github/actions/workflow/status/croc100/pytest-mrt/ci.yml?branch=main" alt="CI"></a>
|
|
70
|
+
<a href="https://pypi.org/project/pytest-mrt"><img src="https://img.shields.io/pypi/pyversions/pytest-mrt" alt="Python"></a>
|
|
71
|
+
<a href="https://github.com/croc100/pytest-mrt/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License"></a>
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## The problem
|
|
77
|
+
|
|
78
|
+
It's 2am. Your new feature is deployed. Something is wrong. You run `alembic downgrade -1`.
|
|
79
|
+
|
|
80
|
+
The command succeeds. But the data is gone.
|
|
81
|
+
|
|
82
|
+
The column came back. The rows didn't.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
This happens because **most tools only check if your migration runs without errors** — not whether your data survives the round-trip. `alembic downgrade` can succeed while silently destroying everything it was supposed to restore.
|
|
87
|
+
|
|
88
|
+
**pytest-mrt** tests the full cycle: seed real data → upgrade → downgrade → verify nothing was lost.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install pytest-mrt
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Quickstart
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
# conftest.py
|
|
104
|
+
from pytest_mrt import MRTConfig
|
|
105
|
+
|
|
106
|
+
def pytest_configure(config):
|
|
107
|
+
config._mrt_config = MRTConfig(
|
|
108
|
+
alembic_ini="alembic.ini",
|
|
109
|
+
db_url="postgresql://localhost/myapp_test",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# test_migrations.py
|
|
115
|
+
def test_all_migrations_are_reversible(mrt):
|
|
116
|
+
mrt.assert_all_reversible()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
$ pytest test_migrations.py -s
|
|
121
|
+
|
|
122
|
+
──────────── MRT — Migration Rollback Test ────────────
|
|
123
|
+
|
|
124
|
+
✓ 001 reversible
|
|
125
|
+
✓ 002 reversible
|
|
126
|
+
✓ 003 reversible
|
|
127
|
+
✗ 004 data loss detected
|
|
128
|
+
└─ Table 'users': 3/3 rows lost after rollback
|
|
129
|
+
✗ 005 data loss detected
|
|
130
|
+
└─ Table 'users' still exists after rollback — downgrade is incomplete
|
|
131
|
+
|
|
132
|
+
╭─────────────────────────────────────────────────────╮
|
|
133
|
+
│ 2 migration(s) will cause data loss on rollback. │
|
|
134
|
+
│ 004 │
|
|
135
|
+
│ └─ Table 'users': 3/3 rows lost after rollback │
|
|
136
|
+
│ 005 │
|
|
137
|
+
│ └─ Table 'users' still exists after rollback │
|
|
138
|
+
╰─────────────────────────────────────────────────────╯
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## What it catches
|
|
144
|
+
|
|
145
|
+
### Static analysis — before you even run
|
|
146
|
+
|
|
147
|
+
| Pattern | Severity | Why it's dangerous |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `op.drop_column()` in upgrade | 🔴 error | Column data is permanently gone |
|
|
150
|
+
| `op.drop_table()` in upgrade | 🔴 error | All table data is permanently gone |
|
|
151
|
+
| `TRUNCATE` in migration | 🔴 error | Destroys data with no undo |
|
|
152
|
+
| `def downgrade(): pass` | 🔴 error | Rollback silently does nothing |
|
|
153
|
+
| No `downgrade()` function | 🔴 error | Migration is completely irreversible |
|
|
154
|
+
| `RunPython` without `reverse_func` | 🔴 error | Data transformation cannot be undone |
|
|
155
|
+
| `NOT NULL` without `server_default` | 🟡 warning | Will fail on non-empty tables |
|
|
156
|
+
| `ALTER COLUMN type_=...` | 🟡 warning | Type conversion may lose data |
|
|
157
|
+
| `op.execute()` with raw SQL | 🟡 warning | Cannot verify reversibility |
|
|
158
|
+
| Bulk `UPDATE` without reverse | 🟡 warning | One-way data transformation |
|
|
159
|
+
| `ON DELETE CASCADE` added | 🟡 warning | Child rows silently deleted |
|
|
160
|
+
| `CREATE INDEX` without `CONCURRENTLY` | 🟡 warning | Locks table during index build |
|
|
161
|
+
| `ADD COLUMN` with `DEFAULT` | 🟡 warning | Full table rewrite on PostgreSQL < 11 |
|
|
162
|
+
| `CREATE UNIQUE CONSTRAINT` | 🟡 warning | Will fail if duplicates exist |
|
|
163
|
+
| `NOT NULL` without restoring `nullable` | 🟡 warning | Downgrade leaves column in wrong state |
|
|
164
|
+
|
|
165
|
+
Run static analysis without a database:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
mrt check migrations/versions/
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
173
|
+
│ Rollback Risk Analysis │
|
|
174
|
+
├──────────┬──────────────────────┬─────────────┬─────────────────────────── │
|
|
175
|
+
│ Revision │ Pattern │ Sev │ Message │
|
|
176
|
+
├──────────┼──────────────────────┼─────────────┼─────────────────────────── │
|
|
177
|
+
│ 004 │ DROP COLUMN │ error │ Data loss on rollback │
|
|
178
|
+
│ 005 │ No-op downgrade │ error │ downgrade() does nothing │
|
|
179
|
+
│ 006 │ INDEX without CONC. │ warning │ Locks table during build │
|
|
180
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
181
|
+
2 error(s), 1 warning(s)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Dynamic verification — with real data
|
|
185
|
+
|
|
186
|
+
pytest-mrt seeds actual rows before each migration, then checks they survive the downgrade:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
def test_specific_revision(mrt):
|
|
190
|
+
result = mrt.check_revision("abc123")
|
|
191
|
+
assert result.passed, result.failure_summary()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Or test everything at once:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
def test_all_migrations(mrt):
|
|
198
|
+
mrt.assert_all_reversible()
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## How it works
|
|
204
|
+
|
|
205
|
+
For each migration revision, pytest-mrt:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
1. Capture schema at current state
|
|
209
|
+
2. Seed real data into all existing tables
|
|
210
|
+
3. Run upgrade to this revision
|
|
211
|
+
4. Run downgrade (one step back)
|
|
212
|
+
5. Verify schema is exactly restored
|
|
213
|
+
6. Verify every seeded row survived
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
This catches failures that syntax checks miss:
|
|
217
|
+
- Schema comes back, but seeded rows are gone → **data loss**
|
|
218
|
+
- Downgrade is a no-op, table still exists → **rollback did nothing**
|
|
219
|
+
- Column returns but with wrong type → **schema drift**
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Supported databases
|
|
224
|
+
|
|
225
|
+
| Database | Status |
|
|
226
|
+
|---|---|
|
|
227
|
+
| PostgreSQL | ✅ Full support |
|
|
228
|
+
| SQLite | ✅ Full support (great for CI) |
|
|
229
|
+
| MySQL / MariaDB | 🔜 Planned |
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## CI integration
|
|
234
|
+
|
|
235
|
+
Add to your GitHub Actions workflow:
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
- name: Test migration rollbacks
|
|
239
|
+
run: pytest tests/test_migrations.py -v -s
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Or use the static check as a fast pre-flight:
|
|
243
|
+
|
|
244
|
+
```yaml
|
|
245
|
+
- name: Static migration analysis
|
|
246
|
+
run: mrt check migrations/versions/ --strict
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`--strict` makes warnings fail the build, not just errors.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Configuration
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
# conftest.py
|
|
257
|
+
from pytest_mrt import MRTConfig
|
|
258
|
+
|
|
259
|
+
def pytest_configure(config):
|
|
260
|
+
config._mrt_config = MRTConfig(
|
|
261
|
+
alembic_ini="alembic.ini", # path to alembic.ini
|
|
262
|
+
db_url="postgresql://...", # test database URL
|
|
263
|
+
seed_rows=5, # rows to seed per table (default: 3)
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Use environment variables for CI:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
import os
|
|
271
|
+
from pytest_mrt import MRTConfig
|
|
272
|
+
|
|
273
|
+
def pytest_configure(config):
|
|
274
|
+
config._mrt_config = MRTConfig(
|
|
275
|
+
alembic_ini="alembic.ini",
|
|
276
|
+
db_url=os.environ["TEST_DATABASE_URL"],
|
|
277
|
+
)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Examples
|
|
283
|
+
|
|
284
|
+
See [`examples/blog/`](examples/blog/) for a complete working example with:
|
|
285
|
+
- Safe migrations (add nullable column, create table)
|
|
286
|
+
- Dangerous migrations (drop column with data, no-op downgrade)
|
|
287
|
+
- How pytest-mrt catches each failure
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
cd examples/blog
|
|
291
|
+
pip install pytest-mrt
|
|
292
|
+
pytest test_migrations.py -v -s
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## FAQ
|
|
298
|
+
|
|
299
|
+
**Does it modify my production database?**
|
|
300
|
+
No. pytest-mrt only runs against the database URL you provide in `MRTConfig`. Always use a test database.
|
|
301
|
+
|
|
302
|
+
**Does it work with Django migrations?**
|
|
303
|
+
Django support is on the roadmap. Currently only Alembic is supported.
|
|
304
|
+
|
|
305
|
+
**How is this different from pytest-alembic?**
|
|
306
|
+
`pytest-alembic` checks that migrations run without errors and that your schema matches your models. It does **not** verify that data survives a rollback. pytest-mrt focuses specifically on that gap.
|
|
307
|
+
|
|
308
|
+
**My migration intentionally drops a column. Will this always fail?**
|
|
309
|
+
Yes — dropping a column destroys data. That's exactly what pytest-mrt warns you about. If you want to proceed, you can exclude specific revisions or mark the test as expected-to-fail.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Roadmap
|
|
314
|
+
|
|
315
|
+
- [x] Alembic support
|
|
316
|
+
- [x] Static risk analysis CLI (`mrt check`)
|
|
317
|
+
- [x] Dynamic data integrity verification
|
|
318
|
+
- [x] GitHub Actions CI
|
|
319
|
+
- [ ] Django Migrations support
|
|
320
|
+
- [ ] MySQL / MariaDB support
|
|
321
|
+
- [ ] HTML report output
|
|
322
|
+
- [ ] Per-revision exclusions (`@mrt.skip("004", reason="...")`)
|
|
323
|
+
- [ ] PyPI release
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Contributing
|
|
328
|
+
|
|
329
|
+
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## License
|
|
334
|
+
|
|
335
|
+
Apache 2.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pytest_mrt/__init__.py,sha256=Z9a-gTCwskapo7A8bauDfG77l447IrcD1bnozyZOL7I,77
|
|
2
|
+
pytest_mrt/cli.py,sha256=pkXGYwbZf6Ki-RwICBlP2iMmqOEdBsf4d0TpYLxchNY,1957
|
|
3
|
+
pytest_mrt/config.py,sha256=50-JZLptavXqNQLwWFZMvs36s_7V5aM-xEyKalT0gBs,153
|
|
4
|
+
pytest_mrt/plugin.py,sha256=0hhCbex9GX_iIdLIHsnyrh5ngwtHW9RlLfSiJwmrEbw,3139
|
|
5
|
+
pytest_mrt/reporter.py,sha256=MBJYLw_0cZCwF_32RN0iO5XGKbiP6a4q62OPFVE84s4,1821
|
|
6
|
+
pytest_mrt/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pytest_mrt/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pytest_mrt/core/detector.py,sha256=8xhvqJUK2TW-oKtPTM7weeQSldPpxyMKsf5FiyxKVZM,10485
|
|
9
|
+
pytest_mrt/core/runner.py,sha256=INmvcT6rhPudxOlcuKyq2j7ewOcyj49_c_4Enab78RQ,1446
|
|
10
|
+
pytest_mrt/core/schema.py,sha256=_pTMqqUFicU8Wi7TcZOKXpPLkDsoqn4c96MK9mLBYyE,4322
|
|
11
|
+
pytest_mrt/core/seeder.py,sha256=1n_-VmMAbEr924IjxfLiJYqMZKseJNeW7A5vLuRcAUM,5456
|
|
12
|
+
pytest_mrt/core/verifier.py,sha256=pI3NGSxDcXd28xfbFH-K0XVLWDqUdy38jK7h81GxyV8,2315
|
|
13
|
+
pytest_mrt-0.1.0.dist-info/METADATA,sha256=Jxb0M37GqTy2iIXJM-j_3_ztorxsKMDoPBTLy3wzYKk,12029
|
|
14
|
+
pytest_mrt-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
15
|
+
pytest_mrt-0.1.0.dist-info/entry_points.txt,sha256=sXUH1xz6G48-QRXBUpvQnTr_LpQwXQMKsLEBP60XCiI,79
|
|
16
|
+
pytest_mrt-0.1.0.dist-info/licenses/LICENSE,sha256=y1u42SEfipXW9_1zN2r6M__JguUyOd7i5L6B1mHyDMc,1064
|
|
17
|
+
pytest_mrt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 croc100
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|