Skip to content
Open
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
8 changes: 8 additions & 0 deletions internal/adapters/playerrepository/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,13 @@ type PlayerRepository interface {
GetPlayer(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error)
GetHistory(ctx context.Context, playerUUID string, start, end time.Time, limit int) ([]domain.PlayerPIT, error)
GetPlayerPITs(ctx context.Context, playerUUID string, start, end time.Time) ([]domain.PlayerPIT, error)
// GetMostRecentPlayerPIT returns the most recently queried stat stored for
// the player, or domain.ErrPlayerNotFound if none exist.
GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error)
// GetRecentPlayerPITs returns up to limit of the player's most recently
// queried stats, in ascending queried_at order (oldest first). Unlike
// GetHistory it does not downsample — every stored row in the most recent
// limit is returned, so the result is safe to run session computation over.
GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error)
FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error)
}
151 changes: 151 additions & 0 deletions internal/adapters/playerrepository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,149 @@ func (p *PostgresPlayerRepository) GetPlayerPITs(ctx context.Context, playerUUID
return stats, nil
}

func (p *PostgresPlayerRepository) GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) {
ctx, span := p.tracer.Start(ctx, "PostgresPlayerRepository.GetMostRecentPlayerPIT")
defer span.End()

if !strutils.UUIDIsNormalized(playerUUID) {
err := fmt.Errorf("uuid is not normalized")
reporting.Report(ctx, err, map[string]string{
"uuid": playerUUID,
})
return nil, err
}

conn, err := p.db.Connx(ctx)
if err != nil {
err := fmt.Errorf("failed to get connection: %w", err)
reporting.Report(ctx, err)
return nil, err
}
defer conn.Close()

_, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema)))
if err != nil {
err := fmt.Errorf("failed to set search path: %w", err)
reporting.Report(ctx, err, map[string]string{
"schema": p.schema,
})
return nil, err
}

var stat dbStat
err = conn.GetContext(
ctx,
&stat,
`select
id, data_format_version, player_uuid, queried_at, player_data
from stats
where
player_uuid = $1
order by queried_at desc
limit 1`,
Comment on lines +644 to +650
playerUUID)
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrPlayerNotFound
}
if err != nil {
err := fmt.Errorf("failed to select most recent stat: %w", err)
reporting.Report(ctx, err, map[string]string{
"uuid": playerUUID,
})
return nil, err
}

player, err := dbStatToPlayerPIT(stat)
if err != nil {
err := fmt.Errorf("failed to convert db stat to playerpit: %w", err)
reporting.Report(ctx, err, map[string]string{
"statID": stat.ID,
})
return nil, err
Comment on lines +663 to +669
}

return player, nil
}

func (p *PostgresPlayerRepository) GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error) {
ctx, span := p.tracer.Start(ctx, "PostgresPlayerRepository.GetRecentPlayerPITs")
defer span.End()

if !strutils.UUIDIsNormalized(playerUUID) {
err := fmt.Errorf("uuid is not normalized")
reporting.Report(ctx, err, map[string]string{
"uuid": playerUUID,
})
return nil, err
}

if limit < 1 || limit > 1000 {
err := fmt.Errorf("invalid limit")
reporting.Report(ctx, err, map[string]string{
"limit": strconv.Itoa(limit),
})
return nil, err
}

conn, err := p.db.Connx(ctx)
if err != nil {
err := fmt.Errorf("failed to get connection: %w", err)
reporting.Report(ctx, err)
return nil, err
}
defer conn.Close()

_, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema)))
if err != nil {
err := fmt.Errorf("failed to set search path: %w", err)
reporting.Report(ctx, err, map[string]string{
"schema": p.schema,
})
return nil, err
}

dbStats := make([]dbStat, 0, limit)
err = conn.SelectContext(
ctx,
&dbStats,
`select
id, data_format_version, player_uuid, queried_at, player_data
from stats
where
player_uuid = $1
order by queried_at desc
limit $2`,
playerUUID, limit)
if err != nil {
err := fmt.Errorf("failed to select recent stats: %w", err)
reporting.Report(ctx, err, map[string]string{
"uuid": playerUUID,
"limit": strconv.Itoa(limit),
})
return nil, err
}

// The query returns newest-first; reverse to ascending queried_at order so
// callers (and ComputeSessions) get a chronological slice, matching
// GetPlayerPITs' ordering.
slices.Reverse(dbStats)

stats := make([]domain.PlayerPIT, 0, len(dbStats))
for _, dbStat := range dbStats {
player, err := dbStatToPlayerPIT(dbStat)
if err != nil {
err := fmt.Errorf("failed to convert db stat to playerpit: %w", err)
reporting.Report(ctx, err, map[string]string{
"statID": dbStat.ID,
})
return nil, err
}
stats = append(stats, *player)
}

return stats, nil
}

func (p *PostgresPlayerRepository) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) {
ctx, span := p.tracer.Start(ctx, "PostgresPlayerRepository.FindMilestoneAchievements")
defer span.End()
Expand Down Expand Up @@ -785,6 +928,14 @@ func (p *StubPlayerRepository) GetPlayerPITs(ctx context.Context, playerUUID str
return []domain.PlayerPIT{}, nil
}

func (p *StubPlayerRepository) GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) {
return nil, domain.ErrPlayerNotFound
}

func (p *StubPlayerRepository) GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error) {
return []domain.PlayerPIT{}, nil
}

