feat: api - GET /api/v2/versions/{versionCode}/servers/{serverKey}/players - add more sort options (#7)
ci/woodpecker/push/govulncheck Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details

Reviewed-on: twhelp/corev3#7
This commit is contained in:
Dawid Wysokiński 2024-02-27 07:09:24 +00:00
parent 70deae8696
commit d12bcf5e4e
17 changed files with 971 additions and 312 deletions

View File

@ -180,6 +180,7 @@ paths:
- $ref: "#/components/parameters/CursorQueryParam"
- $ref: "#/components/parameters/LimitQueryParam"
- $ref: "#/components/parameters/PlayerDeletedQueryParam"
- $ref: "#/components/parameters/PlayerSortQueryParam"
responses:
200:
$ref: "#/components/responses/ListPlayersResponse"
@ -1100,6 +1101,26 @@ components:
schema:
type: boolean
required: false
PlayerSortQueryParam:
name: sort
in: query
description: Order matters!
schema:
type: array
items:
type: string
enum:
- odScoreAtt:ASC
- odScoreAtt:DESC
- odScoreDef:ASC
- odScoreDef:DESC
- odScoreTotal:ASC
- odScoreTotal:DESC
- points:ASC
- points:DESC
- deletedAt:ASC
- deletedAt:DESC
maxItems: 2
VersionCodePathParam:
in: path
name: versionCode

View File

@ -3,6 +3,5 @@ package adapter
import "errors"
var (
errUnsupportedSortValue = errors.New("unsupported sort value")
errSortNoUniqueField = errors.New("no unique field used for sorting")
errInvalidSortValue = errors.New("invalid sort value")
)

View File

@ -1,6 +1,11 @@
package adapter
import "github.com/uptrace/bun"
import (
"errors"
"slices"
"github.com/uptrace/bun"
)
func appendODSetClauses(q *bun.InsertQuery) *bun.InsertQuery {
return q.Set("rank_att = EXCLUDED.rank_att").
@ -23,3 +28,90 @@ func separateListResultAndNext[T any](res []T, limit int) ([]T, T) {
return res, next
}
type sortDirection uint8
var errInvalidSortDirection = errors.New("invalid sort direction")
const (
sortDirectionASC sortDirection = iota + 1
sortDirectionDESC
)
func (sd sortDirection) String() string {
switch sd {
case sortDirectionASC:
return "ASC"
case sortDirectionDESC:
return "DESC"
default:
return "unknown"
}
}
type cursorPaginationApplierDataElement struct {
unique bool
column string
direction sortDirection
value any
}
type cursorPaginationApplier struct {
data []cursorPaginationApplierDataElement
}
var errCursorNoUniqueFieldUsedForSorting = errors.New("cursor - no unique field used for sorting")
func (a cursorPaginationApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
if len(a.data) == 0 {
return q
}
uniqueIndex := slices.IndexFunc(a.data, func(element cursorPaginationApplierDataElement) bool {
return element.unique
})
if uniqueIndex < 0 {
return q.Err(errCursorNoUniqueFieldUsedForSorting)
}
return q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
dataLen := len(a.data)
// based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245
return q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
for i := 0; i < dataLen; i++ {
q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
current := a.data[i]
for j := 0; j < i; j++ {
prev := a.data[j]
q = q.Where("? = ?", bun.Safe(prev.column), prev.value)
}
column := bun.Safe(current.column)
greaterSymbol := bun.Safe(">")
lessSymbol := bun.Safe("<")
if i == dataLen-1 {
greaterSymbol = ">="
lessSymbol = "<="
}
switch current.direction {
case sortDirectionASC:
q = q.Where("? ? ?", column, greaterSymbol, current.value)
case sortDirectionDESC:
q = q.Where("? ? ?", column, lessSymbol, current.value)
default:
return q.Err(errInvalidSortDirection)
}
return q
})
}
return q
})
})
}

View File

@ -92,7 +92,7 @@ func (a listEnnoblementsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuer
case domain.EnnoblementSortServerKeyDESC:
q = q.Order("ennoblement.server_key DESC")
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}

View File

