mantimon-tcg/backend/app/repositories/protocols.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

345 lines
9.8 KiB
Python

"""Repository protocol definitions for Mantimon TCG.
This module defines the abstract interfaces (Protocols) for data access.
Concrete implementations can be PostgreSQL, SQLite, or in-memory storage.
The protocols define WHAT operations are available, not HOW they're implemented.
This enables the offline fork to implement LocalCollectionRepository while
the backend uses PostgresCollectionRepository.
Example:
class CollectionRepository(Protocol):
async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ...
# PostgreSQL implementation
class PostgresCollectionRepository:
def __init__(self, db: AsyncSession): ...
async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ...
# Local/offline implementation
class LocalCollectionRepository:
def __init__(self, storage_path: Path): ...
async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ...
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Protocol
from uuid import UUID
from app.db.models.collection import CardSource
# =============================================================================
# Sentinel Value for Optional Updates
# =============================================================================
# UNSET is used to distinguish between "not provided" (keep existing)
# and "explicitly set to None" (clear the field).
#
# Example usage:
# async def update(description: str | None = UNSET):
# if description is not UNSET:
# # User explicitly provided a value (could be None to clear)
# record.description = description
class _UnsetType:
"""Sentinel class for unset optional parameters."""
__slots__ = ()
def __repr__(self) -> str:
return "UNSET"
def __bool__(self) -> bool:
return False
UNSET: Any = _UnsetType()
# =============================================================================
# Data Transfer Objects (DTOs)
# =============================================================================
# These are storage-agnostic representations used by protocols.
# They decouple the service layer from ORM models.
@dataclass
class CollectionEntry:
"""Storage-agnostic representation of a collection entry.
This DTO decouples the service layer from the ORM model,
allowing different storage backends to return the same structure.
"""
id: UUID
user_id: UUID
card_definition_id: str
quantity: int
source: CardSource
obtained_at: datetime
created_at: datetime
updated_at: datetime
@dataclass
class DeckEntry:
"""Storage-agnostic representation of a deck.
This DTO decouples the service layer from the ORM model.
"""
id: UUID
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
starter_type: str | None
description: str | None
created_at: datetime
updated_at: datetime
# =============================================================================
# Repository Protocols
# =============================================================================
class CollectionRepository(Protocol):
"""Protocol for card collection data access.
Implementations handle storage-specific details (PostgreSQL, SQLite, JSON).
Services use this protocol for business logic without knowing storage details.
All methods are async to support both database and file-based storage.
"""
async def get_all(self, user_id: UUID) -> list[CollectionEntry]:
"""Get all collection entries for a user.
Args:
user_id: The user's UUID.
Returns:
List of all collection entries, ordered by card_definition_id.
"""
...
async def get_by_card(self, user_id: UUID, card_definition_id: str) -> CollectionEntry | None:
"""Get a specific collection entry.
Args:
user_id: The user's UUID.
card_definition_id: The card ID to look up.
Returns:
CollectionEntry if exists, None otherwise.
"""
...
async def get_quantity(self, user_id: UUID, card_definition_id: str) -> int:
"""Get quantity of a specific card owned by user.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to check.
Returns:
Number of copies owned (0 if not owned).
"""
...
async def upsert(
self,
user_id: UUID,
card_definition_id: str,
quantity: int,
source: CardSource,
) -> CollectionEntry:
"""Add or update a collection entry.
If entry exists, increments quantity. Otherwise creates new entry.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to add.
quantity: Number of copies to add.
source: How the cards were obtained.
Returns:
The created or updated CollectionEntry.
"""
...
async def decrement(
self,
user_id: UUID,
card_definition_id: str,
quantity: int,
) -> CollectionEntry | None:
"""Decrement quantity of a collection entry.
If quantity reaches 0, deletes the entry.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to decrement.
quantity: Number of copies to remove.
Returns:
Updated entry, or None if entry was deleted or didn't exist.
"""
...
async def exists_with_source(self, user_id: UUID, source: CardSource) -> bool:
"""Check if user has any entries with the given source.
Useful for checking if user has received a starter deck.
Args:
user_id: The user's UUID.
source: The CardSource to check for.
Returns:
True if any entries exist with that source.
"""
...
class DeckRepository(Protocol):
"""Protocol for deck data access.
Implementations handle storage-specific details (PostgreSQL, SQLite, JSON).
Services use this protocol for business logic without knowing storage details.
"""
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.
"""
...
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.
"""
...
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.
"""
...
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.
"""
...
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.
"""
...
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.
"""
...
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.
"""
...
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).
"""
...