Commit 32efaacb by Augusto

import advanced teams and matches, search update

parent d6fd393a
...@@ -1162,6 +1162,60 @@ paths: ...@@ -1162,6 +1162,60 @@ paths:
summary: Import players from TheSports summary: Import players from TheSports
tags: tags:
- Import - 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: /import/players/career:
post: post:
description: Fetches /v3/players/{playerWyId}/career for players already present description: Fetches /v3/players/{playerWyId}/career for players already present
...@@ -1411,6 +1465,53 @@ paths: ...@@ -1411,6 +1465,53 @@ paths:
summary: Import teams from TheSports summary: Import teams from TheSports
tags: tags:
- Import - 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: /import/teams/images:
post: post:
description: Fetches /v3/teams/{wyId} for a specific team (or for teams in DB description: Fetches /v3/teams/{wyId} for a specific team (or for teams in DB
...@@ -1707,6 +1808,10 @@ paths: ...@@ -1707,6 +1808,10 @@ paths:
in: query in: query
name: country name: country
type: string type: string
- description: Filter players by gender (male/female)
in: query
name: gender
type: string
- collectionFormat: csv - collectionFormat: csv
description: 'Filter players by role name (supports multiple: role=Defender&role=Midfielder description: 'Filter players by role name (supports multiple: role=Defender&role=Midfielder
or role=Defender,Midfielder)' or role=Defender,Midfielder)'
...@@ -1872,6 +1977,73 @@ paths: ...@@ -1872,6 +1977,73 @@ paths:
summary: Get player by provider ID summary: Get player by provider ID
tags: tags:
- Players - 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: /referees:
get: get:
description: Returns a paginated list of referees, optionally filtered by name, description: Returns a paginated list of referees, optionally filtered by name,
...@@ -2225,6 +2397,30 @@ paths: ...@@ -2225,6 +2397,30 @@ paths:
in: query in: query
name: offset name: offset
type: integer 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: responses:
"200": "200":
description: OK description: OK
......
...@@ -32,17 +32,23 @@ func Connect(cfg config.Config) (*gorm.DB, error) { ...@@ -32,17 +32,23 @@ func Connect(cfg config.Config) (*gorm.DB, error) {
&models.Round{}, &models.Round{},
&models.Team{}, &models.Team{},
&models.TeamChild{}, &models.TeamChild{},
&models.TeamCareer{},
&models.TeamAdvancedStats{},
&models.Coach{}, &models.Coach{},
&models.Referee{}, &models.Referee{},
&models.Player{}, &models.Player{},
&models.Match{}, &models.Match{},
&models.MatchAdvancedStats{},
&models.MatchTeam{}, &models.MatchTeam{},
&models.MatchLineupPlayer{}, &models.MatchLineupPlayer{},
&models.MatchFormation{}, &models.MatchFormation{},
&models.PlayerTransfer{}, &models.PlayerTransfer{},
&models.PlayerCareer{}, &models.PlayerCareer{},
&models.PlayerAdvancedStats{},
&models.PlayerAdvancedPosition{},
&models.TeamSquad{}, &models.TeamSquad{},
&models.Standing{}, &models.Standing{},
&models.ImportCheckpoint{},
&models.SampleRecord{}, &models.SampleRecord{},
); err != nil { ); err != nil {
return nil, err return nil, err
......
package handlers package handlers
import ( import (
"log"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -17,5 +18,6 @@ func respondError(c *gin.Context, err error) { ...@@ -17,5 +18,6 @@ func respondError(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
log.Printf("internal error: %T: %v", err, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) 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) { ...@@ -576,6 +576,12 @@ func (h *TeamHandler) GetImagesByID(c *gin.Context) {
// @Tags Teams // @Tags Teams
// @Param limit query int false "Maximum number of items to return (default 100)" // @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 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 // @Success 200 {object} handlers.TeamListResponse
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /teams [get] // @Router /teams [get]
...@@ -593,13 +599,62 @@ func (h *TeamHandler) List(c *gin.Context) { ...@@ -593,13 +599,62 @@ func (h *TeamHandler) List(c *gin.Context) {
} }
name := c.Query("name") 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" endpoint := "/teams"
if name != "" { if name != "" {
endpoint = fmt.Sprintf("/teams?name=%s", 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 { if err != nil {
respondError(c, err) respondError(c, err)
return return
......
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"strings" "strings"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/errors" "ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models" "ScoutingSystemScoreData/internal/models"
...@@ -41,51 +42,48 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions) ...@@ -41,51 +42,48 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query := s.db.WithContext(ctx).Model(&models.Coach{}) query := s.db.WithContext(ctx).Model(&models.Coach{})
if opts.Name != "" { if opts.Name != "" {
// Normalize the search term for better matching searchLower := strings.ToLower(strings.TrimSpace(opts.Name))
normalizedSearch := utils.NormalizeText(opts.Name) likePattern := "%" + searchLower + "%"
searchTokens := utils.TokenizeSearchTerm(opts.Name) searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
searchConditions := s.db.Where( searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(coaches.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+ "unaccent(LOWER(coaches.short_name)) ILIKE unaccent(?) OR "+
"LOWER(REGEXP_REPLACE(coaches.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+ "unaccent(LOWER(coaches.first_name)) ILIKE unaccent(?) OR "+
"LOWER(REGEXP_REPLACE(coaches.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+ "unaccent(LOWER(coaches.middle_name)) ILIKE unaccent(?) OR "+
"LOWER(REGEXP_REPLACE(coaches.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?", "unaccent(LOWER(coaches.last_name)) ILIKE unaccent(?)",
likePattern, likePattern, likePattern, likePattern, 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 { if len(searchTokens) > 1 {
tokenFilter := s.db
for _, token := range searchTokens { for _, token := range searchTokens {
tokenPattern := "%" + token + "%" tokenPattern := "%" + token + "%"
query = query.Where( tokenFilter = tokenFilter.Where(
"LOWER(coaches.short_name) ILIKE ? OR LOWER(coaches.first_name) ILIKE ? OR LOWER(coaches.middle_name) ILIKE ? OR LOWER(coaches.last_name) 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(?)",
tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern,
) )
} }
searchConditions = searchConditions.Or(tokenFilter)
} }
query = query.Where(searchConditions)
// Join with teams to check for big competitions // 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 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.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 // Prioritization logic for coaches
query = query.Order("coaches.is_active DESC") 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") 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 { ...@@ -29,6 +29,7 @@ type ListPlayersOptions struct {
Name string Name string
TeamID string TeamID string
Country string Country string
Gender string
Roles []string Roles []string
} }
...@@ -44,6 +45,10 @@ func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions ...@@ -44,6 +45,10 @@ func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions
var players []models.Player var players []models.Player
baseQuery := s.db.WithContext(ctx).Model(&models.Player{}).Where("players.is_active = ?", true) 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 { if len(opts.Roles) > 0 {
roles := make([]string, 0, len(opts.Roles)) roles := make([]string, 0, len(opts.Roles))
seen := make(map[string]struct{}, len(opts.Roles)) seen := make(map[string]struct{}, len(opts.Roles))
...@@ -64,87 +69,66 @@ func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions ...@@ -64,87 +69,66 @@ func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions
} }
if opts.Name != "" { if opts.Name != "" {
// Normalize the search term for better matching // Normalize search term to lowercase for index-friendly queries
normalizedSearch := utils.NormalizeText(opts.Name) searchLower := strings.ToLower(strings.TrimSpace(opts.Name))
likePattern := "%" + searchLower + "%"
searchTokens := utils.TokenizeSearchTerm(opts.Name) searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions // Join with teams table to enable team name search
likePattern := "%" + normalizedSearch + "%" baseQuery = baseQuery.Joins("LEFT JOIN teams ON teams.wy_id = players.current_team_id")
// Create search conditions for normalized text // Build search conditions using LOWER() which can use GIN trigram indexes
// Using LOWER() and unaccent-like matching through normalized comparison // Search in: player names AND team names
searchConditions := s.db.Where( searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+ "unaccent(LOWER(players.short_name)) ILIKE unaccent(?) OR "+
"LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+ "unaccent(LOWER(players.first_name)) ILIKE unaccent(?) OR "+
"LOWER(REGEXP_REPLACE(players.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+ "unaccent(LOWER(players.middle_name)) ILIKE unaccent(?) OR "+
"LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?", "unaccent(LOWER(players.last_name)) ILIKE unaccent(?) OR "+
likePattern, likePattern, likePattern, likePattern, "unaccent(LOWER(teams.name)) ILIKE unaccent(?) OR "+
) "unaccent(LOWER(teams.short_name)) ILIKE unaccent(?)",
likePattern, likePattern, 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,
) )
// Build multi-token filter as: // For multi-word searches (e.g., "ronaldo alnassr", "samu porto"):
// (phrase-like match across fields) OR (ALL tokens match somewhere across fields) // Match if tokens appear across player name + team name fields
nameFilter := s.db.Where(searchConditions)
if len(searchTokens) > 1 { if len(searchTokens) > 1 {
// Strategy: each token must match somewhere (player name OR team name)
tokenFilter := s.db tokenFilter := s.db
for _, token := range searchTokens { for _, token := range searchTokens {
tokenPattern := "%" + token + "%" tokenPattern := "%" + token + "%"
tokenFilter = tokenFilter.Where( 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 ?", "unaccent(LOWER(players.short_name)) ILIKE unaccent(?) OR unaccent(LOWER(players.first_name)) ILIKE unaccent(?) OR "+
tokenPattern, tokenPattern, tokenPattern, tokenPattern, "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. // Optimized ranking: prioritize ShortName matches first
// This prevents a "big competition" result from outranking an exact name hit. // Using simple LOWER() comparisons that can leverage indexes
fetchQuery := baseQuery fetchQuery := baseQuery
fetchQuery = fetchQuery.Order(clause.Expr{SQL: "CASE " + fetchQuery = fetchQuery.Order(clause.Expr{SQL: "CASE " +
"WHEN LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 0 " + "WHEN unaccent(LOWER(players.short_name)) = unaccent(?) 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 unaccent(LOWER(players.short_name)) ILIKE unaccent(?) 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 unaccent(LOWER(players.last_name)) = unaccent(?) THEN 2 " +
"WHEN LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 3 " + "WHEN unaccent(LOWER(players.first_name)) = unaccent(?) THEN 3 " +
"WHEN LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 4 " + "WHEN unaccent(LOWER(players.last_name)) ILIKE unaccent(?) THEN 4 " +
"ELSE 5 END", "WHEN unaccent(LOWER(players.first_name)) ILIKE unaccent(?) THEN 5 " +
Vars: []interface{}{normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch}, "ELSE 6 END",
Vars: []interface{}{searchLower, likePattern, searchLower, searchLower, likePattern, likePattern},
}) })
// Prioritization logic: // Secondary sort: prioritize players with market value (professional/active players)
// 1. Players with current team (especially from big competitions) fetchQuery = fetchQuery.Order("CASE WHEN players.market_value IS NOT NULL AND players.market_value > 0 THEN 0 ELSE 1 END")
// 2. Players with market value (indicates active/professional status) fetchQuery = fetchQuery.Order("players.market_value DESC NULLS LAST")
// 3. Players with national team
// 4. Alphabetical by name
// Join with teams to check for big competitions // Tertiary sort: players with current team
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
fetchQuery = fetchQuery.Order("CASE WHEN players.current_team_id IS NOT NULL THEN 0 ELSE 1 END") fetchQuery = fetchQuery.Order("CASE WHEN players.current_team_id IS NOT NULL THEN 0 ELSE 1 END")
// Priority 3: Market value (higher is better) // Final sort: alphabetical
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
fetchQuery = fetchQuery.Order("players.last_name ASC") fetchQuery = fetchQuery.Order("players.last_name ASC")
fetchQuery = fetchQuery.Order("players.first_name ASC") fetchQuery = fetchQuery.Order("players.first_name ASC")
......
...@@ -3,6 +3,7 @@ package services ...@@ -3,6 +3,7 @@ package services
import ( import (
"context" "context"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
...@@ -11,7 +12,7 @@ import ( ...@@ -11,7 +12,7 @@ import (
) )
type TeamService interface { 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) GetByID(ctx context.Context, id string) (models.Team, error)
GetByWyID(ctx context.Context, wyID int) (models.Team, error) GetByWyID(ctx context.Context, wyID int) (models.Team, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Team, error) GetByAnyProviderID(ctx context.Context, providerID string) (models.Team, error)
...@@ -28,13 +29,20 @@ func NewTeamService(db *gorm.DB) TeamService { ...@@ -28,13 +29,20 @@ func NewTeamService(db *gorm.DB) TeamService {
return &teamService{db: db} 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 var teams []models.Team
query := s.db.WithContext(ctx).Model(&models.Team{}) query := s.db.WithContext(ctx).Model(&models.Team{})
if name != "" { if name != "" {
query = query.Where("name ILIKE ?", "%"+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("CASE WHEN market_value IS NULL THEN 1 ELSE 0 END")
query = query.Order("market_value DESC") query = query.Order("market_value DESC")
query = query.Order("is_active 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