@ -162,8 +162,28 @@ func (a listPlayersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
q = q.Order("player.server_key ASC")
case domain.PlayerSortServerKeyDESC:
q = q.Order("player.server_key DESC")
case domain.PlayerSortODScoreAttASC:
q = q.Order("player.score_att ASC")
case domain.PlayerSortODScoreAttDESC:
q = q.Order("player.score_att DESC")
case domain.PlayerSortODScoreDefASC:
q = q.Order("player.score_def ASC")
case domain.PlayerSortODScoreDefDESC:
q = q.Order("player.score_def DESC")
case domain.PlayerSortODScoreTotalASC:
q = q.Order("player.score_total ASC")
case domain.PlayerSortODScoreTotalDESC:
q = q.Order("player.score_total DESC")
case domain.PlayerSortPointsASC:
q = q.Order("player.points ASC")
case domain.PlayerSortPointsDESC:
q = q.Order("player.points DESC")
case domain.PlayerSortDeletedAtASC:
q = q.OrderExpr("COALESCE(player.deleted_at, ?) ASC", time.Time{})
case domain.PlayerSortDeletedAtDESC:
q = q.OrderExpr("COALESCE(player.deleted_at, ?) DESC", time.Time{})
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}
@ -172,85 +192,97 @@ func (a listPlayersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
//nolint:gocyclo
func (a listPlayersParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery {
if a.params.Cursor().IsZero() {
cursor := a.params.Cursor()
if cursor.IsZero() {
return q
}
q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
cursor := a.params.Cursor()
cursorID := cursor.ID()
cursorServerKey := cursor.ServerKey()
sort := a.params.Sort()
cursorApplier := cursorPaginationApplier{
data: make([]cursorPaginationApplierDataElement, 0, len(sort)),
}
sort := a.params.Sort()
sortLen := len(sort)
for _, s := range sort {
var el cursorPaginationApplierDataElement
// based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245
switch {
case sortLen == 1:
switch sort[0] {
case domain.PlayerSortIDASC:
q = q.Where("player.id >= ?", cursorID)
case domain.PlayerSortIDDESC:
q = q.Where("player.id <= ?", cursorID)
case domain.PlayerSortServerKeyASC,
domain.PlayerSortServerKeyDESC:
return q.Err(errSortNoUniqueField)
default:
return q.Err(errUnsupportedSortValue)
}
case sortLen > 1:
q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
for i := 0; i < sortLen; i++ {
q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
current := sort[i]
for j := 0; j < i; j++ {
s := sort[j]
switch s {
case domain.PlayerSortIDASC,
domain.PlayerSortIDDESC:
q = q.Where("player.id = ?", cursorID)
case domain.PlayerSortServerKeyASC,
domain.PlayerSortServerKeyDESC:
q = q.Where("player.server_key = ?", cursorServerKey)
default:
return q.Err(errUnsupportedSortValue)
}
}
greaterSymbol := bun.Safe(">")
lessSymbol := bun.Safe("<")
if i == sortLen-1 {
greaterSymbol = ">="
lessSymbol = "<="
}
switch current {
case domain.PlayerSortIDASC:
q = q.Where("player.id ? ?", greaterSymbol, cursorID)
case domain.PlayerSortIDDESC:
q = q.Where("player.id ? ?", lessSymbol, cursorID)
case domain.PlayerSortServerKeyASC:
q = q.Where("player.server_key ? ?", greaterSymbol, cursorServerKey)
case domain.PlayerSortServerKeyDESC:
q = q.Where("player.server_key ? ?", lessSymbol, cursorServerKey)
default:
return q.Err(errUnsupportedSortValue)
}
return q
})
}
return q
})
switch s {
case domain.PlayerSortIDASC:
el.value = cursor.ID()
el.unique = true
el.column = "player.id"
el.direction = sortDirectionASC
case domain.PlayerSortIDDESC:
el.value = cursor.ID()
el.unique = true
el.column = "player.id"
el.direction = sortDirectionDESC
case domain.PlayerSortServerKeyASC:
el.value = cursor.ServerKey()
el.unique = false
el.column = "player.server_key"
el.direction = sortDirectionASC
case domain.PlayerSortServerKeyDESC:
el.value = cursor.ServerKey()
el.unique = false
el.column = "player.server_key"
el.direction = sortDirectionDESC
case domain.PlayerSortODScoreAttASC:
el.value = cursor.ODScoreAtt()
el.unique = false
el.column = "player.score_att"
el.direction = sortDirectionASC
case domain.PlayerSortODScoreAttDESC:
el.value = cursor.ODScoreAtt()
el.unique = false
el.column = "player.score_att"
el.direction = sortDirectionDESC
case domain.PlayerSortODScoreDefASC:
el.value = cursor.ODScoreDef()
el.unique = false
el.column = "player.score_def"
el.direction = sortDirectionASC
case domain.PlayerSortODScoreDefDESC:
el.value = cursor.ODScoreDef()
el.unique = false
el.column = "player.score_def"
el.direction = sortDirectionDESC
case domain.PlayerSortODScoreTotalASC:
el.value = cursor.ODScoreTotal()
el.unique = false
el.column = "player.score_total"
el.direction = sortDirectionASC
case domain.PlayerSortODScoreTotalDESC:
el.value = cursor.ODScoreTotal()
el.unique = false
el.column = "player.score_total"
el.direction = sortDirectionDESC
case domain.PlayerSortPointsASC:
el.value = cursor.Points()
el.unique = false
el.column = "player.points"
el.direction = sortDirectionASC
case domain.PlayerSortPointsDESC:
el.value = cursor.Points()
el.unique = false
el.column = "player.points"
el.direction = sortDirectionDESC
case domain.PlayerSortDeletedAtASC:
el.value = cursor.DeletedAt()
el.unique = false
el.column = "COALESCE(player.deleted_at, '0001-01-01 00:00:00+00:00')"
el.direction = sortDirectionASC
case domain.PlayerSortDeletedAtDESC:
el.value = cursor.DeletedAt()
el.unique = false
el.column = "COALESCE(player.deleted_at, '0001-01-01 00:00:00+00:00')"
el.direction = sortDirectionDESC
default:
return q.Err(errInvalidSortValue)
}
return q
})
cursorApplier.data = append(cursorApplier.data, el)
}
return q
return q.Apply(cursorApplier.apply)
}

