Skip to content

Commit 0aa3e89

Browse files
authored
test: fill coverage completeness gaps (seed E2E, seed_custom, Django mode) (#87)
* fix: seed() crash in Django mode and silent seeder failures - seed() used self._runner.engine which is None in Django mode; switch to self._seeder.engine which is always set - replace bare except/pass in SmartSeeder.seed_table() with warnings.warn so failures are visible instead of swallowed silently Closes #83, #84 * fix: seed() ignored rows param, UNIQUE collisions in check_all, silent custom-seed failures Three root causes fixed: 1. seed() was calling seed_table() (auto-generates rows) instead of inserting the caller-supplied rows. Add SmartSeeder.seed_custom() and wire seed() through it so the rows parameter is actually used. 2. SmartSeeder.seed_table() used a deterministic hash for generated values, causing UNIQUE constraint violations when check_all() seeded the same table across consecutive revision checks (prior rows survive downgrade and remain in the DB). Add a per-instance random offset so each SmartSeeder produces distinct values. 3. Custom-seed insertion in verifier._build_seeder() silently swallowed all exceptions. Replace bare except/pass with warnings.warn so failures are visible. * fix: seeder value generation — prevent UNIQUE collisions for any column type String columns used a fixed-width row_index format that truncates to the same prefix when the instance offset is large (e.g. offset=500000 → "mrt_title_500000"[:10] == "mrt_title_" for all rows). Switch to uuid4().hex[:limit] which is unique regardless of column length. Datetime/date/time types had a narrow modulo range (28 days, 24 hours) causing cross-instance collisions. Spread values over a 10-year window using the full hash seed instead. * feat: add PostgreSQL concurrency safety patterns MRT211-MRT213 Three new static analysis checks for lock-heavy PostgreSQL operations: MRT211 — create_foreign_key() without postgresql_not_valid=True Acquires ShareRowExclusiveLock and scans the referencing table. Safe path: add with NOT VALID, then VALIDATE CONSTRAINT separately. MRT212 — create_check_constraint() without postgresql_not_valid=True Full table scan under AccessShareLock. Same two-step approach applies. MRT213 — alter_column(nullable=False) on an existing column SET NOT NULL triggers a full table scan under AccessExclusiveLock. Safe path on PG 12+: validated CHECK constraint first, then SET NOT NULL. Closes #85 * revert: remove MRT211/MRT212 (lock warnings outside core scope) MRT211 (FK without NOT VALID) and MRT212 (CHECK without NOT VALID) warn about PostgreSQL lock duration — a performance/availability concern, not a data survival or rollback safety concern. That's django-migration-linter territory. MRT213 (SET NOT NULL on existing column) stays: it's about existing NULL data causing migration failure, consistent with MRT401/MRT403. * test: fill coverage gaps — seed() E2E, seed_custom(), Django mode seed(), cli drift * fix: don't track custom seed rows whose INSERT failed (false positive) A failed custom-seed INSERT was caught and warned, but the row had already been appended to the seeder's tracked rows. verify() then reported it as 'lost after rollback', blaming the migration for data the seed never inserted. Track rows only after a successful insert. Also applies ruff format to verifier.py (fixes CI lint failure).
1 parent 02f86e8 commit 0aa3e89

9 files changed

Lines changed: 764 additions & 21 deletions

File tree

pytest_mrt/core/detector.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,39 @@ def _check_create_type_without_drop(m: MigrationAST) -> list[RiskWarning]:
773773
]
774774

775775

776+
def _check_set_not_null_alter_column(m: MigrationAST) -> list[RiskWarning]:
777+
"""
778+
alter_column(nullable=False) issues SET NOT NULL which does a full table
779+
scan on PostgreSQL to verify no NULLs exist. Holds AccessExclusiveLock
780+
for the scan duration.
781+
782+
Safe path (PostgreSQL 12+): add a NOT NULL check constraint with NOT VALID,
783+
validate it, then ALTER COLUMN SET NOT NULL (PostgreSQL skips the scan when
784+
a validated constraint already covers it).
785+
"""
786+
warnings = []
787+
for c in m.upgrade_calls():
788+
if c.method == "alter_column":
789+
nullable = m.kwarg_bool(c.node, "nullable")
790+
if nullable is False:
791+
table = m.str_arg(c.node, 0) or "?"
792+
col = m.str_arg(c.node, 1) or "?"
793+
warnings.append(
794+
_warn(
795+
m,
796+
"SET NOT NULL on existing column",
797+
f"alter_column('{table}', '{col}', nullable=False) runs SET NOT NULL — "
798+
"full table scan with AccessExclusiveLock on PostgreSQL. "
799+
"Safe path on PG 12+: add a NOT NULL check constraint (NOT VALID), "
800+
"validate it, then SET NOT NULL.",
801+
"warning",
802+
line=c.node.lineno,
803+
code="MRT213",
804+
)
805+
)
806+
return warnings
807+
808+
776809
_PER_FILE_CHECKS = [
777810
_check_batch_alter_drop, # first: batch context needs special handling
778811
_check_downgrade_exists,
@@ -803,6 +836,7 @@ def _check_create_type_without_drop(m: MigrationAST) -> list[RiskWarning]:
803836
_check_drop_foreign_key,
804837
_check_create_trigger_without_drop,
805838
_check_create_type_without_drop,
839+
_check_set_not_null_alter_column,
806840
]
807841

