Skip to content

Commit 3477a9b

Browse files
nanotaboadaclaude
andcommitted
refactor(migrations): address code review findings (#2)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3cb1ea5 commit 3477a9b

File tree

6 files changed

+46
-45
lines changed

6 files changed

+46
-45
lines changed

CHANGELOG.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ This project uses famous football coaches as release codenames, following an A-Z
4848
configured for async execution with `render_as_batch=True` (SQLite/PostgreSQL
4949
compatible); three migrations: `001` creates the `players` table, `002` seeds
5050
11 Starting XI players, `003` seeds 15 Substitute players (all with
51-
deterministic UUID v5 values); `alembic upgrade head` runs automatically on
52-
app startup via the lifespan handler (#2)
51+
deterministic UUID v5 values); `alembic upgrade head` applied by
52+
`entrypoint.sh` (Docker) or manually for local development (#2)
5353
- `alembic==1.18.4`, `asyncpg==0.31.0` added to dependencies (#2)
5454
- `tests/test_migrations.py`: integration tests for migration downgrade paths —
5555
verifies each step removes only its seeded rows and restores correctly (#2)
@@ -64,15 +64,18 @@ This project uses famous football coaches as release codenames, following an A-Z
6464

6565
### Changed
6666

67-
- `databases/player_database.py`: engine URL now reads `DATABASE_URL`
68-
environment variable (SQLite default, PostgreSQL compatible); `connect_args`
69-
made conditional on SQLite dialect (#2)
70-
- `main.py`: lifespan handler now applies Alembic migrations on startup via
71-
thread executor (#2)
67+
- `databases/player_database.py`: extracted `get_database_url()` helper
68+
(reads `DATABASE_URL`, falls back to `STORAGE_PATH`, SQLite default);
69+
`connect_args` made conditional on SQLite dialect (#2)
70+
- `alembic/env.py`: removed duplicated DATABASE_URL construction; now calls
71+
`get_database_url()` from `databases.player_database` (#2)
72+
- `main.py`: removed `_apply_migrations` from lifespan — migrations are a
73+
one-shot step, not a per-process startup concern; lifespan now logs startup
74+
only (#2)
7275
- `Dockerfile`: removed `COPY storage/ ./hold/` and its associated comment;
7376
added `COPY alembic.ini` and `COPY alembic/` (#2)
74-
- `scripts/entrypoint.sh`: removed hold→volume copy and seed script logic,
75-
now replaced by Alembic migrations applied at app startup (#2)
77+
- `scripts/entrypoint.sh`: runs `alembic upgrade head` before launching the
78+
app; replaces hold→volume copy and manual seed script pattern (#2)
7679
- `compose.yaml`: replaced `STORAGE_PATH` with `DATABASE_URL` pointing to the
7780
SQLite volume path (#2)
7881
- `.gitignore`: added `*.db`; `storage/players-sqlite3.db` removed from git

alembic/env.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
"""
88

99
import asyncio
10-
import os
1110
from logging.config import fileConfig
1211

1312
from sqlalchemy import pool
@@ -16,15 +15,13 @@
1615

1716
from alembic import context
1817

19-
from databases.player_database import Base
18+
from databases.player_database import Base, get_database_url
2019
from schemas.player_schema import Player # noqa: F401 — registers ORM model with Base
2120

2221
# Supports both SQLite (local) and PostgreSQL (Docker, see #542):
23-
# sqlite+aiosqlite:///./storage/players-sqlite3.db
22+
# sqlite+aiosqlite:///./players-sqlite3.db
2423
# postgresql+asyncpg://postgres:password@postgres:5432/playersdb
25-
_storage_path = os.getenv("STORAGE_PATH", "./players-sqlite3.db")
26-
_default_url = f"sqlite+aiosqlite:///{_storage_path}"
27-
database_url = os.getenv("DATABASE_URL", _default_url)
24+
database_url = get_database_url()
2825

2926
config = context.config
3027
config.set_main_option("sqlalchemy.url", database_url)

databases/player_database.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
Environment variables:
1111
DATABASE_URL: Full async database URL. Defaults to SQLite:
12-
sqlite+aiosqlite:///./storage/players-sqlite3.db
12+
sqlite+aiosqlite:///./players-sqlite3.db
1313
STORAGE_PATH: (legacy) SQLite file path. Ignored when DATABASE_URL is set.
1414
"""
1515

@@ -19,12 +19,21 @@
1919
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
2020
from sqlalchemy.orm import sessionmaker, declarative_base
2121

22-
_database_url = os.getenv("DATABASE_URL")
23-
if not _database_url:
24-
_storage_path = os.getenv("STORAGE_PATH", "./players-sqlite3.db")
25-
_database_url = f"sqlite+aiosqlite:///{_storage_path}"
2622

27-
DATABASE_URL: str = _database_url
23+
def get_database_url() -> str:
24+
"""Return the async database URL from environment variables.
25+
26+
Reads DATABASE_URL first; if unset, constructs a SQLite URL from
27+
STORAGE_PATH (defaulting to ./players-sqlite3.db).
28+
"""
29+
database_url = os.getenv("DATABASE_URL")
30+
if not database_url:
31+
storage_path = os.getenv("STORAGE_PATH", "./players-sqlite3.db")
32+
database_url = f"sqlite+aiosqlite:///{storage_path}"
33+
return database_url
34+
35+
36+
DATABASE_URL: str = get_database_url()
2837

2938
_connect_args = (
3039
{"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}

main.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
- Defines the lifespan event handler for app startup/shutdown logging.
66
- Includes API routers for player and health endpoints.
77
8+
Database migrations are applied by entrypoint.sh before the process starts
9+
(Docker). For local development, run `alembic upgrade head` once before
10+
starting the server.
11+
812
This serves as the entry point for running the API server.
913
"""
1014

11-
import asyncio
1215
from contextlib import asynccontextmanager
1316
import logging
1417
from typing import AsyncIterator
15-
from alembic.config import Config
16-
from alembic import command
1718
from fastapi import FastAPI
1819
from routes import player_route, health_route
1920

@@ -22,24 +23,12 @@
2223
logger = logging.getLogger(UVICORN_LOGGER)
2324

2425

25-
def _apply_migrations() -> None:
26-
alembic_cfg = Config("alembic.ini")
27-
command.upgrade(alembic_cfg, "head")
28-
29-
3026
@asynccontextmanager
3127
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
3228
"""
3329
Lifespan event handler for FastAPI.
34-
35-
Runs database migrations before the application starts accepting requests.
36-
Alembic is invoked in a thread executor to avoid conflicts with the running
37-
event loop (alembic's async env.py calls asyncio.run() internally).
3830
"""
39-
logger.info("Applying database migrations...")
40-
loop = asyncio.get_running_loop()
41-
await loop.run_in_executor(None, _apply_migrations)
42-
logger.info("Database migrations applied successfully.")
31+
logger.info("Application startup complete.")
4332
yield
4433

4534

scripts/entrypoint.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
set -e
33

44
echo "Starting container..."
5-
echo "Database migrations will be applied by the application on startup."
5+
echo "Applying database migrations..."
6+
alembic upgrade head
7+
echo "Database migrations applied."
68
echo "Launching app..."
79
exec "$@"

tests/test_migrations.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,52 +13,53 @@
1313

1414
import os
1515
import sqlite3
16+
from pathlib import Path
1617

1718
from alembic import command
1819
from alembic.config import Config
1920

2021
_DB_PATH = os.getenv("STORAGE_PATH", "./players-sqlite3.db")
21-
_ALEMBIC_CFG = Config("alembic.ini")
22+
_alembic_config = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini"))
2223

2324

2425
def test_migration_downgrade_003_removes_substitutes_only():
2526
"""Downgrade 003→002 removes the 15 seeded substitutes, leaves Starting XI."""
26-
command.downgrade(_ALEMBIC_CFG, "-1")
27+
command.downgrade(_alembic_config, "-1")
2728

2829
conn = sqlite3.connect(_DB_PATH)
2930
total = conn.execute("SELECT COUNT(*) FROM players").fetchone()[0]
3031
subs = conn.execute("SELECT COUNT(*) FROM players WHERE starting11=0").fetchone()[0]
3132
conn.close()
3233

33-
command.upgrade(_ALEMBIC_CFG, "head")
34+
command.upgrade(_alembic_config, "head")
3435

3536
assert total == 11
3637
assert subs == 0
3738

3839

3940
def test_migration_downgrade_002_removes_starting11_only():
4041
"""Downgrade 002→001 removes the 11 seeded Starting XI, leaves table empty."""
41-
command.downgrade(_ALEMBIC_CFG, "-2")
42+
command.downgrade(_alembic_config, "-2")
4243

4344
conn = sqlite3.connect(_DB_PATH)
4445
total = conn.execute("SELECT COUNT(*) FROM players").fetchone()[0]
4546
conn.close()
4647

47-
command.upgrade(_ALEMBIC_CFG, "head")
48+
command.upgrade(_alembic_config, "head")
4849

4950
assert total == 0
5051

5152

5253
def test_migration_downgrade_001_drops_players_table():
5354
"""Downgrade 001→base drops the players table entirely."""
54-
command.downgrade(_ALEMBIC_CFG, "base")
55+
command.downgrade(_alembic_config, "base")
5556

5657
conn = sqlite3.connect(_DB_PATH)
5758
table = conn.execute(
5859
"SELECT name FROM sqlite_master WHERE type='table' AND name='players'"
5960
).fetchone()
6061
conn.close()
6162

62-
command.upgrade(_ALEMBIC_CFG, "head")
63+
command.upgrade(_alembic_config, "head")
6364

6465
assert table is None

0 commit comments

Comments
 (0)