View File

@ -97,7 +97,7 @@ func (a listPlayerSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQ
case domain.PlayerSnapshotSortServerKeyDESC:
q = q.Order("ps.server_key DESC")
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}

View File

@ -228,92 +228,55 @@ func (a listServersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
case domain.ServerSortOpenDESC:
q = q.Order("server.open DESC")
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}
return q.Apply(a.applyCursor).Limit(a.params.Limit() + 1)
}
//nolint:gocyclo
func (a listServersParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery {
if a.params.Cursor().IsZero() {
cursor := a.params.Cursor()
if cursor.IsZero() {
return q
}
q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
cursorKey := a.params.Cursor().Key()
cursorOpen := a.params.Cursor().Open()
sort := a.params.Sort()
cursorApplier := cursorPaginationApplier{
data: make([]cursorPaginationApplierDataElement, 0, len(sort)),
}
sort := a.params.Sort()
sortLen := len(sort)
for _, s := range sort {
var el cursorPaginationApplierDataElement
// based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245
switch {
case sortLen == 1:
switch sort[0] {
case domain.ServerSortKeyASC:
q = q.Where("server.key >= ?", cursorKey)
case domain.ServerSortKeyDESC:
q = q.Where("server.key <= ?", cursorKey)
case domain.ServerSortOpenASC, domain.ServerSortOpenDESC:
return q.Err(errSortNoUniqueField)
default:
return q.Err(errUnsupportedSortValue)
}
case sortLen > 1:
q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
for i := 0; i < sortLen; i++ {
q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
current := sort[i]
for j := 0; j < i; j++ {
s := sort[j]
switch s {
case domain.ServerSortKeyASC,
domain.ServerSortKeyDESC:
q = q.Where("server.key = ?", cursorKey)
case domain.ServerSortOpenASC,
domain.ServerSortOpenDESC:
q = q.Where("server.open = ?", cursorOpen)
default:
return q.Err(errUnsupportedSortValue)
}
}
greaterSymbol := bun.Safe(">")
lessSymbol := bun.Safe("<")
if i == sortLen-1 {
greaterSymbol = ">="
lessSymbol = "<="
}
switch current {
case domain.ServerSortKeyASC:
q = q.Where("server.key ? ?", greaterSymbol, cursorKey)
case domain.ServerSortKeyDESC:
q = q.Where("server.key ? ?", lessSymbol, cursorKey)
case domain.ServerSortOpenASC:
q = q.Where("server.open ? ?", greaterSymbol, cursorOpen)
case domain.ServerSortOpenDESC:
q = q.Where("server.open ? ?", lessSymbol, cursorOpen)
default:
return q.Err(errUnsupportedSortValue)
}
return q
})
}
return q
})
switch s {
case domain.ServerSortKeyASC:
el.value = cursor.Key()
el.unique = true
el.column = "server.key"
el.direction = sortDirectionASC
case domain.ServerSortKeyDESC:
el.value = cursor.Key()
el.unique = true
el.column = "server.key"
el.direction = sortDirectionDESC
case domain.ServerSortOpenASC:
el.value = cursor.Open()
el.unique = false
el.column = "server.open"
el.direction = sortDirectionASC
case domain.ServerSortOpenDESC:
el.value = cursor.Open()
el.unique = false
el.column = "server.open"
el.direction = sortDirectionDESC
default:
return q.Err(errInvalidSortValue)
}
return q
})
cursorApplier.data = append(cursorApplier.data, el)
}
return q
return q.Apply(cursorApplier.apply)
}

View File

