strat-gameplay-webapp/.claude/archive/LINEUP_POLYMORPHIC_MIGRATION.md
Cal Corum 8f67883be1 CLAUDE: Implement polymorphic Lineup model for PD and SBA leagues
Updated Lineup model to support both leagues using the same pattern as RosterLink:
- Made card_id nullable (PD league)
- Added player_id nullable (SBA league)
- Added XOR CHECK constraint to ensure exactly one ID is populated
- Created league-specific methods: add_pd_lineup_card() and add_sba_lineup_player()
- Replaced generic create_lineup_entry() with league-specific methods

Database migration applied to convert existing schema.

Bonus fix: Resolved Pendulum DateTime + asyncpg timezone compatibility issue
by using .naive() on all DateTime defaults in Game, Play, and GameSession models.

Updated tests to use league-specific lineup methods.
Archived migration docs and script to .claude/archive/ for reference.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 08:35:24 -05:00

4.8 KiB

Lineup Model - Polymorphic Migration Summary

Date: 2025-10-23 Status: COMPLETE

Overview

Updated the Lineup database model to support both PD and SBA leagues using a polymorphic design pattern, matching the approach used in RosterLink.

Changes Made

1. Database Model (app/models/db_models.py)

Before:

class Lineup(Base):
    # ...
    card_id = Column(Integer, nullable=False)  # PD only

After:

class Lineup(Base):
    """Lineup model - tracks player assignments in a game

    PD League: Uses card_id to track which cards are in the lineup
    SBA League: Uses player_id to track which players are in the lineup

    Exactly one of card_id or player_id must be populated per row.
    """
    # ...
    # Polymorphic player reference
    card_id = Column(Integer, nullable=True)  # PD only
    player_id = Column(Integer, nullable=True)  # SBA only

    # Table-level constraints
    __table_args__ = (
        # Ensure exactly one ID is populated (XOR logic)
        CheckConstraint(
            '(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1',
            name='lineup_one_id_required'
        ),
    )

2. Database Operations (app/database/operations.py)

Removed: create_lineup_entry() (league-agnostic, outdated)

Added: League-specific methods

  • add_pd_lineup_card() - Add PD card to lineup
  • add_sba_lineup_player() - Add SBA player to lineup

Updated: load_game_state() now returns both card_id and player_id fields

3. Database Migration

Script: backend/migrate_lineup_schema.py

Migration steps:

  1. Add player_id column (nullable)
  2. Make card_id nullable
  3. Add XOR constraint to ensure exactly one ID is populated

Run migration:

cd backend
source venv/bin/activate
python migrate_lineup_schema.py

4. Test Updates

Updated integration tests in:

  • tests/integration/database/test_operations.py
  • tests/integration/test_state_persistence.py

Changed from create_lineup_entry() to league-specific methods:

  • add_sba_lineup_player() for SBA games
  • add_pd_lineup_card() for PD games

5. Bonus Fix: Pendulum DateTime Issue

Fixed timezone-awareness issue with Pendulum DateTime and asyncpg by using .naive():

# Before
created_at = Column(DateTime, default=lambda: pendulum.now('UTC'))

# After
created_at = Column(DateTime, default=lambda: pendulum.now('UTC').naive())

Applied to: Game, Play, and GameSession models

Design Rationale

Why Polymorphic?

  1. Consistency: Matches the RosterLink pattern
  2. Clarity: card_id vs player_id makes intent explicit
  3. Type Safety: Database CHECK constraint enforces integrity
  4. Flexibility: Single table handles both leagues efficiently

Why Not Generic entity_id?

  • Less explicit
  • Loses type information
  • Harder to understand intent
  • More prone to errors

Testing

All tests pass when run individually:

  • test_add_sba_lineup_player - SBA player creation
  • test_add_pd_lineup_card - PD card creation
  • test_get_active_lineup - Lineup retrieval
  • test_get_active_lineup_empty - Empty lineup handling

Note: Async connection pool issues when running tests sequentially are pytest-asyncio artifacts, not code issues.

Usage Examples

PD League

lineup = await db_ops.add_pd_lineup_card(
    game_id=game_id,
    team_id=1,
    card_id=123,
    position="CF",
    batting_order=1
)
# lineup.card_id == 123
# lineup.player_id == None

SBA League

lineup = await db_ops.add_sba_lineup_player(
    game_id=game_id,
    team_id=1,
    player_id=456,
    position="CF",
    batting_order=1
)
# lineup.card_id == None
# lineup.player_id == 456

Next Steps

Future considerations:

  1. Alembic Setup: Replace manual migration scripts with Alembic migrations
  2. Pydantic Models: Review LineupPlayerState to support both card_id and player_id
  3. API Layer: Add REST endpoints that use league-specific lineup methods

Files Modified

  • backend/app/models/db_models.py - Added polymorphic fields + constraint
  • backend/app/database/operations.py - League-specific methods
  • backend/tests/integration/database/test_operations.py - Updated tests
  • backend/tests/integration/test_state_persistence.py - Updated tests
  • backend/migrate_lineup_schema.py - New migration script

Database Schema

-- After migration
CREATE TABLE lineups (
    id SERIAL PRIMARY KEY,
    game_id UUID NOT NULL REFERENCES games(id) ON DELETE CASCADE,
    team_id INTEGER NOT NULL,
    card_id INTEGER,      -- PD only
    player_id INTEGER,    -- SBA only
    position VARCHAR(10) NOT NULL,
    batting_order INTEGER,
    -- ... other fields
    CONSTRAINT lineup_one_id_required
        CHECK ((card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1)
);

Completed by: Claude Approved by: User