"""check_schema.py — verify the live DB schema matches SQLModel model definitions. Connects to the database, inspects every table and column defined in the SQLModel metadata, and reports anything that is absent in the real schema. Exits 1 if drift is found, 0 if clean. Usage: python scripts/check_schema.py DATABASE_URL=postgresql+psycopg://... python scripts/check_schema.py This catches the class of problem where Alembic's version table says the DB is up-to-date but columns were never actually created (e.g. a migration was inserted into the chain after the DB had already advanced past it). """ from __future__ import annotations import os import sys from pathlib import Path # Make the app package importable when run directly. sys.path.insert(0, str(Path(__file__).resolve().parents[1])) def _resolve_database_url() -> str | None: url = os.getenv("DATABASE_URL") if url: return url env_file = Path(__file__).resolve().parents[1] / ".env" if env_file.exists(): for line in env_file.read_text().splitlines(): line = line.strip() if line.startswith("DATABASE_URL=") and not line.startswith("#"): return line.split("=", 1)[1].strip() return None def _to_sync_url(url: str) -> str: """Convert async driver URL to sync equivalent for inspection.""" replacements = [ ("postgresql+asyncpg://", "postgresql+psycopg://"), ("postgresql+aiosqlite://", "sqlite:///"), ("postgresql://", "postgresql+psycopg://"), ("postgres://", "postgresql+psycopg://"), ] for old, new in replacements: if url.startswith(old): return new + url[len(old):] return url def main() -> int: from sqlalchemy import create_engine from sqlalchemy import inspect as sa_inspect from sqlmodel import SQLModel # Import all models so SQLModel.metadata is fully populated. import app.models as _ # noqa: F401 database_url = _resolve_database_url() if not database_url: print("ERROR: DATABASE_URL not set and not found in .env") return 1 sync_url = _to_sync_url(database_url) try: engine = create_engine(sync_url, pool_pre_ping=True) inspector = sa_inspect(engine) except Exception as exc: print(f"ERROR: could not connect to database: {exc}") return 1 missing_tables: list[str] = [] missing_columns: list[str] = [] try: for table_name, table in SQLModel.metadata.tables.items(): if not inspector.has_table(table_name): missing_tables.append(table_name) continue db_columns = {col["name"] for col in inspector.get_columns(table_name)} model_columns = {col.name for col in table.columns} for col in sorted(model_columns - db_columns): missing_columns.append(f"{table_name}.{col}") except Exception as exc: print(f"ERROR: failed to inspect schema: {exc}") return 1 finally: engine.dispose() if not missing_tables and not missing_columns: model_table_count = len(SQLModel.metadata.tables) print(f"OK: all {model_table_count} model tables and their columns exist in the database") return 0 print("SCHEMA DRIFT DETECTED\n") if missing_tables: print("Missing tables (exist in models, not in DB):") for t in sorted(missing_tables): print(f" - {t}") print() if missing_columns: print("Missing columns (exist in models, not in DB):") for c in missing_columns: print(f" - {c}") print() print("Possible causes:") print(" 1. A migration was inserted into the chain after the DB had already") print(" advanced past it — run the migration manually or add the columns.") print(" 2. A new model field has no migration yet — run:") print(" alembic revision --autogenerate -m 'add '") print(" 3. The DB was populated from an older schema — run:") print(" alembic upgrade head") return 1 if __name__ == "__main__": raise SystemExit(main())