""" Database Operations - Async persistence layer for game data. Provides async operations for persisting and retrieving game data. Used by StateManager for database persistence and recovery. Author: Claude Date: 2025-10-22 """ import logging from typing import Optional, List, Dict from uuid import UUID from sqlalchemy import select from sqlalchemy.orm import joinedload from app.database.session import AsyncSessionLocal from app.models.db_models import Game, Play, Lineup, GameSession logger = logging.getLogger(f'{__name__}.DatabaseOperations') class DatabaseOperations: """ Async database operations for game persistence. Provides methods for creating, reading, and updating game data in PostgreSQL. All operations are async and use the AsyncSessionLocal for session management. """ async def create_game( self, game_id: UUID, league_id: str, home_team_id: int, away_team_id: int, game_mode: str, visibility: str, home_team_is_ai: bool = False, away_team_is_ai: bool = False, ai_difficulty: Optional[str] = None ) -> Game: """ Create new game in database. Args: game_id: Unique game identifier league_id: League identifier ('sba' or 'pd') home_team_id: Home team ID away_team_id: Away team ID game_mode: Game mode ('ranked', 'friendly', 'practice') visibility: Visibility ('public', 'private') home_team_is_ai: Whether home team is AI away_team_is_ai: Whether away team is AI ai_difficulty: AI difficulty if applicable Returns: Created Game model Raises: SQLAlchemyError: If database operation fails """ async with AsyncSessionLocal() as session: try: game = Game( id=game_id, league_id=league_id, home_team_id=home_team_id, away_team_id=away_team_id, game_mode=game_mode, visibility=visibility, home_team_is_ai=home_team_is_ai, away_team_is_ai=away_team_is_ai, ai_difficulty=ai_difficulty, status="pending" ) session.add(game) await session.commit() await session.refresh(game) logger.info(f"Created game {game_id} in database ({league_id})") return game except Exception as e: await session.rollback() logger.error(f"Failed to create game {game_id}: {e}") raise async def get_game(self, game_id: UUID) -> Optional[Game]: """ Get game by ID. Args: game_id: Game identifier Returns: Game model if found, None otherwise """ async with AsyncSessionLocal() as session: result = await session.execute( select(Game).where(Game.id == game_id) ) game = result.scalar_one_or_none() if game: logger.debug(f"Retrieved game {game_id} from database") return game async def update_game_state( self, game_id: UUID, inning: int, half: str, home_score: int, away_score: int, status: Optional[str] = None ) -> None: """ Update game state fields. Args: game_id: Game identifier inning: Current inning half: Current half ('top' or 'bottom') home_score: Home team score away_score: Away team score status: Game status if updating Raises: ValueError: If game not found """ async with AsyncSessionLocal() as session: try: result = await session.execute( select(Game).where(Game.id == game_id) ) game = result.scalar_one_or_none() if not game: raise ValueError(f"Game {game_id} not found") game.current_inning = inning game.current_half = half game.home_score = home_score game.away_score = away_score if status: game.status = status await session.commit() logger.debug(f"Updated game {game_id} state (inning {inning}, {half})") except Exception as e: await session.rollback() logger.error(f"Failed to update game {game_id} state: {e}") raise async def create_lineup_entry( self, game_id: UUID, team_id: int, card_id: int, position: str, batting_order: Optional[int] = None, is_starter: bool = True ) -> Lineup: """ Create lineup entry in database. Args: game_id: Game identifier team_id: Team identifier card_id: Player card ID position: Player position batting_order: Batting order (1-9) if applicable is_starter: Whether player is starting lineup Returns: Created Lineup model Raises: SQLAlchemyError: If database operation fails """ async with AsyncSessionLocal() as session: try: lineup = Lineup( game_id=game_id, team_id=team_id, card_id=card_id, position=position, batting_order=batting_order, is_starter=is_starter, is_active=True ) session.add(lineup) await session.commit() await session.refresh(lineup) logger.debug(f"Created lineup entry for card {card_id} in game {game_id}") return lineup except Exception as e: await session.rollback() logger.error(f"Failed to create lineup entry: {e}") raise async def get_active_lineup(self, game_id: UUID, team_id: int) -> List[Lineup]: """ Get active lineup for team. Args: game_id: Game identifier team_id: Team identifier Returns: List of active Lineup models, sorted by batting order """ async with AsyncSessionLocal() as session: result = await session.execute( select(Lineup) .where( Lineup.game_id == game_id, Lineup.team_id == team_id, Lineup.is_active == True ) .order_by(Lineup.batting_order) ) lineups = list(result.scalars().all()) logger.debug(f"Retrieved {len(lineups)} active lineup entries for team {team_id}") return lineups async def save_play(self, play_data: dict) -> Play: """ Save play to database. Args: play_data: Dictionary with play data matching Play model fields Returns: Created Play model Raises: SQLAlchemyError: If database operation fails """ async with AsyncSessionLocal() as session: try: play = Play(**play_data) session.add(play) await session.commit() await session.refresh(play) logger.info(f"Saved play {play.play_number} for game {play.game_id}") return play except Exception as e: await session.rollback() logger.error(f"Failed to save play: {e}") raise async def get_plays(self, game_id: UUID) -> List[Play]: """ Get all plays for game. Args: game_id: Game identifier Returns: List of Play models, ordered by play_number """ async with AsyncSessionLocal() as session: result = await session.execute( select(Play) .where(Play.game_id == game_id) .order_by(Play.play_number) ) plays = list(result.scalars().all()) logger.debug(f"Retrieved {len(plays)} plays for game {game_id}") return plays async def load_game_state(self, game_id: UUID) -> Optional[Dict]: """ Load complete game state for recovery. Loads game, lineups, and plays in a single transaction. Args: game_id: Game identifier Returns: Dictionary with 'game', 'lineups', and 'plays' keys, or None if game not found """ async with AsyncSessionLocal() as session: # Get game game_result = await session.execute( select(Game).where(Game.id == game_id) ) game = game_result.scalar_one_or_none() if not game: logger.warning(f"Game {game_id} not found for recovery") return None # Get lineups lineup_result = await session.execute( select(Lineup) .where(Lineup.game_id == game_id, Lineup.is_active == True) ) lineups = list(lineup_result.scalars().all()) # Get plays play_result = await session.execute( select(Play) .where(Play.game_id == game_id) .order_by(Play.play_number) ) plays = list(play_result.scalars().all()) logger.info(f"Loaded game state for {game_id}: {len(lineups)} lineups, {len(plays)} plays") return { 'game': { 'id': game.id, 'league_id': game.league_id, 'home_team_id': game.home_team_id, 'away_team_id': game.away_team_id, 'home_team_is_ai': game.home_team_is_ai, 'away_team_is_ai': game.away_team_is_ai, 'status': game.status, 'current_inning': game.current_inning, 'current_half': game.current_half, 'home_score': game.home_score, 'away_score': game.away_score }, 'lineups': [ { 'id': l.id, 'team_id': l.team_id, 'card_id': l.card_id, 'position': l.position, 'batting_order': l.batting_order, 'is_active': l.is_active } for l in lineups ], 'plays': [ { 'play_number': p.play_number, 'inning': p.inning, 'half': p.half, 'outs_before': p.outs_before, 'result_description': p.result_description } for p in plays ] } async def create_game_session(self, game_id: UUID) -> GameSession: """ Create game session record for WebSocket tracking. Args: game_id: Game identifier Returns: Created GameSession model """ async with AsyncSessionLocal() as session: try: game_session = GameSession(game_id=game_id) session.add(game_session) await session.commit() await session.refresh(game_session) logger.info(f"Created game session for {game_id}") return game_session except Exception as e: await session.rollback() logger.error(f"Failed to create game session: {e}") raise async def update_session_snapshot( self, game_id: UUID, state_snapshot: dict ) -> None: """ Update session state snapshot. Args: game_id: Game identifier state_snapshot: JSON-serializable state snapshot Raises: ValueError: If game session not found """ async with AsyncSessionLocal() as session: try: result = await session.execute( select(GameSession).where(GameSession.game_id == game_id) ) game_session = result.scalar_one_or_none() if not game_session: raise ValueError(f"Game session {game_id} not found") game_session.state_snapshot = state_snapshot await session.commit() logger.debug(f"Updated session snapshot for {game_id}") except Exception as e: await session.rollback() logger.error(f"Failed to update session snapshot: {e}") raise