Commit 32efaacb by Augusto

import advanced teams and matches, search update

parent d6fd393a
......@@ -1162,6 +1162,60 @@ paths:
summary: Import players from TheSports
tags:
- Import
/import/players/advancedstats:
post:
description: 'Single: provide playerWyId+competitionId+seasonId. Batch: omit
playerWyId (imports all players for that competition+season). Auto: omit all
IDs (imports distinct player_wy_id+competition_id+season_id combos from player_careers;
resumable).'
parameters:
- description: Wyscout player ID (optional; omit for batch/auto modes)
in: query
name: playerWyId
type: integer
- description: Wyscout competition ID (required for single/batch; omit for auto
mode)
in: query
name: competitionId
type: integer
- description: Wyscout season ID (required for single/batch; omit for auto mode)
in: query
name: seasonId
type: integer
- description: 'Optional limit on number of requests (batch: players; auto:
combos)'
in: query
name: limit
type: integer
- description: 'Auto mode only: concurrent workers (default 4)'
in: query
name: workers
type: integer
- description: 'Auto mode only: reset checkpoint and restart from beginning'
in: query
name: reset
type: boolean
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import player advanced stats from Wyscout
tags:
- Import
/import/players/career:
post:
description: Fetches /v3/players/{playerWyId}/career for players already present
......@@ -1411,6 +1465,53 @@ paths:
summary: Import teams from TheSports
tags:
- Import
/import/teams/career:
post:
description: Fetches /v3/teams/{teamWyId}/career?details=competition,season
for teams in the DB (teams.wy_id not null) plus teams referenced in team_children.
Upserts records into team_careers.
parameters:
- description: Process only one Wyscout team wy_id
in: query
name: teamWyId
type: integer
- description: Limit number of teams processed when teamWyId is omitted
in: query
name: limit
type: integer
- description: Concurrent workers (default 8)
in: query
name: workers
type: integer
- description: DB batch size for reading team IDs (default 2000)
in: query
name: batchSize
type: integer
- description: Global request rate limit (default 12)
in: query
name: rps
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import team career stats from Wyscout
tags:
- Import
/import/teams/images:
post:
description: Fetches /v3/teams/{wyId} for a specific team (or for teams in DB
......@@ -1707,6 +1808,10 @@ paths:
in: query
name: country
type: string
- description: Filter players by gender (male/female)
in: query
name: gender
type: string
- collectionFormat: csv
description: 'Filter players by role name (supports multiple: role=Defender&role=Midfielder
or role=Defender,Midfielder)'
......@@ -1872,6 +1977,73 @@ paths:
summary: Get player by provider ID
tags:
- Players
/players/wyscout/{wyId}/advancedpositions:
get:
description: Returns the positions played by season for a player, plus an average
distribution across the last 5 seasons.
parameters:
- description: Wyscout player wy_id
in: path
name: wyId
required: true
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get player advanced positions
tags:
- Players
/players/wyscout/{wyId}/advancedstats:
get:
description: 'Returns player advanced stats averages: anchor season average,
last 2 years average, last 5 years average. If seasonId is omitted, the latest
season found for the player is used as anchor.'
parameters:
- description: Wyscout player wy_id
in: path
name: wyId
required: true
type: integer
- description: Anchor season wy_id (optional; defaults to latest season available)
in: query
name: seasonId
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get player advanced stats averages
tags:
- Players
/referees:
get:
description: Returns a paginated list of referees, optionally filtered by name,
......@@ -2225,6 +2397,30 @@ paths:
in: query
name: offset
type: integer
- description: Filter teams by name
in: query
name: name
type: string
- description: Filter teams by gender (male/female)
in: query
name: gender
type: string
- description: Filter teams by type (club/national)
in: query
name: teamType
type: string
- description: Alias for teamType (club/national)
in: query
name: type
type: string
- description: If truthy, filter teams by type=club
in: query
name: club
type: string
- description: If truthy, filter teams by type=national
in: query
name: national
type: string
responses:
"200":
description: OK
......
......@@ -32,17 +32,23 @@ func Connect(cfg config.Config) (*gorm.DB, error) {
&models.Round{},
&models.Team{},
&models.TeamChild{},
&models.TeamCareer{},
&models.TeamAdvancedStats{},
&models.Coach{},
&models.Referee{},
&models.Player{},
&models.Match{},
&models.MatchAdvancedStats{},
&models.MatchTeam{},
&models.MatchLineupPlayer{},
&models.MatchFormation{},
&models.PlayerTransfer{},
&models.PlayerCareer{},
&models.PlayerAdvancedStats{},
&models.PlayerAdvancedPosition{},
&models.TeamSquad{},
&models.Standing{},
&models.ImportCheckpoint{},
&models.SampleRecord{},
); err != nil {
return nil, err
......
package handlers
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
......@@ -17,5 +18,6 @@ func respondError(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("internal error: %T: %v", err, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -576,6 +576,12 @@ func (h *TeamHandler) GetImagesByID(c *gin.Context) {
// @Tags Teams
// @Param limit query int false "Maximum number of items to return (default 100)"
// @Param offset query int false "Number of items to skip before starting to collect the result set (default 0)"
// @Param name query string false "Filter teams by name"
// @Param gender query string false "Filter teams by gender (male/female)"
// @Param teamType query string false "Filter teams by type (club/national)"
// @Param type query string false "Alias for teamType (club/national)"
// @Param club query string false "If truthy, filter teams by type=club"
// @Param national query string false "If truthy, filter teams by type=national"
// @Success 200 {object} handlers.TeamListResponse
// @Failure 500 {object} map[string]string
// @Router /teams [get]
......@@ -593,13 +599,62 @@ func (h *TeamHandler) List(c *gin.Context) {
}
name := c.Query("name")
gender := c.Query("gender")
teamType := strings.TrimSpace(c.Query("teamType"))
if teamType == "" {
teamType = strings.TrimSpace(c.Query("type"))
}
if teamType == "" {
q := c.Request.URL.Query()
clubV, clubPresent := q["club"]
nationalV, nationalPresent := q["national"]
isTruthy := func(present bool, vals []string) bool {
if !present {
return false
}
if len(vals) == 0 {
return true
}
v := strings.ToLower(strings.TrimSpace(vals[0]))
if v == "" {
return true
}
switch v {
case "1", "true", "t", "yes", "y", "on":
return true
default:
return false
}
}
if isTruthy(clubPresent, clubV) {
teamType = "club"
} else if isTruthy(nationalPresent, nationalV) {
teamType = "national"
}
}
if teamType != "" {
switch strings.ToLower(strings.TrimSpace(teamType)) {
case "club", "clubs":
teamType = "club"
case "national", "nation", "nt":
teamType = "national"
default:
teamType = strings.ToLower(strings.TrimSpace(teamType))
}
}
endpoint := "/teams"
if name != "" {
endpoint = fmt.Sprintf("/teams?name=%s", name)
} else if gender != "" {
endpoint = fmt.Sprintf("/teams?gender=%s", gender)
} else if teamType != "" {
endpoint = fmt.Sprintf("/teams?teamType=%s", teamType)
}
teams, total, err := h.Service.ListTeams(c.Request.Context(), limit, offset, name)
teams, total, err := h.Service.ListTeams(c.Request.Context(), limit, offset, name, gender, teamType)
if err != nil {
respondError(c, err)
return
......
......@@ -6,6 +6,7 @@ import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
......@@ -41,51 +42,48 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query := s.db.WithContext(ctx).Model(&models.Coach{})
if opts.Name != "" {
// Normalize the search term for better matching
normalizedSearch := utils.NormalizeText(opts.Name)
searchLower := strings.ToLower(strings.TrimSpace(opts.Name))
likePattern := "%" + searchLower + "%"
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(coaches.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
"unaccent(LOWER(coaches.short_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(coaches.first_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(coaches.middle_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(coaches.last_name)) ILIKE unaccent(?)",
likePattern, likePattern, likePattern, likePattern,
)
// Also search with original pattern for exact matches
originalPattern := "%" + strings.ToLower(opts.Name) + "%"
searchConditions = searchConditions.Or(
"coaches.short_name ILIKE ? OR coaches.first_name ILIKE ? OR coaches.middle_name ILIKE ? OR coaches.last_name ILIKE ?",
originalPattern, originalPattern, originalPattern, originalPattern,
)
// Add token-based search for multi-word queries
if len(searchTokens) > 1 {
// Intentionally do NOT OR tokens together.
// For multi-token input (e.g. "pablo rosario"), we require ALL tokens to match
// somewhere across the name fields.
}
query = query.Where(searchConditions)
if len(searchTokens) > 1 {
tokenFilter := s.db
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
query = query.Where(
"LOWER(coaches.short_name) ILIKE ? OR LOWER(coaches.first_name) ILIKE ? OR LOWER(coaches.middle_name) ILIKE ? OR LOWER(coaches.last_name) ILIKE ?",
tokenFilter = tokenFilter.Where(
"unaccent(LOWER(coaches.short_name)) ILIKE unaccent(?) OR unaccent(LOWER(coaches.first_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(coaches.middle_name)) ILIKE unaccent(?) OR unaccent(LOWER(coaches.last_name)) ILIKE unaccent(?)",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
searchConditions = searchConditions.Or(tokenFilter)
}
query = query.Where(searchConditions)
// Join with teams to check for big competitions
query = query.Joins("LEFT JOIN teams ON teams.wy_id = coaches.current_team_wy_id")
query = query.Joins("LEFT JOIN competitions ON competitions.wy_id = teams.competition_wy_id")
query = query.Order(clause.Expr{SQL: "CASE " +
"WHEN unaccent(LOWER(coaches.short_name)) = unaccent(?) THEN 0 " +
"WHEN unaccent(LOWER(coaches.short_name)) ILIKE unaccent(?) THEN 1 " +
"WHEN unaccent(LOWER(coaches.last_name)) = unaccent(?) THEN 2 " +
"WHEN unaccent(LOWER(coaches.first_name)) = unaccent(?) THEN 3 " +
"WHEN unaccent(LOWER(coaches.last_name)) ILIKE unaccent(?) THEN 4 " +
"WHEN unaccent(LOWER(coaches.first_name)) ILIKE unaccent(?) THEN 5 " +
"ELSE 6 END",
Vars: []interface{}{searchLower, likePattern, searchLower, searchLower, likePattern, likePattern},
})
// Prioritization logic for coaches
query = query.Order("coaches.is_active DESC")
query = query.Order("CASE WHEN teams.competition_wy_id IN (364, 795, 426, 524, 412, 102, 103) THEN 0 ELSE 1 END")
......
......@@ -29,6 +29,7 @@ type ListPlayersOptions struct {
Name string
TeamID string
Country string
Gender string
Roles []string
}
......@@ -44,6 +45,10 @@ func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions
var players []models.Player
baseQuery := s.db.WithContext(ctx).Model(&models.Player{}).Where("players.is_active = ?", true)
if strings.TrimSpace(opts.Gender) != "" {
baseQuery = baseQuery.Where("players.gender = ?", strings.ToLower(strings.TrimSpace(opts.Gender)))
}
if len(opts.Roles) > 0 {
roles := make([]string, 0, len(opts.Roles))
seen := make(map[string]struct{}, len(opts.Roles))
......@@ -64,87 +69,66 @@ func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions
}
if opts.Name != "" {
// Normalize the search term for better matching
normalizedSearch := utils.NormalizeText(opts.Name)
// Normalize search term to lowercase for index-friendly queries
searchLower := strings.ToLower(strings.TrimSpace(opts.Name))
likePattern := "%" + searchLower + "%"
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Join with teams table to enable team name search
baseQuery = baseQuery.Joins("LEFT JOIN teams ON teams.wy_id = players.current_team_id")
// Create search conditions for normalized text
// Using LOWER() and unaccent-like matching through normalized comparison
// Build search conditions using LOWER() which can use GIN trigram indexes
// Search in: player names AND team names
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
likePattern, likePattern, likePattern, likePattern,
)
// Also search with original pattern for exact matches
originalPattern := "%" + strings.ToLower(opts.Name) + "%"
searchConditions = searchConditions.Or(
"players.short_name ILIKE ? OR players.first_name ILIKE ? OR players.middle_name ILIKE ? OR players.last_name ILIKE ?",
originalPattern, originalPattern, originalPattern, originalPattern,
"unaccent(LOWER(players.short_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.first_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.middle_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.last_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.short_name)) ILIKE unaccent(?)",
likePattern, likePattern, likePattern, likePattern, likePattern, likePattern,
)
// Build multi-token filter as:
// (phrase-like match across fields) OR (ALL tokens match somewhere across fields)
nameFilter := s.db.Where(searchConditions)
// For multi-word searches (e.g., "ronaldo alnassr", "samu porto"):
// Match if tokens appear across player name + team name fields
if len(searchTokens) > 1 {
// Strategy: each token must match somewhere (player name OR team name)
tokenFilter := s.db
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
tokenFilter = tokenFilter.Where(
"LOWER(players.short_name) ILIKE ? OR LOWER(players.first_name) ILIKE ? OR LOWER(players.middle_name) ILIKE ? OR LOWER(players.last_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
"unaccent(LOWER(players.short_name)) ILIKE unaccent(?) OR unaccent(LOWER(players.first_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.middle_name)) ILIKE unaccent(?) OR unaccent(LOWER(players.last_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.name)) ILIKE unaccent(?) OR unaccent(LOWER(teams.short_name)) ILIKE unaccent(?)",
tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
nameFilter = nameFilter.Or(tokenFilter)
searchConditions = searchConditions.Or(tokenFilter)
}
baseQuery = baseQuery.Where(nameFilter)
baseQuery = baseQuery.Where(searchConditions)
// Always rank best name match first.
// This prevents a "big competition" result from outranking an exact name hit.
// Optimized ranking: prioritize ShortName matches first
// Using simple LOWER() comparisons that can leverage indexes
fetchQuery := baseQuery
fetchQuery = fetchQuery.Order(clause.Expr{SQL: "CASE " +
"WHEN LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 0 " +
"WHEN TRIM(LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ' || LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g'))) = ? THEN 1 " +
"WHEN TRIM(LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ' || COALESCE(LOWER(REGEXP_REPLACE(players.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ', '') || LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g'))) = ? THEN 2 " +
"WHEN LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 3 " +
"WHEN LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 4 " +
"ELSE 5 END",
Vars: []interface{}{normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch},
"WHEN unaccent(LOWER(players.short_name)) = unaccent(?) THEN 0 " +
"WHEN unaccent(LOWER(players.short_name)) ILIKE unaccent(?) THEN 1 " +
"WHEN unaccent(LOWER(players.last_name)) = unaccent(?) THEN 2 " +
"WHEN unaccent(LOWER(players.first_name)) = unaccent(?) THEN 3 " +
"WHEN unaccent(LOWER(players.last_name)) ILIKE unaccent(?) THEN 4 " +
"WHEN unaccent(LOWER(players.first_name)) ILIKE unaccent(?) THEN 5 " +
"ELSE 6 END",
Vars: []interface{}{searchLower, likePattern, searchLower, searchLower, likePattern, likePattern},
})
// Prioritization logic:
// 1. Players with current team (especially from big competitions)
// 2. Players with market value (indicates active/professional status)
// 3. Players with national team
// 4. Alphabetical by name
// Secondary sort: prioritize players with market value (professional/active players)
fetchQuery = fetchQuery.Order("CASE WHEN players.market_value IS NOT NULL AND players.market_value > 0 THEN 0 ELSE 1 END")
fetchQuery = fetchQuery.Order("players.market_value DESC NULLS LAST")
// Join with teams to check for big competitions
fetchQuery = fetchQuery.Joins("LEFT JOIN teams ON teams.wy_id = players.current_team_id")
fetchQuery = fetchQuery.Joins("LEFT JOIN competitions ON competitions.wy_id = teams.competition_wy_id")
// Tie-breakers after name match rank.
// Priority 1: Big competitions (top 5 leagues + Champions League, etc.)
// Competition WyIDs for major leagues:
// Premier League: 364, La Liga: 795, Bundesliga: 426, Serie A: 524, Ligue 1: 412
// Champions League: 102, Europa League: 103, etc.
fetchQuery = fetchQuery.Order("CASE WHEN teams.competition_wy_id IN (364, 795, 426, 524, 412, 102, 103) THEN 0 ELSE 1 END")
// Priority 2: Has current team
// Tertiary sort: players with current team
fetchQuery = fetchQuery.Order("CASE WHEN players.current_team_id IS NOT NULL THEN 0 ELSE 1 END")
// Priority 3: Market value (higher is better)
fetchQuery = fetchQuery.Order("CASE WHEN players.market_value IS NULL THEN 1 ELSE 0 END")
fetchQuery = fetchQuery.Order("players.market_value DESC")
// Priority 4: Has national team
fetchQuery = fetchQuery.Order("CASE WHEN players.current_national_team_id IS NULL THEN 1 ELSE 0 END")
// Priority 5: Alphabetical
// Final sort: alphabetical
fetchQuery = fetchQuery.Order("players.last_name ASC")
fetchQuery = fetchQuery.Order("players.first_name ASC")
......
......@@ -3,6 +3,7 @@ package services
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
......@@ -11,7 +12,7 @@ import (
)
type TeamService interface {
ListTeams(ctx context.Context, limit, offset int, name string) ([]models.Team, int64, error)
ListTeams(ctx context.Context, limit, offset int, name, gender, teamType string) ([]models.Team, int64, error)
GetByID(ctx context.Context, id string) (models.Team, error)
GetByWyID(ctx context.Context, wyID int) (models.Team, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Team, error)
......@@ -28,13 +29,20 @@ func NewTeamService(db *gorm.DB) TeamService {
return &teamService{db: db}
}
func (s *teamService) ListTeams(ctx context.Context, limit, offset int, name string) ([]models.Team, int64, error) {
func (s *teamService) ListTeams(ctx context.Context, limit, offset int, name, gender, teamType string) ([]models.Team, int64, error) {
var teams []models.Team
query := s.db.WithContext(ctx).Model(&models.Team{})
if name != "" {
query = query.Where("name ILIKE ?", "%"+name+"%")
}
if strings.TrimSpace(gender) != "" {
query = query.Where("gender = ?", strings.ToLower(strings.TrimSpace(gender)))
}
if strings.TrimSpace(teamType) != "" {
query = query.Where("type = ?", strings.ToLower(strings.TrimSpace(teamType)))
}
query = query.Order("CASE WHEN market_value IS NULL THEN 1 ELSE 0 END")
query = query.Order("market_value DESC")
query = query.Order("is_active DESC")
......
-- Migration 0014: create player_advanced_stats table for Wyscout player advanced stats
CREATE TABLE IF NOT EXISTS player_advanced_stats (
id varchar(16) PRIMARY KEY,
player_wy_id integer NOT NULL,
competition_id integer NOT NULL,
season_id integer NOT NULL,
round_id integer,
positions_json jsonb,
total_json jsonb,
average_json jsonb,
percent_json jsonb,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uidx_player_advanced_stats UNIQUE (player_wy_id, competition_id, season_id)
);
CREATE INDEX IF NOT EXISTS idx_player_advanced_stats_player_wy_id ON player_advanced_stats (player_wy_id);
CREATE INDEX IF NOT EXISTS idx_player_advanced_stats_competition_id ON player_advanced_stats (competition_id);
CREATE INDEX IF NOT EXISTS idx_player_advanced_stats_season_id ON player_advanced_stats (season_id);
-- Migration 0016: create import_checkpoints table for resumable imports
CREATE TABLE IF NOT EXISTS import_checkpoints (
key text PRIMARY KEY,
last_player_wy_id integer NOT NULL DEFAULT 0,
last_competition_id integer NOT NULL DEFAULT 0,
last_season_id integer NOT NULL DEFAULT 0,
processed bigint NOT NULL DEFAULT 0,
errors bigint NOT NULL DEFAULT 0,
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_import_checkpoints_updated_at ON import_checkpoints (updated_at);
-- Migration 0017: Add indexes for player name search optimization
-- This migration adds trigram indexes for fast fuzzy text search on player names
-- Enable pg_trgm extension for trigram-based text search (if not already enabled)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Enable unaccent extension for accent-insensitive search (if not already enabled)
CREATE EXTENSION IF NOT EXISTS unaccent;
-- Add GIN trigram indexes for fast ILIKE searches on name columns
-- These indexes dramatically speed up pattern matching queries like ILIKE '%search%'
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_short_name_trgm
ON players USING GIN (LOWER(short_name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_first_name_trgm
ON players USING GIN (LOWER(first_name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_last_name_trgm
ON players USING GIN (LOWER(last_name) gin_trgm_ops);
-- Add B-tree indexes for exact match and prefix searches (faster for short_name = 'X' or short_name LIKE 'X%')
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_short_name_lower
ON players (LOWER(short_name));
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_last_name_lower
ON players (LOWER(last_name));
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_first_name_lower
ON players (LOWER(first_name));
-- Composite index for active players with market value (common sort criteria)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_active_market_value
ON players (is_active, market_value DESC NULLS LAST)
WHERE is_active = true;
-- Index for role filtering (commonly used with name search)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_role_name_lower
ON players (LOWER(role_name))
WHERE is_active = true;
-- Teams: trigram indexes for fast ILIKE searches (used by player/coach search joins)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_teams_name_trgm
ON teams USING GIN (LOWER(name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_teams_short_name_trgm
ON teams USING GIN (LOWER(short_name) gin_trgm_ops);
-- Coaches: trigram indexes for fast ILIKE searches on coach name fields
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_first_name_trgm
ON coaches USING GIN (LOWER(first_name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_middle_name_trgm
ON coaches USING GIN (LOWER(middle_name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_last_name_trgm
ON coaches USING GIN (LOWER(last_name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_short_name_trgm
ON coaches USING GIN (LOWER(short_name) gin_trgm_ops);
-- Accent-insensitive trigram indexes (support searches where user input may omit accents)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_short_name_unaccent_trgm
ON players USING GIN (unaccent(LOWER(short_name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_first_name_unaccent_trgm
ON players USING GIN (unaccent(LOWER(first_name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_players_last_name_unaccent_trgm
ON players USING GIN (unaccent(LOWER(last_name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_teams_name_unaccent_trgm
ON teams USING GIN (unaccent(LOWER(name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_teams_short_name_unaccent_trgm
ON teams USING GIN (unaccent(LOWER(short_name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_first_name_unaccent_trgm
ON coaches USING GIN (unaccent(LOWER(first_name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_middle_name_unaccent_trgm
ON coaches USING GIN (unaccent(LOWER(middle_name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_last_name_unaccent_trgm
ON coaches USING GIN (unaccent(LOWER(last_name)) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_coaches_short_name_unaccent_trgm
ON coaches USING GIN (unaccent(LOWER(short_name)) gin_trgm_ops);
-- Migration 0018: create team_advanced_stats table for Wyscout team advanced stats
CREATE TABLE IF NOT EXISTS team_advanced_stats (
id varchar(16) PRIMARY KEY,
team_wy_id integer NOT NULL,
competition_id integer NOT NULL,
season_id integer NOT NULL,
round_id integer,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uidx_team_advanced_stats UNIQUE (team_wy_id, competition_id, season_id)
);
CREATE INDEX IF NOT EXISTS idx_team_advanced_stats_team_wy_id ON team_advanced_stats (team_wy_id);
CREATE INDEX IF NOT EXISTS idx_team_advanced_stats_competition_id ON team_advanced_stats (competition_id);
CREATE INDEX IF NOT EXISTS idx_team_advanced_stats_season_id ON team_advanced_stats (season_id);
-- Migration 0020: extend import_checkpoints to support team advanced stats auto import
ALTER TABLE import_checkpoints
ADD COLUMN IF NOT EXISTS last_team_wy_id integer NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_import_checkpoints_last_team_wy_id ON import_checkpoints (last_team_wy_id);
-- Migration 0021: create match_advanced_stats table
CREATE TABLE IF NOT EXISTS match_advanced_stats (
id text PRIMARY KEY,
match_wy_id integer NOT NULL,
team_wy_id integer NOT NULL,
competition_id integer,
season_id integer,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uidx_match_advanced_stats ON match_advanced_stats (match_wy_id, team_wy_id);
CREATE INDEX IF NOT EXISTS idx_match_advanced_stats_match_wy_id ON match_advanced_stats (match_wy_id);
CREATE INDEX IF NOT EXISTS idx_match_advanced_stats_team_wy_id ON match_advanced_stats (team_wy_id);
CREATE INDEX IF NOT EXISTS idx_match_advanced_stats_comp_season ON match_advanced_stats (competition_id, season_id);
-- Migration 0022: add explicit columns to match_advanced_stats
ALTER TABLE match_advanced_stats
ADD COLUMN IF NOT EXISTS general_shots integer,
ADD COLUMN IF NOT EXISTS general_fouls integer,
ADD COLUMN IF NOT EXISTS general_corners integer,
ADD COLUMN IF NOT EXISTS general_red_cards integer,
ADD COLUMN IF NOT EXISTS general_yellow_cards integer,
ADD COLUMN IF NOT EXISTS general_offsides integer,
ADD COLUMN IF NOT EXISTS general_dribbles integer,
ADD COLUMN IF NOT EXISTS general_goals integer,
ADD COLUMN IF NOT EXISTS general_xg_per_shot double precision,
ADD COLUMN IF NOT EXISTS general_avg_distance double precision,
ADD COLUMN IF NOT EXISTS general_xg double precision,
ADD COLUMN IF NOT EXISTS general_progressive_runs integer,
ADD COLUMN IF NOT EXISTS general_touches_in_box integer,
ADD COLUMN IF NOT EXISTS general_fouls_suffered integer,
ADD COLUMN IF NOT EXISTS general_shots_on_target integer,
ADD COLUMN IF NOT EXISTS general_shots_blocked integer,
ADD COLUMN IF NOT EXISTS general_shots_outside_box integer,
ADD COLUMN IF NOT EXISTS general_shots_outside_box_on_target integer,
ADD COLUMN IF NOT EXISTS general_shots_on_post integer,
ADD COLUMN IF NOT EXISTS general_shots_wide integer,
ADD COLUMN IF NOT EXISTS general_shots_from_box integer,
ADD COLUMN IF NOT EXISTS general_shots_from_box_on_target integer,
ADD COLUMN IF NOT EXISTS general_free_kicks integer,
ADD COLUMN IF NOT EXISTS general_shots_from_danger_zone integer,
ADD COLUMN IF NOT EXISTS general_total_throw_ins integer,
ADD COLUMN IF NOT EXISTS general_left_throw_ins integer,
ADD COLUMN IF NOT EXISTS general_right_throw_ins integer,
ADD COLUMN IF NOT EXISTS possession_percent integer,
ADD COLUMN IF NOT EXISTS possession_pure_possession_time text,
ADD COLUMN IF NOT EXISTS possession_number integer,
ADD COLUMN IF NOT EXISTS possession_avg_possession_duration text,
ADD COLUMN IF NOT EXISTS possession_reaching_opponent_half integer,
ADD COLUMN IF NOT EXISTS possession_reaching_opponent_box integer,
ADD COLUMN IF NOT EXISTS possession_1_15 integer,
ADD COLUMN IF NOT EXISTS possession_16_30 integer,
ADD COLUMN IF NOT EXISTS possession_31_45 integer,
ADD COLUMN IF NOT EXISTS possession_46_60 integer,
ADD COLUMN IF NOT EXISTS possession_61_75 integer,
ADD COLUMN IF NOT EXISTS possession_76_90 integer,
ADD COLUMN IF NOT EXISTS possession_91_105 integer,
ADD COLUMN IF NOT EXISTS possession_106_120 integer,
ADD COLUMN IF NOT EXISTS possession_minutes_1_15 text,
ADD COLUMN IF NOT EXISTS possession_minutes_16_30 text,
ADD COLUMN IF NOT EXISTS possession_minutes_31_45 text,
ADD COLUMN IF NOT EXISTS possession_minutes_46_60 text,
ADD COLUMN IF NOT EXISTS possession_minutes_61_75 text,
ADD COLUMN IF NOT EXISTS possession_minutes_76_90 text,
ADD COLUMN IF NOT EXISTS possession_minutes_91_105 text,
ADD COLUMN IF NOT EXISTS possession_minutes_106_120 text,
ADD COLUMN IF NOT EXISTS possession_total_time text,
ADD COLUMN IF NOT EXISTS possession_dead_time text,
ADD COLUMN IF NOT EXISTS open_play_total integer,
ADD COLUMN IF NOT EXISTS open_play_short integer,
ADD COLUMN IF NOT EXISTS open_play_medium integer,
ADD COLUMN IF NOT EXISTS open_play_long integer,
ADD COLUMN IF NOT EXISTS open_play_very_long integer,
ADD COLUMN IF NOT EXISTS attacks_total integer,
ADD COLUMN IF NOT EXISTS attacks_with_shots integer,
ADD COLUMN IF NOT EXISTS attacks_positional_attack integer,
ADD COLUMN IF NOT EXISTS attacks_positional_with_shots integer,
ADD COLUMN IF NOT EXISTS attacks_counter_attacks integer,
ADD COLUMN IF NOT EXISTS attacks_free_kicks integer,
ADD COLUMN IF NOT EXISTS attacks_free_kicks_with_shot integer,
ADD COLUMN IF NOT EXISTS attacks_corners integer,
ADD COLUMN IF NOT EXISTS attacks_corners_with_shot integer,
ADD COLUMN IF NOT EXISTS transitions_recoveries_high integer,
ADD COLUMN IF NOT EXISTS transitions_recoveries_medium integer,
ADD COLUMN IF NOT EXISTS transitions_recoveries_low integer,
ADD COLUMN IF NOT EXISTS transitions_recoveries_total integer,
ADD COLUMN IF NOT EXISTS transitions_opponent_half_recoveries integer,
ADD COLUMN IF NOT EXISTS transitions_losses_high integer,
ADD COLUMN IF NOT EXISTS transitions_losses_medium integer,
ADD COLUMN IF NOT EXISTS transitions_losses_low integer,
ADD COLUMN IF NOT EXISTS transitions_losses_total integer,
ADD COLUMN IF NOT EXISTS transitions_own_half_losses integer,
ADD COLUMN IF NOT EXISTS passes_crosses_total integer,
ADD COLUMN IF NOT EXISTS passes_crosses_successful integer,
ADD COLUMN IF NOT EXISTS passes_crosses_blocked integer,
ADD COLUMN IF NOT EXISTS passes_crosses_low integer,
ADD COLUMN IF NOT EXISTS passes_crosses_high integer,
ADD COLUMN IF NOT EXISTS passes_crosses_from_left_flank integer,
ADD COLUMN IF NOT EXISTS passes_crosses_from_right_flank integer,
ADD COLUMN IF NOT EXISTS passes_crosses_from_left_flank_successful integer,
ADD COLUMN IF NOT EXISTS passes_crosses_from_right_flank_successful integer,
ADD COLUMN IF NOT EXISTS passes_passes integer,
ADD COLUMN IF NOT EXISTS passes_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_forward_passes integer,
ADD COLUMN IF NOT EXISTS passes_forward_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_back_passes integer,
ADD COLUMN IF NOT EXISTS passes_back_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_progressive_passes integer,
ADD COLUMN IF NOT EXISTS passes_progressive_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_long_passes integer,
ADD COLUMN IF NOT EXISTS passes_long_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_through_passes integer,
ADD COLUMN IF NOT EXISTS passes_through_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_pass_to_final_thirds integer,
ADD COLUMN IF NOT EXISTS passes_pass_to_final_thirds_successful integer,
ADD COLUMN IF NOT EXISTS passes_pass_to_penalty_areas integer,
ADD COLUMN IF NOT EXISTS passes_pass_to_penalty_areas_successful integer,
ADD COLUMN IF NOT EXISTS passes_vertical_passes integer,
ADD COLUMN IF NOT EXISTS passes_vertical_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_deep_completed_passes integer,
ADD COLUMN IF NOT EXISTS passes_deep_completed_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_key_passes integer,
ADD COLUMN IF NOT EXISTS passes_key_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_assists integer,
ADD COLUMN IF NOT EXISTS passes_smart_passes integer,
ADD COLUMN IF NOT EXISTS passes_smart_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_shot_assists integer,
ADD COLUMN IF NOT EXISTS passes_short_medium_passes integer,
ADD COLUMN IF NOT EXISTS passes_short_medium_passes_successful integer,
ADD COLUMN IF NOT EXISTS passes_avg_pass_length double precision,
ADD COLUMN IF NOT EXISTS passes_avg_pass_to_final_third_length double precision,
ADD COLUMN IF NOT EXISTS passes_match_tempo double precision,
ADD COLUMN IF NOT EXISTS passes_lateral_passes integer,
ADD COLUMN IF NOT EXISTS passes_lateral_passes_successful integer,
ADD COLUMN IF NOT EXISTS defence_tackles integer,
ADD COLUMN IF NOT EXISTS defence_interceptions integer,
ADD COLUMN IF NOT EXISTS defence_clearances integer,
ADD COLUMN IF NOT EXISTS defence_ppda double precision,
ADD COLUMN IF NOT EXISTS duels_duels integer,
ADD COLUMN IF NOT EXISTS duels_duels_successful integer,
ADD COLUMN IF NOT EXISTS duels_defensive_duels integer,
ADD COLUMN IF NOT EXISTS duels_defensive_duels_successful integer,
ADD COLUMN IF NOT EXISTS duels_offensive_duels integer,
ADD COLUMN IF NOT EXISTS duels_offensive_duels_successful integer,
ADD COLUMN IF NOT EXISTS duels_loose_ball_duels integer,
ADD COLUMN IF NOT EXISTS duels_loose_ball_duels_successful integer,
ADD COLUMN IF NOT EXISTS duels_aerial_duels integer,
ADD COLUMN IF NOT EXISTS duels_aerial_duels_successful integer,
ADD COLUMN IF NOT EXISTS duels_ground_duels integer,
ADD COLUMN IF NOT EXISTS duels_ground_duels_successful integer,
ADD COLUMN IF NOT EXISTS duels_dribbles integer,
ADD COLUMN IF NOT EXISTS duels_dribbles_successful integer,
ADD COLUMN IF NOT EXISTS duels_challenge_intensity double precision,
ADD COLUMN IF NOT EXISTS flanks_left_flank_attacks integer,
ADD COLUMN IF NOT EXISTS flanks_left_flank_xg double precision,
ADD COLUMN IF NOT EXISTS flanks_right_flank_attacks integer,
ADD COLUMN IF NOT EXISTS flanks_right_flank_xg double precision,
ADD COLUMN IF NOT EXISTS flanks_center_attacks integer,
ADD COLUMN IF NOT EXISTS flanks_center_xg double precision;
-- Migration 0023: extend import_checkpoints for match advanced stats auto-import
ALTER TABLE import_checkpoints
ADD COLUMN IF NOT EXISTS last_match_wy_id integer NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_import_checkpoints_last_match_wy_id ON import_checkpoints (last_match_wy_id);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment