mantimon-tcg/backend/app/repositories/postgres/deck.py
Cal Corum 7d397a2e22 Fix medium priority issues from code review
UNSET sentinel pattern:
- Add UNSET sentinel in protocols.py for nullable field updates
- Fix inability to clear deck description (UNSET=keep, None=clear)
- Fix repository inability to clear validation_errors

Starter deck improvements:
- Remove unused has_starter_deck from CollectionService
- Add deprecation notes to old starter deck methods

Validation improvements:
- Add energy type validation in deck_validator.py
- Add energy type validation in deck schemas
- Add VALID_ENERGY_TYPES constant

Game loading fix:
- Fix get_deck_for_game silently skipping invalid cards
- Now raises ValueError with clear error message

Tests:
- Add TestEnergyTypeValidation test class
- Add TestGetDeckForGame test class
- Add tests for validate_energy_types utility function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:32:08 -06:00

249 lines
7.5 KiB
Python

"""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)