@ -117,6 +117,10 @@ func (repo *TribeBunRepository) List(
) (domain.ListTribesResult, error) {
var tribes bunmodel.Tribes
fmt.Println(repo.db.NewSelect().
Model(&tribes).
Apply(listTribesParamsApplier{params: params}.apply).String())
if err := repo.db.NewSelect().
Model(&tribes).
Apply(listTribesParamsApplier{params: params}.apply).
@ -212,7 +216,7 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
case domain.TribeSortDeletedAtDESC:
q = q.OrderExpr("COALESCE(tribe.deleted_at, ?) DESC", time.Time{})
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}
@ -221,145 +225,107 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
//nolint:gocyclo
func (a listTribesParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery {
if a.params.Cursor().IsZero() {
cursor := a.params.Cursor()
if cursor.IsZero() {
return q
}
q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
cursor := a.params.Cursor()
cursorID := cursor.ID()
cursorServerKey := cursor.ServerKey()
cursorODScoreAtt := cursor.ODScoreAtt()
cursorODScoreDef := cursor.ODScoreDef()
cursorODScoreTotal := cursor.ODScoreTotal()
cursorPoints := cursor.Points()
cursorDominance := cursor.Dominance()
cursorDeletedAt := cursor.DeletedAt()
sort := a.params.Sort()
cursorApplier := cursorPaginationApplier{
data: make([]cursorPaginationApplierDataElement, 0, len(sort)),
}
sort := a.params.Sort()
sortLen := len(sort)
for _, s := range sort {
var el cursorPaginationApplierDataElement
// based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245
switch {
case sortLen == 1:
switch sort[0] {
case domain.TribeSortIDASC:
q = q.Where("tribe.id >= ?", cursorID)
case domain.TribeSortIDDESC:
q = q.Where("tribe.id <= ?", cursorID)
case domain.TribeSortServerKeyASC,
domain.TribeSortServerKeyDESC,
domain.TribeSortODScoreAttASC,
domain.TribeSortODScoreAttDESC,
domain.TribeSortODScoreDefASC,
domain.TribeSortODScoreDefDESC,
domain.TribeSortODScoreTotalASC,
domain.TribeSortODScoreTotalDESC,
domain.TribeSortPointsASC,
domain.TribeSortPointsDESC,
domain.TribeSortDominanceASC,
domain.TribeSortDominanceDESC,
domain.TribeSortDeletedAtASC,
domain.TribeSortDeletedAtDESC:
return q.Err(errSortNoUniqueField)
default:
return q.Err(errUnsupportedSortValue)
}
case sortLen > 1:
q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
for i := 0; i < sortLen; i++ {
q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery {
current := sort[i]
for j := 0; j < i; j++ {
s := sort[j]
switch s {
case domain.TribeSortIDASC,
domain.TribeSortIDDESC:
q = q.Where("tribe.id = ?", cursorID)
case domain.TribeSortServerKeyASC,
domain.TribeSortServerKeyDESC:
q = q.Where("tribe.server_key = ?", cursorServerKey)
case domain.TribeSortODScoreAttASC,
domain.TribeSortODScoreAttDESC:
q = q.Where("tribe.score_att = ?", cursorODScoreAtt)
case domain.TribeSortODScoreDefASC,
domain.TribeSortODScoreDefDESC:
q = q.Where("tribe.score_def = ?", cursorODScoreDef)
case domain.TribeSortODScoreTotalASC,
domain.TribeSortODScoreTotalDESC:
q = q.Where("tribe.score_total = ?", cursorODScoreTotal)
case domain.TribeSortPointsASC,
domain.TribeSortPointsDESC:
q = q.Where("tribe.points = ?", cursorPoints)
case domain.TribeSortDominanceASC,
domain.TribeSortDominanceDESC:
q = q.Where("tribe.dominance = ?", cursorDominance)
case domain.TribeSortDeletedAtASC,
domain.TribeSortDeletedAtDESC:
q = q.Where("COALESCE(tribe.deleted_at, ?) = ?", time.Time{}, cursorDeletedAt)
default:
return q.Err(errUnsupportedSortValue)
}
}
greaterSymbol := bun.Safe(">")
lessSymbol := bun.Safe("<")
if i == sortLen-1 {
greaterSymbol = ">="
lessSymbol = "<="
}
switch current {
case domain.TribeSortIDASC:
q = q.Where("tribe.id ? ?", greaterSymbol, cursorID)
case domain.TribeSortIDDESC:
q = q.Where("tribe.id ? ?", lessSymbol, cursorID)
case domain.TribeSortServerKeyASC:
q = q.Where("tribe.server_key ? ?", greaterSymbol, cursorServerKey)
case domain.TribeSortServerKeyDESC:
q = q.Where("tribe.server_key ? ?", lessSymbol, cursorServerKey)
case domain.TribeSortODScoreAttASC:
q = q.Where("tribe.score_att ? ?", greaterSymbol, cursorODScoreAtt)
case domain.TribeSortODScoreAttDESC:
q = q.Where("tribe.score_att ? ?", lessSymbol, cursorODScoreAtt)
case domain.TribeSortODScoreDefASC:
q = q.Where("tribe.score_def ? ?", greaterSymbol, cursorODScoreDef)
case domain.TribeSortODScoreDefDESC:
q = q.Where("tribe.score_def ? ?", lessSymbol, cursorODScoreDef)
case domain.TribeSortODScoreTotalASC:
q = q.Where("tribe.score_total ? ?", greaterSymbol, cursorODScoreTotal)
case domain.TribeSortODScoreTotalDESC:
q = q.Where("tribe.score_total ? ?", lessSymbol, cursorODScoreTotal)
case domain.TribeSortPointsASC:
q = q.Where("tribe.points ? ?", greaterSymbol, cursorPoints)
case domain.TribeSortPointsDESC:
q = q.Where("tribe.points ? ?", lessSymbol, cursorPoints)
case domain.TribeSortDominanceASC:
q = q.Where("tribe.dominance ? ?", greaterSymbol, cursorDominance)
case domain.TribeSortDominanceDESC:
q = q.Where("tribe.dominance ? ?", lessSymbol, cursorDominance)
case domain.TribeSortDeletedAtASC:
q = q.Where("COALESCE(tribe.deleted_at, ?) ? ?", time.Time{}, greaterSymbol, cursorDeletedAt)
case domain.TribeSortDeletedAtDESC:
q = q.Where("COALESCE(tribe.deleted_at, ?) ? ?", time.Time{}, lessSymbol, cursorDeletedAt)
default:
return q.Err(errUnsupportedSortValue)
}
return q
})
}
return q
})
switch s {
case domain.TribeSortIDASC:
el.value = cursor.ID()
el.unique = true
el.column = "tribe.id"
el.direction = sortDirectionASC
case domain.TribeSortIDDESC:
el.value = cursor.ID()
el.unique = true
el.column = "tribe.id"
el.direction = sortDirectionDESC
case domain.TribeSortServerKeyASC:
el.value = cursor.ServerKey()
el.unique = false
el.column = "tribe.server_key"
el.direction = sortDirectionASC
case domain.TribeSortServerKeyDESC:
el.value = cursor.ServerKey()
el.unique = false
el.column = "tribe.server_key"
el.direction = sortDirectionDESC
case domain.TribeSortODScoreAttASC:
el.value = cursor.ODScoreAtt()
el.unique = false
el.column = "tribe.score_att"
el.direction = sortDirectionASC
case domain.TribeSortODScoreAttDESC:
el.value = cursor.ODScoreAtt()
el.unique = false
el.column = "tribe.score_att"
el.direction = sortDirectionDESC
case domain.TribeSortODScoreDefASC:
el.value = cursor.ODScoreDef()
el.unique = false
el.column = "tribe.score_def"
el.direction = sortDirectionASC
case domain.TribeSortODScoreDefDESC:
el.value = cursor.ODScoreDef()
el.unique = false
el.column = "tribe.score_def"
el.direction = sortDirectionDESC
case domain.TribeSortODScoreTotalASC:
el.value = cursor.ODScoreTotal()
el.unique = false
el.column = "tribe.score_total"
el.direction = sortDirectionASC
case domain.TribeSortODScoreTotalDESC:
el.value = cursor.ODScoreTotal()
el.unique = false
el.column = "tribe.score_total"
el.direction = sortDirectionDESC
case domain.TribeSortPointsASC:
el.value = cursor.Points()
el.unique = false
el.column = "tribe.points"
el.direction = sortDirectionASC
case domain.TribeSortPointsDESC:
el.value = cursor.Points()
el.unique = false
el.column = "tribe.points"
el.direction = sortDirectionDESC
case domain.TribeSortDominanceASC:
el.value = cursor.Dominance()
el.unique = false
el.column = "tribe.dominance"
el.direction = sortDirectionASC
case domain.TribeSortDominanceDESC:
el.value = cursor.Dominance()
el.unique = false
el.column = "tribe.dominance"
el.direction = sortDirectionDESC
case domain.TribeSortDeletedAtASC:
el.value = cursor.DeletedAt()
el.unique = false
el.column = "COALESCE(tribe.deleted_at, '0001-01-01 00:00:00+00:00')"
el.direction = sortDirectionASC
case domain.TribeSortDeletedAtDESC:
el.value = cursor.DeletedAt()
el.unique = false
el.column = "COALESCE(tribe.deleted_at, '0001-01-01 00:00:00+00:00')"
el.direction = sortDirectionDESC
default:
return q.Err(errInvalidSortValue)
}
return q
})
cursorApplier.data = append(cursorApplier.data, el)
}
return q
return q.Apply(cursorApplier.apply)
}

