Phase 1 Database Implementation (DB-001 through DB-012): Models: - User: OAuth support (Google/Discord), premium subscriptions - Collection: Card ownership with CardSource enum - Deck: JSONB cards/energy_cards, validation state - CampaignProgress: One-to-one with User, medals/NPCs as JSONB - ActiveGame: In-progress games with GameType enum - GameHistory: Completed games with EndReason enum, replay data Infrastructure: - Alembic migrations with sync psycopg2 (avoids async issues) - Docker Compose for Postgres (5433) and Redis (6380) - App config with Pydantic settings - Redis client helper Test Infrastructure: - 68 database tests (47 model + 21 relationship) - Async factory pattern for test data creation - Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch) - Uses dev containers instead of testcontainers for reliability Key technical decisions: - passive_deletes=True for ON DELETE SET NULL relationships - NullPool for test sessions (no connection reuse) - expire_on_commit=False with manual expire() for relationship tests
105 lines
2.9 KiB
Python
105 lines
2.9 KiB
Python
"""SQLAlchemy base model and common mixins.
|
|
|
|
This module provides the declarative base class and common column mixins
|
|
used by all database models.
|
|
|
|
Example:
|
|
from app.db.base import Base
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
email = Column(String, unique=True, nullable=False)
|
|
display_name = Column(String, nullable=False)
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import DateTime, func
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
"""Base class for all SQLAlchemy models.
|
|
|
|
Provides common columns and functionality:
|
|
- id: UUID primary key (auto-generated)
|
|
- created_at: Timestamp when record was created
|
|
- updated_at: Timestamp when record was last updated
|
|
|
|
All models should inherit from this class.
|
|
|
|
Example:
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
email: Mapped[str] = mapped_column(unique=True)
|
|
"""
|
|
|
|
# Type annotation for type checkers
|
|
__tablename__: str
|
|
|
|
# Common columns for all models
|
|
id: Mapped[str] = mapped_column(
|
|
UUID(as_uuid=False),
|
|
primary_key=True,
|
|
default=lambda: str(uuid4()),
|
|
doc="Unique identifier (UUID)",
|
|
)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
doc="Timestamp when record was created",
|
|
)
|
|
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
nullable=False,
|
|
doc="Timestamp when record was last updated",
|
|
)
|
|
|
|
@declared_attr.directive
|
|
@classmethod
|
|
def __tablename__(cls) -> str:
|
|
"""Generate table name from class name.
|
|
|
|
Converts CamelCase to snake_case and pluralizes.
|
|
Example: UserProfile -> user_profiles
|
|
|
|
Can be overridden by setting __tablename__ directly.
|
|
"""
|
|
# Convert CamelCase to snake_case
|
|
name = cls.__name__
|
|
result = [name[0].lower()]
|
|
for char in name[1:]:
|
|
if char.isupper():
|
|
result.append("_")
|
|
result.append(char.lower())
|
|
else:
|
|
result.append(char)
|
|
|
|
# Simple pluralization (add 's')
|
|
table_name = "".join(result)
|
|
if not table_name.endswith("s"):
|
|
table_name += "s"
|
|
return table_name
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Convert model instance to dictionary.
|
|
|
|
Returns:
|
|
Dictionary with all column values.
|
|
"""
|
|
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
|
|
|
|
def __repr__(self) -> str:
|
|
"""Generate string representation of model instance."""
|
|
class_name = self.__class__.__name__
|
|
return f"<{class_name}(id={self.id!r})>"
|