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>
345 lines
9.8 KiB
Python
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).
|
|
"""
|
|
...
|