View File

@ -90,7 +90,7 @@ func (a listTribeChangesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuer
case domain.TribeChangeSortServerKeyDESC:
q = q.Order("tc.server_key DESC")
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}

View File

@ -96,7 +96,7 @@ func (a listTribeSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQu
case domain.TribeSnapshotSortServerKeyDESC:
q = q.Order("ts.server_key DESC")
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}

View File

@ -50,26 +50,51 @@ func (a listVersionsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
}
for _, s := range a.params.Sort() {
cursorZero := a.params.Cursor().IsZero()
cursorCode := a.params.Cursor().Code()
switch s {
case domain.VersionSortCodeASC:
if !cursorZero {
q = q.Where("version.code >= ?", cursorCode)
}
q = q.Order("version.code ASC")
case domain.VersionSortCodeDESC:
if !cursorZero {
q = q.Where("version.code <= ?", cursorCode)
}
q = q.Order("version.code DESC")
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}
return q.Limit(a.params.Limit() + 1)
return q.Limit(a.params.Limit() + 1).Apply(a.applyCursor)
}
func (a listVersionsParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery {
cursor := a.params.Cursor()
if cursor.IsZero() {
return q
}
sort := a.params.Sort()
cursorApplier := cursorPaginationApplier{
data: make([]cursorPaginationApplierDataElement, 0, len(sort)),
}
for _, s := range sort {
var el cursorPaginationApplierDataElement
switch s {
case domain.VersionSortCodeASC:
el.value = cursor.Code()
el.unique = true
el.column = "version.code"
el.direction = sortDirectionASC
case domain.VersionSortCodeDESC:
el.value = cursor.Code()
el.unique = true
el.column = "version.code"
el.direction = sortDirectionDESC
default:
return q.Err(errInvalidSortValue)
}
cursorApplier.data = append(cursorApplier.data, el)
}
return q.Apply(cursorApplier.apply)
}

View File

@ -135,7 +135,7 @@ func (a listVillagesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
case domain.VillageSortServerKeyDESC:
q = q.Order("village.server_key DESC")
default:
return q.Err(errUnsupportedSortValue)
return q.Err(errInvalidSortValue)
}
}

