"""PostgreSQL implementation of DeckRepository. This module provides the PostgreSQL-specific implementation of the DeckRepository protocol using SQLAlchemy async sessions. Example: async with get_db_session() as db: repo = PostgresDeckRepository(db) decks = await repo.get_by_user(user_id) """ from uuid import UUID from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models.deck import Deck from app.repositories.protocols import UNSET, DeckEntry def _to_dto(model: Deck) -> DeckEntry: """Convert ORM model to DTO.""" return DeckEntry( id=model.id, user_id=model.user_id, name=model.name, cards=model.cards or {}, energy_cards=model.energy_cards or {}, is_valid=model.is_valid, validation_errors=model.validation_errors, is_starter=model.is_starter, starter_type=model.starter_type, description=model.description, created_at=model.created_at, updated_at=model.updated_at, ) class PostgresDeckRepository: """PostgreSQL implementation of DeckRepository. Uses SQLAlchemy async sessions for database access. Attributes: _db: The async database session. """ def __init__(self, db: AsyncSession) -> None: """Initialize with database session. Args: db: SQLAlchemy async session. """ self._db = db async def get_by_id(self, deck_id: UUID) -> DeckEntry | None: """Get a deck by its ID. Args: deck_id: The deck's UUID. Returns: DeckEntry if found, None otherwise. """ result = await self._db.execute(select(Deck).where(Deck.id == deck_id)) model = result.scalar_one_or_none() return _to_dto(model) if model else None async def get_by_user(self, user_id: UUID) -> list[DeckEntry]: """Get all decks for a user. Args: user_id: The user's UUID. Returns: List of all user's decks, ordered by name. """ result = await self._db.execute( select(Deck).where(Deck.user_id == user_id).order_by(Deck.name) ) return [_to_dto(model) for model in result.scalars().all()] async def get_user_deck(self, user_id: UUID, deck_id: UUID) -> DeckEntry | None: """Get a specific deck owned by a user. Combines ownership check with retrieval. Args: user_id: The user's UUID. deck_id: The deck's UUID. Returns: DeckEntry if found and owned by user, None otherwise. """ result = await self._db.execute( select(Deck).where( Deck.id == deck_id, Deck.user_id == user_id, ) ) model = result.scalar_one_or_none() return _to_dto(model) if model else None async def count_by_user(self, user_id: UUID) -> int: """Count how many decks a user has. Args: user_id: The user's UUID. Returns: Number of decks owned by user. """ result = await self._db.execute(select(func.count(Deck.id)).where(Deck.user_id == user_id)) return result.scalar_one() async def create( self, user_id: UUID, name: str, cards: dict[str, int], energy_cards: dict[str, int], is_valid: bool, validation_errors: list[str] | None, is_starter: bool = False, starter_type: str | None = None, description: str | None = None, ) -> DeckEntry: """Create a new deck. Args: user_id: The user's UUID. name: Display name for the deck. cards: Card ID to quantity mapping. energy_cards: Energy type to quantity mapping. is_valid: Whether deck passes validation. validation_errors: List of validation error messages. is_starter: Whether this is a starter deck. starter_type: Type of starter deck if applicable. description: Optional deck description. Returns: The created DeckEntry. """ deck = Deck( user_id=user_id, name=name, cards=cards, energy_cards=energy_cards, is_valid=is_valid, validation_errors=validation_errors, is_starter=is_starter, starter_type=starter_type, description=description, ) self._db.add(deck) await self._db.commit() await self._db.refresh(deck) return _to_dto(deck) async def update( self, deck_id: UUID, name: str | None = None, cards: dict[str, int] | None = None, energy_cards: dict[str, int] | None = None, is_valid: bool | None = None, validation_errors: list[str] | None = UNSET, # type: ignore[assignment] description: str | None = UNSET, # type: ignore[assignment] ) -> DeckEntry | None: """Update an existing deck. Only provided (non-UNSET) fields are updated. Use UNSET (default) to keep existing value, or None to clear. Args: deck_id: The deck's UUID. name: New name (optional, None keeps existing). cards: New card composition (optional, None keeps existing). energy_cards: New energy composition (optional, None keeps existing). is_valid: New validation status (optional, None keeps existing). validation_errors: New errors (UNSET=keep, None=clear, list=set). description: New description (UNSET=keep, None=clear, str=set). Returns: Updated DeckEntry, or None if deck not found. """ result = await self._db.execute(select(Deck).where(Deck.id == deck_id)) deck = result.scalar_one_or_none() if deck is None: return None if name is not None: deck.name = name if cards is not None: deck.cards = cards if energy_cards is not None: deck.energy_cards = energy_cards if is_valid is not None: deck.is_valid = is_valid # Use UNSET pattern for nullable fields that can be cleared if validation_errors is not UNSET: deck.validation_errors = validation_errors if description is not UNSET: deck.description = description await self._db.commit() await self._db.refresh(deck) return _to_dto(deck) async def delete(self, deck_id: UUID) -> bool: """Delete a deck. Args: deck_id: The deck's UUID. Returns: True if deleted, False if not found. """ result = await self._db.execute(select(Deck).where(Deck.id == deck_id)) deck = result.scalar_one_or_none() if deck is None: return False await self._db.delete(deck) await self._db.commit() return True async def has_starter(self, user_id: UUID) -> tuple[bool, str | None]: """Check if user has a starter deck. Args: user_id: The user's UUID. Returns: Tuple of (has_starter, starter_type). """ result = await self._db.execute( select(Deck.starter_type) .where( Deck.user_id == user_id, Deck.is_starter == True, # noqa: E712 ) .limit(1) ) starter_type = result.scalar_one_or_none() return (starter_type is not None, starter_type)