func (p *StubPlayerRepository) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) {
return []domain.MilestoneAchievement{}, nil
}
Expand Down
166 changes: 166 additions & 0 deletions internal/adapters/playerrepository/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,172 @@ func TestPostgresPlayerRepository(t *testing.T) {
})
})

t.Run("GetMostRecentPlayerPIT", func(t *testing.T) {
t.Parallel()
p := newPostgresPlayerRepository(t, db, "get_most_recent_player_pit_tests")

now := time.Now().Truncate(time.Millisecond)

storePlayers := func(t *testing.T, p PlayerRepository, players ...*domain.PlayerPIT) {
t.Helper()
for _, player := range players {
err := p.StorePlayer(ctx, player)
require.NoError(t, err)
}
}

t.Run("returns the most recently queried stat", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)

p1 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(1).BuildPtr(now.Add(-2 * time.Hour))
p2 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(2).BuildPtr(now.Add(-1 * time.Hour))
p3 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(3).BuildPtr(now)

// Store out of chronological order to make sure ordering is by
// queried_at, not insertion order.
storePlayers(t, p, p2, p3, p1)

mostRecent, err := p.GetMostRecentPlayerPIT(ctx, playerUUID)
require.NoError(t, err)
require.NotNil(t, mostRecent)
require.Equal(t, playerUUID, mostRecent.UUID)
require.WithinDuration(t, p3.QueriedAt, mostRecent.QueriedAt, 0)
require.Equal(t, 3, mostRecent.Fours.GamesPlayed)
requireValidDBID(t, mostRecent.DBID)
})

t.Run("returns ErrPlayerNotFound when the player has no stats", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)

mostRecent, err := p.GetMostRecentPlayerPIT(ctx, playerUUID)
require.ErrorIs(t, err, domain.ErrPlayerNotFound)
require.Nil(t, mostRecent)
})

t.Run("does not return another player's stats", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)
otherUUID := domaintest.NewUUID(t)

own := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(5).BuildPtr(now.Add(-1 * time.Hour))
// Other player has a more recent stat, which must not be returned.
other := domaintest.NewPlayerBuilder(otherUUID).Fours().WithGamesPlayed(9).BuildPtr(now)
storePlayers(t, p, own, other)

mostRecent, err := p.GetMostRecentPlayerPIT(ctx, playerUUID)
require.NoError(t, err)
require.NotNil(t, mostRecent)
require.Equal(t, playerUUID, mostRecent.UUID)
require.Equal(t, 5, mostRecent.Fours.GamesPlayed)
})
})

t.Run("GetRecentPlayerPITs", func(t *testing.T) {
t.Parallel()
p := newPostgresPlayerRepository(t, db, "get_recent_player_pits_tests")

now := time.Now().Truncate(time.Millisecond)

storePlayers := func(t *testing.T, p PlayerRepository, players ...*domain.PlayerPIT) {
t.Helper()
for _, player := range players {
err := p.StorePlayer(ctx, player)
require.NoError(t, err)
}
}

gamesPlayed := func(pits []domain.PlayerPIT) []int {
out := make([]int, len(pits))
for i, pit := range pits {
out[i] = pit.Fours.GamesPlayed
}
return out
}

t.Run("returns recent stats in ascending queried_at order", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)

p1 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(1).BuildPtr(now.Add(-2 * time.Hour))
p2 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(2).BuildPtr(now.Add(-1 * time.Hour))
p3 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(3).BuildPtr(now)

// Store out of chronological order to prove ordering is by
// queried_at, not insertion order.
storePlayers(t, p, p2, p3, p1)

recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 10)
require.NoError(t, err)
require.Equal(t, []int{1, 2, 3}, gamesPlayed(recent))
require.WithinDuration(t, p1.QueriedAt, recent[0].QueriedAt, 0)
require.WithinDuration(t, p3.QueriedAt, recent[2].QueriedAt, 0)
requireValidDBID(t, recent[0].DBID)
})

t.Run("returns only the most recent limit stats, still ascending", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)

players := make([]*domain.PlayerPIT, 5)
for i := range players {
players[i] = domaintest.NewPlayerBuilder(playerUUID).
Fours().WithGamesPlayed(i + 1).
BuildPtr(now.Add(time.Duration(i-4) * time.Hour))
}
storePlayers(t, p, players...)

recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 3)
require.NoError(t, err)
// The three newest (games 3,4,5), oldest-first.
require.Equal(t, []int{3, 4, 5}, gamesPlayed(recent))
})

t.Run("returns empty when the player has no stats", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)

recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 10)
require.NoError(t, err)
require.Empty(t, recent)
})

t.Run("does not return another player's stats", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)
otherUUID := domaintest.NewUUID(t)

own := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(5).BuildPtr(now.Add(-1 * time.Hour))
// Other player has a more recent stat, which must not be returned.
other := domaintest.NewPlayerBuilder(otherUUID).Fours().WithGamesPlayed(9).BuildPtr(now)
storePlayers(t, p, own, other)

recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 10)
require.NoError(t, err)
require.Equal(t, []int{5}, gamesPlayed(recent))
})

t.Run("rejects an out-of-range limit", func(t *testing.T) {
t.Parallel()

playerUUID := domaintest.NewUUID(t)

_, err := p.GetRecentPlayerPITs(ctx, playerUUID, 0)
require.Error(t, err)

_, err = p.GetRecentPlayerPITs(ctx, playerUUID, 1001)
require.Error(t, err)
})
})

t.Run("GetHistory", func(t *testing.T) {
t.Parallel()

Expand Down
Loading