View File

@ -159,6 +159,180 @@ func testPlayerRepository(t *testing.T, newRepos func(t *testing.T) repositories
require.NoError(t, err)
},
},
{
name: "OK: sort=[scoreAtt DESC, serverKey ASC, id ASC]",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{
domain.PlayerSortODScoreAttDESC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortIDASC,
}))
return params
},
assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) {
t.Helper()
players := res.Players()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
return cmp.Or(
cmp.Compare(a.OD().ScoreAtt(), b.OD().ScoreAtt())*-1,
cmp.Compare(a.ServerKey(), b.ServerKey()),
cmp.Compare(a.ID(), b.ID()),
)
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
},
{
name: "OK: sort=[scoreDef DESC, serverKey ASC, id ASC]",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{
domain.PlayerSortODScoreDefDESC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortIDASC,
}))
return params
},
assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) {
t.Helper()
players := res.Players()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
return cmp.Or(
cmp.Compare(a.OD().ScoreDef(), b.OD().ScoreDef())*-1,
cmp.Compare(a.ServerKey(), b.ServerKey()),
cmp.Compare(a.ID(), b.ID()),
)
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
},
{
name: "OK: sort=[scoreTotal DESC, serverKey ASC, id ASC]",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{
domain.PlayerSortODScoreTotalDESC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortIDASC,
}))
return params
},
assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) {
t.Helper()
players := res.Players()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
return cmp.Or(
cmp.Compare(a.OD().ScoreTotal(), b.OD().ScoreTotal())*-1,
cmp.Compare(a.ServerKey(), b.ServerKey()),
cmp.Compare(a.ID(), b.ID()),
)
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
},
{
name: "OK: sort=[points DESC, serverKey ASC, id ASC]",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{
domain.PlayerSortPointsDESC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortIDASC,
}))
return params
},
assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) {
t.Helper()
players := res.Players()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
return cmp.Or(
cmp.Compare(a.Points(), b.Points())*-1,
cmp.Compare(a.ServerKey(), b.ServerKey()),
cmp.Compare(a.ID(), b.ID()),
)
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
},
{
name: "OK: sort=[deletedAt ASC, serverKey ASC, id ASC]",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{
domain.PlayerSortDeletedAtASC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortIDASC,
}))
return params
},
assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) {
t.Helper()
players := res.Players()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
return cmp.Or(
a.DeletedAt().Compare(b.DeletedAt()),
cmp.Compare(a.ServerKey(), b.ServerKey()),
cmp.Compare(a.ID(), b.ID()),
)
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
},
{
name: "OK: sort=[deletedAt DESC, serverKey ASC, id ASC]",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{
domain.PlayerSortDeletedAtDESC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortIDASC,
}))
return params
},
assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) {
t.Helper()
players := res.Players()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
return cmp.Or(
a.DeletedAt().Compare(b.DeletedAt())*-1,
cmp.Compare(a.ServerKey(), b.ServerKey()),
cmp.Compare(a.ID(), b.ID()),
)
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
},
{
name: "OK: ids serverKeys",
params: func(t *testing.T) domain.ListPlayersParams {

View File

@ -6,6 +6,7 @@ import (
"math"
"net/url"
"slices"
"strings"
"time"
)
@ -371,8 +372,48 @@ const (
PlayerSortIDDESC
PlayerSortServerKeyASC
PlayerSortServerKeyDESC
PlayerSortODScoreAttASC
PlayerSortODScoreAttDESC
PlayerSortODScoreDefASC
PlayerSortODScoreDefDESC
PlayerSortODScoreTotalASC
PlayerSortODScoreTotalDESC
PlayerSortPointsASC
PlayerSortPointsDESC
PlayerSortDeletedAtASC
PlayerSortDeletedAtDESC
)
//nolint:gocyclo
func newPlayerSortFromString(s string) (PlayerSort, error) {
switch strings.ToLower(s) {
case "odscoreatt:asc":
return PlayerSortODScoreAttASC, nil
case "odscoreatt:desc":
return PlayerSortODScoreAttDESC, nil
case "odscoredef:asc":
return PlayerSortODScoreDefASC, nil
case "odscoredef:desc":
return PlayerSortODScoreDefDESC, nil
case "odscoretotal:asc":
return PlayerSortODScoreTotalASC, nil
case "odscoretotal:desc":
return PlayerSortODScoreTotalDESC, nil
case "points:asc":
return PlayerSortPointsASC, nil
case "points:desc":
return PlayerSortPointsDESC, nil
case "deletedat:asc":
return PlayerSortDeletedAtASC, nil
case "deletedat:desc":
return PlayerSortDeletedAtDESC, nil
default:
return 0, UnsupportedSortStringError{
Sort: s,
}
}
}
type PlayerCursor struct {
id int
serverKey string
@ -628,7 +669,7 @@ func (params *ListPlayersParams) Sort() []PlayerSort {
const (
playerSortMinLength = 1
playerSortMaxLength = 2
playerSortMaxLength = 3
)
func (params *ListPlayersParams) SetSort(sort []PlayerSort) error {
@ -645,6 +686,31 @@ func (params *ListPlayersParams) SetSort(sort []PlayerSort) error {
return nil
}
func (params *ListPlayersParams) PrependSortString(sort []string) error {
if err := validateSliceLen(sort, playerSortMinLength, max(playerSortMaxLength-len(params.sort), 0)); err != nil {
return ValidationError{
Model: listPlayersParamsModelName,
Field: "sort",
Err: err,
}
}
for i := len(sort) - 1; i >= 0; i-- {
converted, err := newPlayerSortFromString(sort[i])
if err != nil {
return SliceElementValidationError{
Model: listPlayersParamsModelName,
Field: "sort",
Index: i,
Err: err,
}
}
params.sort = append([]PlayerSort{converted}, params.sort...)
}
return nil
}
func (params *ListPlayersParams) Cursor() PlayerCursor {
return params.cursor
}

View File

@ -490,6 +490,7 @@ func TestListPlayersParams_SetSort(t *testing.T) {
name: "OK",
args: args{
sort: []domain.PlayerSort{
domain.PlayerSortPointsASC,
domain.PlayerSortIDASC,
domain.PlayerSortServerKeyASC,
},
@ -505,18 +506,19 @@ func TestListPlayersParams_SetSort(t *testing.T) {
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 2,
Max: 3,
Current: 0,
},
},
},
{
name: "ERR: len(sort) > 2",
name: "ERR: len(sort) > 3",
args: args{
sort: []domain.PlayerSort{
domain.PlayerSortDeletedAtDESC,
domain.PlayerSortPointsASC,
domain.PlayerSortIDASC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortServerKeyDESC,
},
},
expectedErr: domain.ValidationError{
@ -524,8 +526,8 @@ func TestListPlayersParams_SetSort(t *testing.T) {
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 2,
Current: 3,
Max: 3,
Current: 4,
},
},
},
@ -546,6 +548,181 @@ func TestListPlayersParams_SetSort(t *testing.T) {
}
}
func TestListPlayersParams_PrependSortString(t *testing.T) {
t.Parallel()
defaultNewParams := func(t *testing.T) domain.ListPlayersParams {
t.Helper()
return domain.ListPlayersParams{}
}
type args struct {
sort []string
}
tests := []struct {
name string
newParams func(t *testing.T) domain.ListPlayersParams
args args
expectedSort []domain.PlayerSort
expectedErr error
}{
{
name: "OK: [odScoreAtt:ASC, odScoreDef:ASC, odScoreTotal:ASC]",
args: args{
sort: []string{
"odScoreAtt:ASC",
"odScoreDef:ASC",
"odScoreTotal:ASC",
},
},
expectedSort: []domain.PlayerSort{
domain.PlayerSortODScoreAttASC,
domain.PlayerSortODScoreDefASC,
domain.PlayerSortODScoreTotalASC,
},
},
{
name: "OK: [odScoreAtt:DESC, odScoreDef:DESC, odScoreTotal:DESC]",
args: args{
sort: []string{
"odScoreAtt:DESC",
"odScoreDef:DESC",
"odScoreTotal:DESC",
},
},
expectedSort: []domain.PlayerSort{
domain.PlayerSortODScoreAttDESC,
domain.PlayerSortODScoreDefDESC,
domain.PlayerSortODScoreTotalDESC,
},
},
{
name: "OK: [points:ASC, deletedAt:ASC]",
args: args{
sort: []string{
"points:ASC",
"deletedAt:ASC",
},
},
expectedSort: []domain.PlayerSort{
domain.PlayerSortPointsASC,
domain.PlayerSortDeletedAtASC,
},
},
{
name: "OK: [points:DESC, deletedAt:DESC]",
args: args{
sort: []string{
"points:DESC",
"deletedAt:DESC",
},
},
expectedSort: []domain.PlayerSort{
domain.PlayerSortPointsDESC,
domain.PlayerSortDeletedAtDESC,
},
},
{
name: "ERR: len(sort) < 1",
args: args{
sort: nil,
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 3,
Current: 0,
},
},
},
{
name: "ERR: len(sort) > 3",
args: args{
sort: []string{
"odScoreAtt:ASC",
"odScoreDef:ASC",
"odScoreTotal:ASC",
"points:ASC",
},
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 3,
Current: 4,
},
},
},
{
name: "ERR: custom params + len(sort) > 2",
newParams: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{domain.PlayerSortIDASC}))
return params
},
args: args{
sort: []string{
"odScoreAtt:ASC",
"odScoreDef:ASC",
"odScoreTotal:ASC",
},
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 2,
Current: 3,
},
},
},
{
name: "ERR: unsupported sort string",
newParams: defaultNewParams,
args: args{
sort: []string{
"odScoreAtt:ASC",
"odScoreDef:ASC",
"odScoreTotal:",
},
},
expectedErr: domain.SliceElementValidationError{
Model: "ListPlayersParams",
Field: "sort",
Index: 2,
Err: domain.UnsupportedSortStringError{
Sort: "odScoreTotal:",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
newParams := defaultNewParams
if tt.newParams != nil {
newParams = tt.newParams
}
params := newParams(t)
require.ErrorIs(t, params.PrependSortString(tt.args.sort), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.expectedSort, params.Sort())
})
}
}
func TestListPlayersParams_SetEncodedCursor(t *testing.T) {
t.Parallel()

View File

@ -8,6 +8,7 @@ import (
"gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel"
)
//nolint:gocyclo
func (h *apiHTTPHandler) ListPlayers(
w http.ResponseWriter,
r *http.Request,
@ -22,6 +23,13 @@ func (h *apiHTTPHandler) ListPlayers(
return
}
if params.Sort != nil {
if err := domainParams.PrependSortString(*params.Sort); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayersErrorPath).render(w, r, err)
return
}
}
if err := domainParams.SetServerKeys([]string{serverKey}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayersErrorPath).render(w, r, err)
return

