# Database Module - Async PostgreSQL Persistence ## Purpose Async PostgreSQL persistence layer using SQLAlchemy 2.0. Handles all database operations with connection pooling and proper transaction management. **Schema Management**: Alembic migrations (see `backend/README.md` for commands) ## Structure ``` app/database/ ├── __init__.py # Package exports ├── session.py # Async session management, Base declarative └── operations.py # DatabaseOperations class (session injection pattern) backend/alembic/ # Migration scripts (managed by Alembic) ├── env.py # Migration environment config └── versions/ # Migration files (001, 004, 005, etc.) ``` ## Session Injection Pattern (IMPORTANT) `DatabaseOperations` supports **session injection** to enable: - **Transaction grouping**: Multiple operations in one atomic transaction - **Test isolation**: Tests inject sessions with automatic rollback - **Connection efficiency**: Avoids asyncpg "operation in progress" conflicts ### Two Usage Modes #### Mode 1: Standalone (Default) - Each operation auto-commits ```python from app.database.operations import DatabaseOperations db_ops = DatabaseOperations() # No session injected await db_ops.create_game(...) # Creates session, commits, closes await db_ops.save_play(...) # Creates NEW session, commits, closes ``` #### Mode 2: Session Injection - Caller controls transaction ```python from app.database.operations import DatabaseOperations from app.database.session import AsyncSessionLocal async with AsyncSessionLocal() as session: try: db_ops = DatabaseOperations(session) # Inject session await db_ops.save_play(...) # Uses injected session (flush only) await db_ops.update_game_state(...) # Same session (flush only) await session.commit() # Caller commits once except Exception: await session.rollback() raise ``` ### When to Use Each Mode | Scenario | Mode | Why | |----------|------|-----| | Single operation | Standalone | Simpler, auto-commits | | Multiple related operations | Session injection | Atomic transaction | | Integration tests | Session injection | Rollback after test | | game_engine.py transactions | Session injection | save_play + update_game_state atomic | ### Internal Implementation ```python class DatabaseOperations: def __init__(self, session: AsyncSession | None = None): self._session = session @asynccontextmanager async def _get_session(self): if self._session: yield self._session # Caller controls commit/rollback else: async with AsyncSessionLocal() as session: try: yield session await session.commit() except Exception: await session.rollback() raise ``` **Key Detail**: Methods use `flush()` not `commit()` - this persists changes within the transaction but doesn't finalize it. For injected sessions, the caller commits. For standalone, the context manager commits. ## DatabaseOperations API ### Game Operations ```python from app.database.operations import DatabaseOperations db_ops = DatabaseOperations() await db_ops.create_game(game_id, league_id, home_team_id, away_team_id, ...) await db_ops.update_game_state(game_id, inning, half, home_score, away_score) game_data = await db_ops.load_game_state(game_id) ``` ### Play Operations ```python play_id = await db_ops.save_play(play_data) plays = await db_ops.get_plays(game_id, limit=100) ``` ### Lineup Operations ```python lineup_id = await db_ops.add_pd_lineup_card(game_id, team_id, card_id, position, batting_order) lineup_id = await db_ops.add_sba_lineup_player(game_id, team_id, player_id, position, batting_order) lineup = await db_ops.get_active_lineup(game_id, team_id) await db_ops.deactivate_lineup_player(lineup_id) ``` ### Roster Operations ```python await db_ops.add_pd_roster_card(game_id, card_id, team_id) await db_ops.add_sba_roster_player(game_id, player_id, team_id) roster = await db_ops.get_pd_roster(game_id, team_id) ``` ### Session Operations ```python await db_ops.create_game_session(game_id) await db_ops.update_session_snapshot(game_id, state_snapshot) ``` ## Common Patterns ### Transaction Grouping (Recommended for related operations) ```python async with AsyncSessionLocal() as session: try: db_ops = DatabaseOperations(session) await db_ops.save_play(play_data) await db_ops.update_game_state(game_id, ...) await session.commit() # Both succeed or both fail except Exception: await session.rollback() raise ``` ### Efficient Queries ```python # Use joinedload for relationships needed immediately from sqlalchemy.orm import joinedload query = select(Play).options( joinedload(Play.batter), joinedload(Play.pitcher) ) ``` ### Direct UPDATE ```python # More efficient than SELECT + modify + commit stmt = update(Game).where(Game.id == game_id).values(inning=5) await session.execute(stmt) ``` ## Database Schema See `../models/CLAUDE.md` for model details. | Table | Purpose | |-------|---------| | games | Game container, scores, status | | plays | At-bat records with 25+ stats | | lineups | Player assignments, substitutions | | game_sessions | WebSocket state | | roster_links | Eligible cards/players | ## Environment ```bash DATABASE_URL=postgresql+asyncpg://user:pass@10.10.0.42:5432/paperdynasty_dev ``` ## References - **Migrations**: See `backend/README.md` for Alembic commands - **Models**: See `../models/CLAUDE.md` - **State Recovery**: See `../core/state_manager.py` --- **Tests**: `tests/integration/database/` (32 tests) | **Updated**: 2025-11-27