808842

pytest_mrt/core/seeder.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

3+
import random
34
import re
45
import uuid
6+
import warnings
57
from dataclasses import dataclass
6-
from datetime import date, datetime, time
8+
from datetime import date, datetime, time, timedelta
79
from decimal import Decimal
810
from typing import Any
911

@@ -121,18 +123,20 @@ def _generate_value(
121123
if any(x in t for x in ("BYTEA", "VARBINARY", "BLOB", "BINARY")):
122124
return f"mrt_{row_index}".encode()
123125
if "TIMESTAMP" in t or "DATETIME" in t:
124-
return datetime(2024, 1, row_index % 28 + 1, row_index % 24, 0, 0)
126+
return datetime(2020, 1, 1) + timedelta(seconds=seed % (10 * 365 * 86400))
125127
if "DATE" in t:
126-
return date(2024, 1, row_index % 28 + 1)
128+
return date(2020, 1, 1) + timedelta(days=seed % (10 * 365))
127129
if "TIME" in t:
128-
return time(row_index % 24, 0, 0)
130+
return time((seed // 3600) % 24, (seed // 60) % 60, seed % 60)
129131
if any(x in t for x in ("VARCHAR", "TEXT", "CHAR", "STRING", "CLOB")):
130132
m = re.search(r"\((\d+)\)", t)
131133
limit = int(m.group(1)) if m else 255
132-
val = f"mrt_{col.name[:8]}_{row_index:04d}"
133-
return val[:limit]
134+
# UUID guarantees uniqueness regardless of column length or row_index magnitude,
135+
# avoiding UNIQUE collisions when the same table is seeded across multiple
136+
# check_revision() calls (prior rows survive downgrade and remain in the DB).
137+
return uuid.uuid4().hex[:limit]
134138

135-
return f"mrt_{row_index}"
139+
return uuid.uuid4().hex
136140

137141

138142
def _normalize_for_compare(val: Any) -> Any:
@@ -207,6 +211,10 @@ class SmartSeeder:
207211
def __init__(self, engine: Engine):
208212
self.engine = engine
209213
self._rows: list[SeededRow] = []
214+
# Random offset makes generated values unique per seeder instance, preventing
215+
# UNIQUE constraint collisions when check_all() seeds the same table multiple times
216+
# (prior check's rows survive downgrade and remain in the DB for the next check).
217+
self._offset = random.randint(0, 10**6)
210218

211219
def _q(self, name: str) -> str:
212220
return _q(self.engine, name)
@@ -248,7 +256,7 @@ def seed_table(self, table: TableInfo, count: int = 3) -> None:
248256
enum_vals = None
249257
if "ENUM" in col_info.type_str.upper():
250258
enum_vals = _get_enum_values(self.engine, table.name, col_name)
251-
val = _generate_value(col_info, row_index, enum_vals)
259+
val = _generate_value(col_info, self._offset + row_index, enum_vals)
252260
if val is not None:
253261
row[col_name] = val
254262
# nullable columns left as NULL intentionally
@@ -297,8 +305,46 @@ def seed_table(self, table: TableInfo, count: int = 3) -> None:
297305
full_row = dict(result.mappings().first() or {})
298306
self._rows.append(SeededRow(table.name, pk_col, pk_val, full_row))
299307

300-
except Exception:
301-
pass
308+
except Exception as exc:
309+
warnings.warn(
310+
f"pytest-mrt: failed to seed row {row_index} into '{table.name}': {exc}",
311+
stacklevel=2,
312+
)
313+
314+
def seed_custom(self, table: str, pk_col: str, rows: list[dict]) -> None:
315+
"""Insert caller-supplied rows and track them for verify()."""
316+
tq = self._q(table)
317+
pkq = self._q(pk_col)
318+
for row in rows:
319+
cols = ", ".join(self._q(c) for c in row)
320+
placeholders = ", ".join(f":p_{c}" for c in row)
321+
params = {f"p_{c}": v for c, v in row.items()}
322+
stmt = text(f"INSERT INTO {tq} ({cols}) VALUES ({placeholders})")
323+
try:
324+
with self.engine.begin() as conn:
325+
conn.execute(stmt, params)
326+
except Exception as exc:
327+
warnings.warn(
328+
f"pytest-mrt: failed to insert custom row into '{table}': {exc}",
329+
stacklevel=2,
330+
)
331+
continue
332+
333+
pk_val = row.get(pk_col)
334+
if pk_val is None:
335+
with self.engine.connect() as conn:
336+
result = conn.execute(
337+
text(f"SELECT {pkq} FROM {tq} ORDER BY {pkq} DESC LIMIT 1")
338+
)
339+
pk_val = result.scalar()
340+
341+
if pk_val is not None:
342+
with self.engine.connect() as conn:
343+
result = conn.execute(
344+
text(f"SELECT * FROM {tq} WHERE {pkq} = :pk"), {"pk": pk_val}
345+
)
346+
full_row = dict(result.mappings().first() or {})
347+
self._rows.append(SeededRow(table, pk_col, pk_val, full_row))
302348

303349
def verify(self) -> list[str]:
304350
"""

pytest_mrt/core/verifier.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ def _build_seeder(self, schema: SchemaSnapshot) -> SmartSeeder:
6363
if tname in self.custom_seeds:
6464
rows = self.custom_seeds[tname]()
6565
pk_col = table_info.pk_cols[0] if table_info.pk_cols else "id"
66-
for row in rows:
67-
seeder._rows.append(
68-
SeededRow(table=tname, pk_col=pk_col, pk_val=row.get(pk_col), data=row)
69-
)
7066
for row in rows:
7167
# Use dialect-aware quoting — fixes MySQL double-quote bug
7268
def q(name: str) -> str:
@@ -83,8 +79,21 @@ def q(name: str) -> str:
8379
text(f"INSERT INTO {q(tname)} ({cols}) VALUES ({placeholders})"),
8480
params,
8581
)
86-
except Exception:
87-
pass
82+
except Exception as exc:
83+
import warnings
84+
85+
warnings.warn(
86+
f"pytest-mrt: failed to insert custom seed row into '{tname}': {exc}",
87+
stacklevel=2,
88+
)
89+
# Insert failed — do NOT track this row, otherwise verify()
90+
# would report it as "lost after rollback" (false positive).
91+
continue
92+
93+
# Only track rows that were actually inserted.
94+
seeder._rows.append(
95+
SeededRow(table=tname, pk_col=pk_col, pk_val=row.get(pk_col), data=row)
96+
)
8897
else:
8998
seeder.seed_table(table_info)
9099
return seeder

pytest_mrt/plugin.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,10 @@ def downgrade(self, revision: str = "-1") -> None:
112112
# ── manual seeding ────────────────────────────────────────────────
113113

114114
def seed(self, table: str, rows: list[dict], pk_col: str = "id") -> None:
115-
snap = SchemaSnapshot.capture(self._runner.engine)
116-
if table in snap.tables:
117-
self._seeder.seed_table(snap.tables[table])
118-
else:
115+
snap = SchemaSnapshot.capture(self._seeder.engine)
116+
if table not in snap.tables:
119117
raise ValueError(f"Table '{table}' not found in current schema")
118+
self._seeder.seed_custom(table, pk_col, rows)
120119

121120
# ── static analysis ───────────────────────────────────────────────
122121

tests/test_cli.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,3 +739,173 @@ def downgrade():
739739
)
740740
result = runner.invoke(app, ["fix", str(f)])
741741
assert "--apply" in result.output
742+
743+
744+
# ── mrt drift ────────────────────────────────────────────────────────
745+
746+
747+
def _alembic_env(tmp_path):
748+
"""Minimal Alembic setup returning (ini_path, versions_dir, db_url)."""
749+
import textwrap as _tw
750+
751+
db = tmp_path / "test.db"
752+
versions = tmp_path / "versions"
753+
versions.mkdir()
754+
env_py = tmp_path / "env.py"
755+
env_py.write_text(
756+
_tw.dedent("""
757+
from alembic import context
758+
from sqlalchemy import engine_from_config, pool
759+
760+
config = context.config
761+
762+
def run_migrations_offline():
763+
url = config.get_main_option("sqlalchemy.url")
764+
context.configure(url=url, literal_binds=True)
765+
with context.begin_transaction():
766+
context.run_migrations()
767+
768+
def run_migrations_online():
769+
connectable = engine_from_config(
770+
config.get_section(config.config_ini_section),
771+
prefix="sqlalchemy.",
772+
poolclass=pool.NullPool,
773+
)
774+
with connectable.connect() as connection:
775+
context.configure(connection=connection)
776+
with context.begin_transaction():
777+
context.run_migrations()
778+
779+
if context.is_offline_mode():
780+
run_migrations_offline()
781+
else:
782+
run_migrations_online()
783+
""")
784+
)
785+
ini = tmp_path / "alembic.ini"
786+
ini.write_text(
787+
_tw.dedent(f"""
788+
[alembic]
789+
script_location = {tmp_path}
790+
sqlalchemy.url = sqlite:///{db}
791+
version_locations = {versions}
792+
""")
793+
)
794+
(versions / "001_create_users.py").write_text(
795+
_tw.dedent("""
796+
revision = '001'
797+
down_revision = None
798+
branch_labels = None
799+
depends_on = None
800+
import sqlalchemy as sa
801+
from alembic import op
802+
def upgrade():
803+
op.create_table('users',
804+
sa.Column('id', sa.Integer, primary_key=True),
805+
sa.Column('name', sa.String(64), nullable=False),
806+
)
807+
def downgrade():
808+
op.drop_table('users')
809+
""")
810+
)
811+
return str(ini), str(versions), f"sqlite:///{db}"
812+
813+
814+
def test_drift_invalid_metadata_path_exits_1(tmp_path):
815+
"""mrt drift with a bad metadata path exits 1 with an error message."""
816+
ini, _, db_url = _alembic_env(tmp_path)
817+
result = runner.invoke(
818+
app,
819+
["drift", "nonexistent.module:Base", "--config", ini, "--db-url", db_url],
820+
)
821+
assert result.exit_code == 1
822+
assert "Error" in result.output
823+
824+
825+
def test_drift_missing_alembic_ini_exits_1(tmp_path):
826+
"""mrt drift exits 1 when alembic.ini does not exist."""
827+
import sys
828+
import types
829+
import sqlalchemy as sa
830+
831+
# Register a valid metadata module so metadata loading succeeds,
832+
# then the missing alembic.ini triggers the expected error.
833+
mod = types.ModuleType("_test_drift_meta_noini")
834+
base = sa.MetaData()
835+
mod.Base = base
836+
sys.modules["_test_drift_meta_noini"] = mod
837+
try:
838+
result = runner.invoke(
839+
app,
840+
["drift", "_test_drift_meta_noini:Base", "--config", str(tmp_path / "nope.ini")],
841+
)
842+
finally:
843+
sys.modules.pop("_test_drift_meta_noini", None)
844+
845+
assert result.exit_code == 1
846+
assert "not found" in result.output.lower() or "no such file" in result.output.lower()
847+
848+
849+
def test_drift_no_drift_exits_0(tmp_path):
850+
"""mrt drift exits 0 and prints success when models match the DB."""
851+
import sqlalchemy as sa
852+
853+
ini, _, db_url = _alembic_env(tmp_path)
854+
855+
# Register the metadata in sys.modules so load_metadata can find it
856+
import sys
857+
import types
858+
859+
mod = types.ModuleType("_mrt_test_models")
860+
meta = sa.MetaData()
861+
sa.Table(
862+
"users",
863+
meta,
864+
sa.Column("id", sa.Integer, primary_key=True),
865+
sa.Column("name", sa.String(64), nullable=False),
866+
)
867+
mod.Base = meta
868+
sys.modules["_mrt_test_models"] = mod
869+
870+
try:
871+
result = runner.invoke(
872+
app,
873+
["drift", "_mrt_test_models:Base", "--config", ini, "--db-url", db_url],
874+
)
875+
finally:
876+
sys.modules.pop("_mrt_test_models", None)
877+
878+
assert result.exit_code == 0
879+
assert "No schema drift" in result.output
880+
881+
882+
def test_drift_with_drift_exits_1(tmp_path):
883+
"""mrt drift exits 1 and lists differences when the model has an extra column."""
884+
import sqlalchemy as sa
885+
import sys
886+
import types
887+
888+
ini, _, db_url = _alembic_env(tmp_path)
889+
890+
mod = types.ModuleType("_mrt_test_drift_models")
891+
meta = sa.MetaData()
892+
sa.Table(
893+
"users",
894+
meta,
895+
sa.Column("id", sa.Integer, primary_key=True),
896+
sa.Column("name", sa.String(64), nullable=False),
897+
sa.Column("email", sa.String(255)), # extra — not in DB
898+
)
899+
mod.Base = meta
900+
sys.modules["_mrt_test_drift_models"] = mod
901+
902+
try:
903+
result = runner.invoke(
904+
app,
905+
["drift", "_mrt_test_drift_models:Base", "--config", ini, "--db-url", db_url],
906+
)
907+
finally:
908+
sys.modules.pop("_mrt_test_drift_models", None)
909+
910+
assert result.exit_code == 1
911+
assert "difference" in result.output.lower()

0 commit comments

Comments
 (0)