#!/usr/bin/env python3 """Apply SQL migrations stored in ./migrations. Usage: python scripts/apply_migrations.py python scripts/apply_migrations.py --dry-run """ from __future__ import annotations import argparse from pathlib import Path import os import sys PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT)) os.chdir(PROJECT_ROOT) from sqlalchemy import text from app.db import engine ROOT = Path(__file__).resolve().parents[1] MIGRATIONS_DIR = ROOT / "migrations" def ensure_ledger(conn) -> None: conn.execute(text(""" CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true", help="List pending migrations without applying them.") args = parser.parse_args() files = sorted(MIGRATIONS_DIR.glob("*.sql")) if not files: print("No migrations found.") return 0 with engine.begin() as conn: ensure_ledger(conn) applied = { row[0] for row in conn.execute(text("SELECT version FROM schema_migrations")) } pending = [] for path in files: version = path.stem.split("_", 1)[0] if version not in applied: pending.append((version, path)) if not pending: print("No pending migrations.") return 0 print("Pending migrations:") for version, path in pending: print(f"- {version}: {path.name}") if args.dry_run: return 0 for version, path in pending: sql = path.read_text() print(f"Applying {path.name}...") conn.execute(text(sql)) conn.execute(text(""" INSERT INTO schema_migrations(version, name) VALUES (:version, :name) ON CONFLICT (version) DO NOTHING """), {"version": version, "name": path.name}) print("Migrations applied.") return 0 if __name__ == "__main__": raise SystemExit(main())