View File

@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest"
@ -111,6 +112,70 @@ func TestListPlayers(t *testing.T) {
assert.Len(t, body.Data, limit)
},
},
{
name: "OK: sort=[deletedAt:DESC,points:ASC]",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Add("sort", "deletedAt:DESC")
q.Add("sort", "points:ASC")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// body
body := decodeJSON[apimodel.ListPlayersResponse](t, resp.Body)
assert.Zero(t, body.Cursor.Next)
assert.NotZero(t, body.Cursor.Self)
assert.NotZero(t, body.Data)
assert.True(t, slices.IsSortedFunc(body.Data, func(a, b apimodel.Player) int {
var aDeletedAt time.Time
if a.DeletedAt != nil {
aDeletedAt = *a.DeletedAt
}
var bDeletedAt time.Time
if b.DeletedAt != nil {
bDeletedAt = *b.DeletedAt
}
return cmp.Or(
aDeletedAt.Compare(bDeletedAt)*-1,
cmp.Compare(a.Points, b.Points),
cmp.Compare(a.Id, b.Id),
)
}))
},
},
{
name: "OK: sort=[odScoreAtt:DESC]",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("sort", "odScoreAtt:DESC")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// body
body := decodeJSON[apimodel.ListPlayersResponse](t, resp.Body)
assert.Zero(t, body.Cursor.Next)
assert.NotZero(t, body.Cursor.Self)
assert.NotZero(t, body.Data)
assert.True(t, slices.IsSortedFunc(body.Data, func(a, b apimodel.Player) int {
return cmp.Or(
cmp.Compare(a.OpponentsDefeated.ScoreAtt, b.OpponentsDefeated.ScoreAtt)*-1,
cmp.Compare(a.Id, b.Id),
)
}))
},
},
{
name: "OK: deleted=false",
reqModifier: func(t *testing.T, req *http.Request) {
@ -127,8 +192,8 @@ func TestListPlayers(t *testing.T) {
// body
body := decodeJSON[apimodel.ListPlayersResponse](t, resp.Body)
assert.NotZero(t, body.Data)
for _, tr := range body.Data {
assert.Nil(t, tr.DeletedAt)
for _, p := range body.Data {
assert.Nil(t, p.DeletedAt)
}
},
},
@ -148,8 +213,8 @@ func TestListPlayers(t *testing.T) {
// body
body := decodeJSON[apimodel.ListPlayersResponse](t, resp.Body)
assert.NotZero(t, body.Data)
for _, tr := range body.Data {
assert.NotNil(t, tr.DeletedAt)
for _, p := range body.Data {
assert.NotNil(t, p.DeletedAt)
}
},
},
@ -354,6 +419,77 @@ func TestListPlayers(t *testing.T) {
}, body)
},
},
{
name: "ERR: len(sort) > 2",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Add("sort", "odScoreAtt:DESC")
q.Add("sort", "odScoreAtt:ASC")
q.Add("sort", "points:ASC")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
domainErr := domain.LenOutOfRangeError{
Min: 1,
Max: 2,
Current: len(req.URL.Query()["sort"]),
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: domainErr.Code(),
Message: domainErr.Error(),
Params: map[string]any{
"current": float64(domainErr.Current),
"max": float64(domainErr.Max),
"min": float64(domainErr.Min),
},
Path: []string{"$query", "sort"},
},
},
}, body)
},
},
{
name: "ERR: invalid sort",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Add("sort", "odScoreAtt:ASC")
q.Add("sort", "test:DESC")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
domainErr := domain.UnsupportedSortStringError{
Sort: req.URL.Query()["sort"][1],
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: domainErr.Code(),
Message: domainErr.Error(),
Params: map[string]any{
"sort": domainErr.Sort,
},
Path: []string{"$query", "sort", "1"},
},
},
}, body)
},
},
{
name: "ERR: deleted is not a valid boolean",
reqModifier: func(t *testing.T, req *http.Request) {