Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ Normal browser requests do **not** call the MLB Stats API.
- Offline deterministic test suite and GitHub Actions CI

Player visualizations, additional team metrics, multi-season overlays, and a more
organized Team/Player UI are planned but not implemented yet.
organized Team/Player UI are planned but not implemented yet. A season-level
Player catalog can be imported for future DB-only search and selection; see
[Player season catalog](docs/player-season-catalog.md).

## Screenshots

Expand Down Expand Up @@ -112,6 +114,18 @@ Import an entire MLB season:
poetry run python scripts/import_league_season.py --season 2025
```

Import the MLB player directory for a season, then import hitting data for an
individual player as needed:

```bash
poetry run python scripts/import_player_catalog.py --season 2025
poetry run python scripts/import_player_season.py --player-id 677594 --season 2025
```

The Player catalog is one bulk request. It stores identities and season
memberships for future DB-only search; it does not fetch statistics for every
discovered player.

League imports record whether every discovered team was refreshed successfully.
MLB-wide comparison statistics are only presented when the persisted coverage
state supports describing the stored data as league-wide.
Expand Down
80 changes: 80 additions & 0 deletions alembic/versions/8b3f31d9a5c2_create_player_season_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""create_player_season_catalog

Add the season-membership table used by Player discovery. Existing
``player_season_hitting`` rows are authoritative evidence that their players
belong to those seasons, so those memberships are backfilled without
fabricating any identity or statistic.

Revision ID: 8b3f31d9a5c2
Revises: 73d9fae8fafb
Create Date: 2026-09-09 00:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "8b3f31d9a5c2"
down_revision: str | Sequence[str] | None = "73d9fae8fafb"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Create player-season membership and backfill known hitting seasons."""
op.create_table(
"player_seasons",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("player_id", sa.Integer(), nullable=False),
sa.Column("season", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.CheckConstraint(
"player_id > 0", name=op.f("ck_player_seasons_player_id_positive")
),
sa.CheckConstraint(
"season > 0", name=op.f("ck_player_seasons_season_positive")
),
sa.ForeignKeyConstraint(
["player_id"],
["players.player_id"],
name=op.f("fk_player_seasons_player_id_players"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_player_seasons")),
sa.UniqueConstraint(
"player_id", "season", name="uq_player_seasons_player_id_season"
),
)
op.create_index(
"ix_player_seasons_season_player_id",
"player_seasons",
["season", "player_id"],
unique=False,
)

player_seasons = sa.table(
"player_seasons",
sa.column("player_id", sa.Integer()),
sa.column("season", sa.Integer()),
sa.column("created_at", sa.DateTime()),
)
hitting = sa.table(
"player_season_hitting",
sa.column("player_id", sa.Integer()),
sa.column("season", sa.Integer()),
sa.column("created_at", sa.DateTime()),
)
op.execute(
player_seasons.insert().from_select(
["player_id", "season", "created_at"],
sa.select(hitting.c.player_id, hitting.c.season, hitting.c.created_at),
)
)


def downgrade() -> None:
"""Remove Player directory membership while preserving identities and stats."""
op.drop_index("ix_player_seasons_season_player_id", table_name="player_seasons")
op.drop_table("player_seasons")
61 changes: 60 additions & 1 deletion app/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
LeagueSeasonIngestionState,
LeagueSeasonIngestionStatus,
)
from app.schemas.players import PlayerIdentity, PlayerSeasonHitting
from app.schemas.players import (
PlayerIdentity,
PlayerSeasonCatalogEntry,
PlayerSeasonHitting,
)


class TeamGameBattingLineRecord(Base):
Expand Down Expand Up @@ -507,6 +511,61 @@ def from_domain(
)


class PlayerSeasonCatalogRecord(Base):
"""Persistence representation of season membership in MLB's player directory.

Identity attributes remain normalized in ``players``. This table stores
only the many-to-many fact needed for season-scoped discovery: MLB listed
this player for this Major League season.
"""

__tablename__ = "player_seasons"
__table_args__ = (
UniqueConstraint(
"player_id", "season", name="uq_player_seasons_player_id_season"
),
CheckConstraint("player_id > 0", name="player_id_positive"),
CheckConstraint("season > 0", name="season_positive"),
Index("ix_player_seasons_season_player_id", "season", "player_id"),
)

id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
player_id: Mapped[int] = mapped_column(
Integer, ForeignKey("players.player_id"), nullable=False
)
season: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False
)

def to_domain(self, identity: PlayerIdentity) -> PlayerSeasonCatalogEntry:
"""Combine this membership with its normalized player identity."""
if identity.player_id != self.player_id:
raise ValueError(
f"identity player {identity.player_id} does not match catalog "
f"player {self.player_id}"
)
return PlayerSeasonCatalogEntry(
player_id=identity.player_id,
full_name=identity.full_name,
primary_position=identity.primary_position,
season=self.season,
)

@staticmethod
def from_domain(
entry: PlayerSeasonCatalogEntry,
*,
created_at: datetime,
) -> PlayerSeasonCatalogRecord:
"""Build a new immutable season-membership row."""
return PlayerSeasonCatalogRecord(
player_id=entry.player_id,
season=entry.season,
created_at=created_at,
)


class PlayerSeasonHittingRecord(Base):
"""Persistence representation of one player's full-season hitting aggregate.

Expand Down
92 changes: 91 additions & 1 deletion app/database/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.database.models import (
LeagueSeasonIngestionRecord,
PlayerRecord,
PlayerSeasonCatalogRecord,
PlayerSeasonHittingRecord,
TeamGameBattingLineRecord,
TeamGamePitchingLineRecord,
Expand All @@ -27,7 +28,11 @@
PlayerPersistenceOutcome,
TeamGamePersistenceResult,
)
from app.schemas.players import PlayerIdentity, PlayerSeasonHitting
from app.schemas.players import (
PlayerIdentity,
PlayerSeasonCatalogEntry,
PlayerSeasonHitting,
)

# The two line tables the generic upsert below reconciles. They hold different
# columns but expose the same to_domain / apply_domain / from_domain interface.
Expand Down Expand Up @@ -504,6 +509,28 @@ def get_player(session: Session, *, player_id: int) -> PlayerIdentity | None:
return None if record is None else record.to_domain()


def list_player_catalog(
session: Session, *, season: int
) -> list[PlayerSeasonCatalogEntry]:
"""Return the locally stored MLB player directory for one season, by name.

Ordering is applied to the domain entries rather than in SQL because the
database's default collation sorts accented names after every unaccented
one; see ``PlayerIdentity.name_sort_key``.
"""
stmt = (
select(PlayerSeasonCatalogRecord, PlayerRecord)
.join(
PlayerRecord,
PlayerRecord.player_id == PlayerSeasonCatalogRecord.player_id,
)
.where(PlayerSeasonCatalogRecord.season == season)
)
rows = session.execute(stmt).all()
entries = [membership.to_domain(player.to_domain()) for membership, player in rows]
return sorted(entries, key=PlayerSeasonCatalogEntry.name_sort_key)


def get_player_season_hitting(
session: Session,
*,
Expand Down Expand Up @@ -539,6 +566,55 @@ def upsert_player(
return PlayerPersistenceOutcome.UPDATED


def upsert_player_catalog_entry(
session: Session,
*,
entry: PlayerSeasonCatalogEntry,
) -> PlayerPersistenceOutcome:
"""Upsert one logical catalog entry without committing or rolling back.

Identity is normalized into ``players`` and season membership into
``player_seasons``. The returned outcome describes the logical catalog
entry rather than either physical row in isolation.
"""
identity_outcome = upsert_player(session, identity=entry.to_identity())
membership = _load_player_season_catalog(
session, player_id=entry.player_id, season=entry.season
)
if membership is None:
now = datetime.now(UTC).replace(tzinfo=None)
session.add(PlayerSeasonCatalogRecord.from_domain(entry, created_at=now))
return PlayerPersistenceOutcome.INSERTED
return identity_outcome


def ensure_player_season_catalog_membership(
session: Session,
*,
identity: PlayerIdentity,
season: int,
) -> None:
"""Ensure a known player-season import is represented in the catalog.

The caller remains responsible for persisting ``identity`` itself. This
focused helper exists so the one-player ingestion path can maintain the
membership invariant without changing what its identity outcome reports.
"""
existing = _load_player_season_catalog(
session, player_id=identity.player_id, season=season
)
if existing is not None:
return
entry = PlayerSeasonCatalogEntry(
player_id=identity.player_id,
full_name=identity.full_name,
primary_position=identity.primary_position,
season=season,
)
now = datetime.now(UTC).replace(tzinfo=None)
session.add(PlayerSeasonCatalogRecord.from_domain(entry, created_at=now))


def upsert_player_season_hitting(
session: Session,
*,
Expand Down Expand Up @@ -575,6 +651,20 @@ def _load_player(session: Session, player_id: int) -> PlayerRecord | None:
).one_or_none()


def _load_player_season_catalog(
session: Session,
*,
player_id: int,
season: int,
) -> PlayerSeasonCatalogRecord | None:
return session.scalars(
select(PlayerSeasonCatalogRecord).where(
PlayerSeasonCatalogRecord.player_id == player_id,
PlayerSeasonCatalogRecord.season == season,
)
).one_or_none()


def _load_player_season_hitting(
session: Session,
*,
Expand Down
28 changes: 28 additions & 0 deletions app/schemas/ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,31 @@ class PlayerSeasonIngestionResult(BaseModel):
full_name: str = Field(min_length=1)
identity_outcome: PlayerPersistenceOutcome
hitting_outcome: PlayerPersistenceOutcome


class PlayerCatalogIngestionResult(BaseModel):
"""Outcome of refreshing one season's MLB player directory.

Counts describe logical catalog entries. ``INSERTED`` means a new
``(player_id, season)`` membership was stored. ``UPDATED`` means that
membership already existed but MLB supplied changed global identity data.
``UNCHANGED`` means both membership and identity already matched.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

season: int = Field(gt=0)
players_discovered: int = Field(ge=1)
inserted: int = Field(ge=0)
updated: int = Field(ge=0)
unchanged: int = Field(ge=0)

@model_validator(mode="after")
def _discovered_matches_counts(self) -> PlayerCatalogIngestionResult:
total = self.inserted + self.updated + self.unchanged
if self.players_discovered != total:
raise ValueError(
f"players_discovered ({self.players_discovered}) must equal "
f"inserted + updated + unchanged ({total})"
)
return self
Loading
Loading