package db import ( "context" "time" "git.lehouerou.net/laurent/sorare/types" "github.com/pkg/errors" "github.com/samber/lo" "github.com/shopspring/decimal" "github.com/uptrace/bun" "git.lehouerou.net/laurent/sorarebuddy/model" ) type PlayerRepository struct { *Repository[model.Player] } func NewPlayerRepository(db *bun.DB) *PlayerRepository { return &PlayerRepository{ Repository: NewRepository[model.Player](db, []string{"slug"}), } } func (r *PlayerRepository) SearchByDisplayName( ctx context.Context, displayName string, limit int, ) ([]model.Player, error) { var players []model.Player err := r.db.NewSelect(). Model(&players). Relation("Team"). Relation("DomesticLeague"). Relation("DomesticLeague.Zone"). Where("f_unaccent(player.display_name) ILIKE ?", "%"+displayName+"%"). Limit(limit). Scan(ctx) return players, err } func (r *PlayerRepository) GetOne(ctx context.Context, slug string) (model.Player, error) { var player model.Player err := r.db.NewSelect(). Model(&player). Where("player.slug = ?", slug). Relation("CardSupply"). Relation("ClubMembership"). Relation("ClubMembership.Team"). Relation("ClubMembership.Team.Country"). Relation("ClubMembership.Team.DomesticLeague"). Relation("ClubMembership.Team.DomesticLeague.Zone"). Relation("Country"). Relation("Team"). Relation("DomesticLeague"). Relation("DomesticLeague.Zone"). Scan(ctx) return player, err } func (r *PlayerRepository) GetMany(ctx context.Context, slugs ...string) ([]model.Player, error) { var players []model.Player err := r.db.NewSelect(). Model(&players). Where("player.slug IN (?)", bun.In(slugs)). Relation("CardSupply"). Relation("ClubMembership"). Relation("Country"). Relation("Team"). Relation("DomesticLeague"). Relation("DomesticLeague.Zone"). Scan(ctx) return players, err } func (r *PlayerRepository) GetAll(ctx context.Context) ([]model.Player, error) { var players []model.Player err := r.db.NewSelect(). Model(&players). Scan(ctx) return players, err } func (r *PlayerRepository) GetPlayerSlugsNotInDb(ctx context.Context, playerSlugs []string) ([]string, error) { var players []model.Player err := r.db.NewSelect(). Model(&players). Where("slug IN (?)", bun.In(playerSlugs)). Scan(ctx) if err != nil { return nil, errors.Wrap(err, "getting players not in db") } diff, _ := lo.Difference(playerSlugs, lo.Map(players, func(p model.Player, index int) string { return p.Slug })) return diff, nil } type SingleRankingsOptions struct { StartDate time.Time `json:"startDate"` Position types.Position `json:"position"` Competitions []string `json:"competitions"` Zones []int `json:"zones"` OnlyClubGames bool `json:"onlyClubGames"` OnlyStarting bool `json:"onlyStarting"` MinGameCount int `json:"minGameCount"` MinTeamGamesPlayedPercentage int `json:"minTeamGamesPlayedPercentage"` Order string `json:"order"` Limit int `json:"limit"` Rarity types.Rarity `json:"rarity"` U23 bool `json:"u23"` MinTotalMinutes int `json:"minTotalMinutes"` MinAge int `json:"minAge"` MaxAge int `json:"maxAge"` HasGameInNextGw bool `json:"hasGameInNextGw"` } type SingleRanking struct { PlayerSlug string `bun:"player_slug" json:"playerSlug"` TeamSlug string `bun:"team_slug" json:"teamSlug"` AvgTeamGoalsFor decimal.Decimal `bun:"avg_team_goals_for" json:"avgTeamGoalsFor"` AvgTeamGoalsAgainst decimal.Decimal `bun:"avg_team_goals_against" json:"avgTeamGoalsAgainst"` TotalAvgScore decimal.Decimal `bun:"total_avg_score" json:"totalAvgScore"` DecisiveAvgScore decimal.Decimal `bun:"decisive_avg_score" json:"decisiveAvgScore"` AllAroundAvgScore decimal.Decimal `bun:"all_around_avg_score" json:"allAroundAvgScore"` MinScore decimal.Decimal `bun:"min_score" json:"minScore"` MaxScore decimal.Decimal `bun:"max_score" json:"maxScore"` TotalStddevScore decimal.Decimal `bun:"total_stddev_score" json:"totalStddevScore"` GameUsedInStatsCount int `bun:"game_used_in_stats_count" json:"gameUsedInStatsCount"` GameStartedCount int `bun:"game_started_count" json:"gameStartedCount"` GameBenchedPlayedCount int `bun:"game_benched_played_count" json:"gameBenchedPlayedCount"` GameBenchedUnplayedCount int `bun:"game_benched_unplayed_count" json:"gameBenchedUnplayedCount"` TeamsGameCount int `bun:"teams_game_count" json:"teamsGameCount"` TotalPossibleMinutes int `bun:"total_possible_minutes" json:"totalPossibleMinutes"` PercentageMinutesPlayed decimal.Decimal `bun:"percentage_minutes_played" json:"percentageMinutesPlayed"` StackPlayPercentage decimal.Decimal `bun:"stack_play_percentage" json:"stackPlayPercentage"` Floor decimal.Decimal `bun:"floor" json:"floor"` MoreThan80Score int `bun:"more_than_80_score" json:"moreThan80Score"` MoreThan70Score int `bun:"more_than_70_score" json:"moreThan70Score"` MoreThan60Score int `bun:"more_than_60_score" json:"moreThan60Score"` MoreThan50Score int `bun:"more_than_50_score" json:"moreThan50Score"` DecisiveCount int `bun:"decisive_count" json:"decisiveCount"` MoreThan40AA int `bun:"more_than_40_aa" json:"moreThan40AA"` MoreThan30AA int `bun:"more_than_30_aa" json:"moreThan30AA"` MoreThan20AA int `bun:"more_than_20_aa" json:"moreThan20AA"` MoreThan10AA int `bun:"more_than_10_aa" json:"moreThan10AA"` MoreThan80ScorePercentage decimal.Decimal `bun:"more_than_80_score_percentage" json:"moreThan80ScorePercentage"` MoreThan70ScorePercentage decimal.Decimal `bun:"more_than_70_score_percentage" json:"moreThan70ScorePercentage"` MoreThan60ScorePercentage decimal.Decimal `bun:"more_than_60_score_percentage" json:"moreThan60ScorePercentage"` MoreThan50ScorePercentage decimal.Decimal `bun:"more_than_50_score_percentage" json:"moreThan50ScorePercentage"` DecisiveCountPercentage decimal.Decimal `bun:"decisive_percentage" json:"decisivePercentage"` MoreThan40AAPercentage decimal.Decimal `bun:"more_than_40_aa_percentage" json:"moreThan40AAPercentage"` MoreThan30AAPercentage decimal.Decimal `bun:"more_than_30_aa_percentage" json:"moreThan30AAPercentage"` MoreThan20AAPercentage decimal.Decimal `bun:"more_than_20_aa_percentage" json:"moreThan20AAPercentage"` MoreThan10AAPercentage decimal.Decimal `bun:"more_than_10_aa_percentage" json:"moreThan10AAPercentage"` TotalMinutes decimal.Decimal `bun:"total_minutes" json:"totalMinutes"` TotalAA decimal.Decimal `bun:"total_aa" json:"totalAA"` AAPerMin decimal.Decimal `bun:"aa_per_min" json:"aaPerMin"` Scores []*decimal.Decimal `bun:"scores,array" json:"scores"` Player model.Player `bun:"-" json:"player"` } func CalculateCutoffBirthdate(age int) time.Time { // Get the current time. now := time.Now() // Subtract the age from the current year. // Also subtract 6 months to reverse the original SQL operation. birthYear := now.Year() - age if now.Month() < 7 { // If before July, subtract an additional year. birthYear-- } // Construct the birth date using the calculated birth year. // The month and day are set to the same as the current date for simplicity. // Adjust these values as needed for your specific requirements. birthDate := time.Date(birthYear, now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) return birthDate } func (r *PlayerRepository) SingleRankings(ctx context.Context, opts SingleRankingsOptions) ([]SingleRanking, error) { minAgeBirthdate := CalculateCutoffBirthdate(opts.MinAge) maxAgeBirthdate := CalculateCutoffBirthdate(opts.MaxAge) // Return the list of team slugs for teams that have games in the currently opened game week. NextGWGameCountRequest := r.db.NewSelect(). ColumnExpr("t.slug"). TableExpr("teams AS t"). Join("JOIN games AS g ON g.away_team_slug = t.slug OR g.home_team_slug = t.slug"). Join("JOIN fixtures AS f ON f.slug = g.fixture_slug"). Where("f.state = 'opened'"). Group("t.slug") FilteredGames := r.db.NewSelect(). ColumnExpr("g.*"). TableExpr("games AS g"). Where("g.date >= ?", opts.StartDate) if opts.OnlyClubGames { FilteredGames.Join("JOIN competitions AS c ON c.slug = g.competition_slug") FilteredGames.Where("c.competition_type = 'CLUB'") } // Return for each player the sum of supplies over all seasons PlayersSupplies := r.db.NewSelect(). ColumnExpr("cs.player_slug"). ColumnExpr("SUM(cs.limited) AS limited"). ColumnExpr("SUM(cs.rare) AS rare"). ColumnExpr("SUM(cs.super_rare) AS super_rare"). ColumnExpr("SUM(cs.unique) AS unique"). TableExpr("card_supplies AS cs"). Group("player_slug") FilteredPlayers := r.db.NewSelect(). ColumnExpr("p.*"). TableExpr("players AS p"). Join("JOIN \"Supplies\" AS s ON s.player_slug = p.slug"). Join("JOIN competitions AS dc ON dc.slug = p.domestic_league_slug"). Where("p.field_position = ?", opts.Position). Where("p.birth_date >= ?", maxAgeBirthdate). Where("p.birth_date <= ?", minAgeBirthdate). Where("dc.zone_id IS NOT NULL") if opts.U23 { FilteredPlayers.Where("p.birth_date >= ?", CalculateCutoffBirthdate(23)) } if len(opts.Competitions) > 0 { FilteredPlayers.Where("dc.slug IN (?)", bun.In(opts.Competitions)) } if len(opts.Zones) > 0 { FilteredPlayers.Where("dc.zone_id IN (?)", bun.In(opts.Zones)) } if opts.HasGameInNextGw { FilteredPlayers.Join("JOIN \"NextGWGameCount\" AS ngc ON ngc.slug = p.team_slug") } PlayerPossibleGameCount := r.db.NewSelect(). ColumnExpr("p.slug"). ColumnExpr("COUNT(DISTINCT g.id) AS total_game_count"). ColumnExpr("SUM(g.minutes) AS total_minutes"). ColumnExpr("ARRAY_AGG(COALESCE(fs.score, -1) ORDER BY g.date DESC) AS scores"). ColumnExpr("COUNT(DISTINCT CASE WHEN gp.status = 'starting' THEN gp.game_id ELSE NULL END) AS game_started_count"). ColumnExpr("COUNT(DISTINCT CASE WHEN gp.status = 'bench' AND fs.minutes_played > 0 THEN gp.game_id ELSE NULL END) AS game_benched_played_count"). ColumnExpr("COUNT(DISTINCT CASE WHEN gp.status = 'bench' AND fs.minutes_played = 0 THEN gp.game_id ELSE NULL END) AS game_benched_unplayed_count"). ColumnExpr("SUM(fs.minutes_played) * 100 / SUM(g.minutes) AS percentage_minutes_played"). TableExpr("\"FilteredPlayers\" AS p"). Join("JOIN memberships AS m ON m.player_slug = p.slug AND m.start_date <= now() AND (m.end_date IS NULL OR m.end_date >= ?)", opts.StartDate). Join("JOIN games AS g ON (g.away_team_slug = m.team_slug OR g.home_team_slug = m.team_slug) AND g.date >= GREATEST(m.start_date, ?) AND (g.date <= m.end_date OR m.end_date IS NULL) AND g.date < DATE(now())", opts.StartDate). Join("LEFT JOIN game_players AS gp ON gp.player_slug = p.slug AND gp.game_id = g.id"). Join("LEFT JOIN game_player_scores AS fs ON fs.player_slug = p.slug AND fs.game_id = g.id"). Group("p.slug") if opts.OnlyClubGames { PlayerPossibleGameCount.Where("m.membership_type = 'club'") } // Return the list of games played by players filtered out with params gamePlayersRequest := r.db.NewSelect(). ColumnExpr("gp.player_slug"). ColumnExpr("gp.game_id"). ColumnExpr("gp.team_slug"). ColumnExpr("CASE WHEN gp.team_slug = g.home_team_slug THEN g.home_goals ELSE g.away_goals END as team_goals_for"). ColumnExpr("CASE WHEN gp.team_slug = g.home_team_slug THEN g.away_goals ELSE g.home_goals END as team_goals_against"). ColumnExpr("CASE WHEN gp.team_slug = g.home_team_slug THEN 'H' ELSE 'A' END AS home_away"). TableExpr("\"FilteredGames\" AS g"). Join("JOIN game_players AS gp ON g.id = gp.game_id"). Join("JOIN \"FilteredPlayers\" AS p ON p.slug = gp.player_slug") if opts.OnlyStarting { gamePlayersRequest.Where("gp.status = 'starting'") } scoresRequest := r.db.NewSelect(). ColumnExpr("gp.player_slug"). ColumnExpr("round(AVG(gp.team_goals_for), 2) AS avg_team_goals_for"). ColumnExpr("round(AVG(gp.team_goals_against), 2) AS avg_team_goals_against"). ColumnExpr("round(AVG(fs.score), 2) AS total_avg_score"). ColumnExpr("round(AVG(fs.decisive_score), 2) AS decisive_avg_score"). ColumnExpr("round(AVG(fs.all_around_score), 2) AS all_around_avg_score"). ColumnExpr("min(fs.score) as min_score"). ColumnExpr("max(fs.score) as max_score"). ColumnExpr("COALESCE(round(stddev(fs.score),2), 0) AS total_stddev_score"). ColumnExpr("COUNT(*) AS game_used_in_stats_count"). ColumnExpr("COUNT(CASE WHEN fs.score > 80 THEN 1 ELSE NULL END) AS more_than_80_score"). ColumnExpr("COUNT(CASE WHEN fs.score > 70 THEN 1 ELSE NULL END) AS more_than_70_score"). ColumnExpr("COUNT(CASE WHEN fs.score > 60 THEN 1 ELSE NULL END) AS more_than_60_score"). ColumnExpr("COUNT(CASE WHEN fs.score > 50 THEN 1 ELSE NULL END) AS more_than_50_score"). ColumnExpr("COUNT(CASE WHEN fs.decisive_score > 60 THEN 1 ELSE NULL END) AS decisive_count"). ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 40 THEN 1 ELSE NULL END) AS more_than_40_aa"). ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 30 THEN 1 ELSE NULL END) AS more_than_30_aa"). ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 20 THEN 1 ELSE NULL END) AS more_than_20_aa"). ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 10 THEN 1 ELSE NULL END) AS more_than_10_aa"). ColumnExpr("SUM(fs.minutes_played) as total_minutes"). ColumnExpr("SUM(fs.all_around_score) as total_aa"). ColumnExpr("COALESCE(ROUND(SUM(fs.all_around_score) / NULLIF(SUM(fs.minutes_played), 0), 3), 0) AS aa_per_min"). TableExpr("\"FilteredGamePlayers\" AS gp"). Join("JOIN game_player_scores AS fs ON gp.player_slug = fs.player_slug AND gp.game_id = fs.game_id"). Group("gp.player_slug") filteredScoresRequest := r.db.NewSelect(). ColumnExpr("s.*"). ColumnExpr("ROUND(((s.game_used_in_stats_count::float / ppgc.total_game_count::float) * 100)::numeric, 2) AS stack_play_percentage"). ColumnExpr("ROUND(((s.more_than_80_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_80_score_percentage"). ColumnExpr("ROUND(((s.more_than_70_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_70_score_percentage"). ColumnExpr("ROUND(((s.more_than_60_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_60_score_percentage"). ColumnExpr("ROUND(((s.more_than_50_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_50_score_percentage"). ColumnExpr("ROUND(((s.decisive_count::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS decisive_percentage"). ColumnExpr("ROUND(((s.more_than_40_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_40_aa_percentage"). ColumnExpr("ROUND(((s.more_than_30_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_30_aa_percentage"). ColumnExpr("ROUND(((s.more_than_20_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_20_aa_percentage"). ColumnExpr("ROUND(((s.more_than_10_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_10_aa_percentage"). ColumnExpr("ppgc.game_started_count"). ColumnExpr("ppgc.game_benched_played_count"). ColumnExpr("ppgc.game_benched_unplayed_count"). ColumnExpr("ppgc.total_game_count AS teams_game_count"). ColumnExpr("ppgc.scores"). ColumnExpr("ppgc.total_minutes AS total_possible_minutes"). ColumnExpr("ppgc.percentage_minutes_played"). TableExpr("\"Scores\" AS s"). Join("JOIN \"PlayerPossibleGameCount\" AS ppgc ON ppgc.slug = s.player_slug"). Where("s.game_used_in_stats_count >= ?", opts.MinGameCount). Where("s.total_minutes >= ?", opts.MinTotalMinutes) mainRequest := r.db.NewSelect(). With("Supplies", PlayersSupplies). With("NextGWGameCount", NextGWGameCountRequest). With("FilteredGames", FilteredGames). With("FilteredPlayers", FilteredPlayers). With("PlayerPossibleGameCount", PlayerPossibleGameCount). With("FilteredGamePlayers", gamePlayersRequest). With("Scores", scoresRequest). With("FilteredScores", filteredScoresRequest). ColumnExpr("fs.*"). TableExpr("\"FilteredScores\" AS fs"). Where("fs.stack_play_percentage >= ?", opts.MinTeamGamesPlayedPercentage). Order(opts.Order). Limit(opts.Limit) var res []SingleRanking err := mainRequest.Scan(ctx, &res) if err != nil { return nil, errors.Wrap(err, "selecting single rankings") } playerSlugs := lo.Map(res, func(r SingleRanking, _ int) string { return r.PlayerSlug }) if len(playerSlugs) == 0 { return res, nil } players, err := r.GetMany(ctx, playerSlugs...) if err != nil { return nil, errors.Wrap(err, "selecting players") } for i, re := range res { for _, p := range players { if re.PlayerSlug == p.Slug { res[i].Player = p break } } } return res, nil }