diff --git a/internal/adapter/repository_bun_server.go b/internal/adapter/repository_bun_server.go index 6c26973..61ff7ea 100644 --- a/internal/adapter/repository_bun_server.go +++ b/internal/adapter/repository_bun_server.go @@ -264,20 +264,7 @@ func (a listServersParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQue } case sortLen > 1: q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { - switch sort[0] { - case domain.ServerSortKeyASC: - q = q.Where("server.key > ?", cursorKey) - case domain.ServerSortKeyDESC: - q = q.Where("server.key < ?", cursorKey) - case domain.ServerSortOpenASC: - q = q.Where("server.open > ?", cursorOpen) - case domain.ServerSortOpenDESC: - q = q.Where("server.open < ?", cursorOpen) - default: - return q.Err(errUnsupportedSortValue) - } - - for i := 1; i < sortLen; i++ { + for i := 0; i < sortLen; i++ { q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { current := sort[i] @@ -296,15 +283,23 @@ func (a listServersParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQue } } + greaterSymbol := bun.Safe(">") + lessSymbol := bun.Safe("<") + + if i == sortLen-1 { + greaterSymbol = ">=" + lessSymbol = "<=" + } + switch current { case domain.ServerSortKeyASC: - q = q.Where("server.key >= ?", cursorKey) + q = q.Where("server.key ? ?", greaterSymbol, cursorKey) case domain.ServerSortKeyDESC: - q = q.Where("server.key <= ?", cursorKey) + q = q.Where("server.key ? ?", lessSymbol, cursorKey) case domain.ServerSortOpenASC: - q = q.Where("server.open >= ?", cursorOpen) + q = q.Where("server.open ? ?", greaterSymbol, cursorOpen) case domain.ServerSortOpenDESC: - q = q.Where("server.open <= ?", cursorOpen) + q = q.Where("server.open ? ?", lessSymbol, cursorOpen) default: return q.Err(errUnsupportedSortValue) } diff --git a/internal/adapter/repository_bun_tribe.go b/internal/adapter/repository_bun_tribe.go index 5cb9139..a40a44c 100644 --- a/internal/adapter/repository_bun_tribe.go +++ b/internal/adapter/repository_bun_tribe.go @@ -183,6 +183,30 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Order("tribe.server_key ASC") case domain.TribeSortServerKeyDESC: q = q.Order("tribe.server_key DESC") + case domain.TribeSortODScoreAttASC: + q = q.Order("tribe.score_att ASC") + case domain.TribeSortODScoreAttDESC: + q = q.Order("tribe.score_att DESC") + case domain.TribeSortODScoreDefASC: + q = q.Order("tribe.score_def ASC") + case domain.TribeSortODScoreDefDESC: + q = q.Order("tribe.score_def DESC") + case domain.TribeSortODScoreTotalASC: + q = q.Order("tribe.score_total ASC") + case domain.TribeSortODScoreTotalDESC: + q = q.Order("tribe.score_total DESC") + case domain.TribeSortPointsASC: + q = q.Order("tribe.points ASC") + case domain.TribeSortPointsDESC: + q = q.Order("tribe.points DESC") + case domain.TribeSortDominanceASC: + q = q.Order("tribe.dominance ASC") + case domain.TribeSortDominanceDESC: + q = q.Order("tribe.dominance DESC") + case domain.TribeSortDeletedAtASC: + q = q.OrderExpr("COALESCE(tribe.deleted_at, ?) ASC", time.Time{}) + case domain.TribeSortDeletedAtDESC: + q = q.OrderExpr("COALESCE(tribe.deleted_at, ?) DESC", time.Time{}) default: return q.Err(errUnsupportedSortValue) } @@ -198,8 +222,15 @@ func (a listTribesParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuer } q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { - cursorID := a.params.Cursor().ID() - cursorServerKey := a.params.Cursor().ServerKey() + 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() sortLen := len(sort) @@ -213,27 +244,27 @@ func (a listTribesParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuer q = q.Where("tribe.id >= ?", cursorID) case domain.TribeSortIDDESC: q = q.Where("tribe.id <= ?", cursorID) - case domain.TribeSortServerKeyASC, domain.TribeSortServerKeyDESC: + 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 { - switch sort[0] { - case domain.TribeSortIDASC: - q = q.Where("tribe.id > ?", cursorID) - case domain.TribeSortIDDESC: - q = q.Where("tribe.id < ?", cursorID) - case domain.TribeSortServerKeyASC: - q = q.Where("tribe.server_key > ?", cursorServerKey) - case domain.TribeSortServerKeyDESC: - q = q.Where("tribe.server_key < ?", cursorServerKey) - default: - return q.Err(errUnsupportedSortValue) - } - - for i := 1; i < sortLen; i++ { + for i := 0; i < sortLen; i++ { q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { current := sort[i] @@ -247,20 +278,70 @@ func (a listTribesParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuer 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 >= ?", cursorID) + q = q.Where("tribe.id ? ?", greaterSymbol, cursorID) case domain.TribeSortIDDESC: - q = q.Where("tribe.id <= ?", cursorID) + q = q.Where("tribe.id ? ?", lessSymbol, cursorID) case domain.TribeSortServerKeyASC: - q = q.Where("tribe.server_key >= ?", cursorServerKey) + q = q.Where("tribe.server_key ? ?", greaterSymbol, cursorServerKey) case domain.TribeSortServerKeyDESC: - q = q.Where("tribe.server_key <= ?", cursorServerKey) + 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) } diff --git a/internal/adapter/repository_tribe_test.go b/internal/adapter/repository_tribe_test.go index 96c1d8b..2e2a2c9 100644 --- a/internal/adapter/repository_tribe_test.go +++ b/internal/adapter/repository_tribe_test.go @@ -224,6 +224,209 @@ func testTribeRepository(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.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortODScoreAttDESC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) 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.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortODScoreDefDESC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) 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.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortODScoreTotalDESC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) 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.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortPointsDESC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) 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=[dominance ASC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortDominanceASC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) int { + return cmp.Or( + cmp.Compare(a.Dominance(), b.Dominance()), + 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.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortDeletedAtASC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) 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.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortDeletedAtDESC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) 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.ListTribesParams { @@ -430,6 +633,98 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) require.NoError(t, err) }, }, + { + name: "OK: cursor sort=[points DESC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortPointsDESC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + + res, err := repos.tribe.List(ctx, params) + require.NoError(t, err) + require.Greater(t, len(res.Tribes()), 2) + + require.NoError(t, params.SetCursor(domaintest.NewTribeCursor(t, func(cfg *domaintest.TribeCursorConfig) { + cfg.ID = res.Tribes()[1].ID() + cfg.ServerKey = res.Tribes()[1].ServerKey() + cfg.Points = res.Tribes()[1].Points() + }))) + + return params + }, + assertResult: func(t *testing.T, params domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) int { + return cmp.Or( + cmp.Compare(a.Points(), b.Points())*-1, + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + assert.GreaterOrEqual(t, tribes[0].ID(), params.Cursor().ID()) + assert.GreaterOrEqual(t, tribes[0].ServerKey(), params.Cursor().ServerKey()) + for _, tr := range res.Tribes() { + assert.LessOrEqual(t, tr.Points(), params.Cursor().Points()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: cursor sort=[deletedAt ASC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortDeletedAtASC, + domain.TribeSortServerKeyASC, + domain.TribeSortIDASC, + })) + + res, err := repos.tribe.List(ctx, params) + require.NoError(t, err) + require.Greater(t, len(res.Tribes()), 2) + + require.NoError(t, params.SetCursor(domaintest.NewTribeCursor(t, func(cfg *domaintest.TribeCursorConfig) { + cfg.ID = res.Tribes()[1].ID() + cfg.ServerKey = res.Tribes()[1].ServerKey() + cfg.DeletedAt = res.Tribes()[1].DeletedAt() + }))) + + return params + }, + assertResult: func(t *testing.T, params domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) int { + return cmp.Or( + a.DeletedAt().Compare(b.DeletedAt()), + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + assert.GreaterOrEqual(t, tribes[0].ID(), params.Cursor().ID()) + assert.GreaterOrEqual(t, tribes[0].ServerKey(), params.Cursor().ServerKey()) + for _, tr := range res.Tribes() { + assert.GreaterOrEqual(t, tr.DeletedAt(), params.Cursor().DeletedAt()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, { name: "OK: limit=2", params: func(t *testing.T) domain.ListTribesParams { diff --git a/internal/domain/domaintest/tribe.go b/internal/domain/domaintest/tribe.go index 9bbaf7e..8ca284e 100644 --- a/internal/domain/domaintest/tribe.go +++ b/internal/domain/domaintest/tribe.go @@ -1,6 +1,7 @@ package domaintest import ( + "math" "time" "gitea.dwysokinski.me/twhelp/corev3/internal/domain" @@ -13,23 +14,44 @@ func RandTribeTag() string { } type TribeCursorConfig struct { - ID int - ServerKey string + ID int + ServerKey string + ODScoreAtt int + ODScoreDef int + ODScoreTotal int + Points int + Dominance float64 + DeletedAt time.Time } func NewTribeCursor(tb TestingTB, opts ...func(cfg *TribeCursorConfig)) domain.TribeCursor { tb.Helper() cfg := &TribeCursorConfig{ - ID: RandID(), - ServerKey: RandServerKey(), + ID: RandID(), + ServerKey: RandServerKey(), + ODScoreAtt: gofakeit.IntRange(0, math.MaxInt), + ODScoreDef: gofakeit.IntRange(0, math.MaxInt), + ODScoreTotal: gofakeit.IntRange(0, math.MaxInt), + Points: gofakeit.IntRange(0, math.MaxInt), + Dominance: gofakeit.Float64Range(0.1, 99.9), + DeletedAt: time.Time{}, } for _, opt := range opts { opt(cfg) } - tc, err := domain.NewTribeCursor(cfg.ID, cfg.ServerKey) + tc, err := domain.NewTribeCursor( + cfg.ID, + cfg.ServerKey, + cfg.ODScoreAtt, + cfg.ODScoreDef, + cfg.ODScoreTotal, + cfg.Points, + cfg.Dominance, + cfg.DeletedAt, + ) require.NoError(tb, err) return tc diff --git a/internal/domain/server.go b/internal/domain/server.go index c633802..0c9e787 100644 --- a/internal/domain/server.go +++ b/internal/domain/server.go @@ -5,7 +5,6 @@ import ( "math" "net/url" "slices" - "strconv" "time" ) @@ -494,12 +493,17 @@ func decodeServerCursor(encoded string) (ServerCursor, error) { return ServerCursor{}, err } - open, err := strconv.ParseBool(m["open"]) + key, err := m.string("key") if err != nil { return ServerCursor{}, ErrInvalidCursor } - vc, err := NewServerCursor(m["key"], open) + open, err := m.bool("open") + if err != nil { + return ServerCursor{}, ErrInvalidCursor + } + + vc, err := NewServerCursor(key, open) if err != nil { return ServerCursor{}, ErrInvalidCursor } @@ -524,9 +528,9 @@ func (sc ServerCursor) Encode() string { return "" } - return encodeCursor([][2]string{ + return encodeCursor([]keyValuePair{ {"key", sc.key}, - {"open", strconv.FormatBool(sc.open)}, + {"open", sc.open}, }) } diff --git a/internal/domain/tribe.go b/internal/domain/tribe.go index 8309794..15e538e 100644 --- a/internal/domain/tribe.go +++ b/internal/domain/tribe.go @@ -6,7 +6,6 @@ import ( "math" "net/url" "slices" - "strconv" "time" ) @@ -367,16 +366,43 @@ const ( TribeSortIDDESC TribeSortServerKeyASC TribeSortServerKeyDESC + TribeSortODScoreAttASC + TribeSortODScoreAttDESC + TribeSortODScoreDefASC + TribeSortODScoreDefDESC + TribeSortODScoreTotalASC + TribeSortODScoreTotalDESC + TribeSortPointsASC + TribeSortPointsDESC + TribeSortDominanceASC + TribeSortDominanceDESC + TribeSortDeletedAtASC + TribeSortDeletedAtDESC ) type TribeCursor struct { - id int - serverKey string + id int + serverKey string + odScoreAtt int + odScoreDef int + odScoreTotal int + points int + dominance float64 + deletedAt time.Time } const tribeCursorModelName = "TribeCursor" -func NewTribeCursor(id int, serverKey string) (TribeCursor, error) { +func NewTribeCursor( + id int, + serverKey string, + odScoreAtt int, + odScoreDef int, + odScoreTotal int, + points int, + dominance float64, + deletedAt time.Time, +) (TribeCursor, error) { if err := validateIntInRange(id, 1, math.MaxInt); err != nil { return TribeCursor{}, ValidationError{ Model: tribeCursorModelName, @@ -393,24 +419,107 @@ func NewTribeCursor(id int, serverKey string) (TribeCursor, error) { } } + if err := validateIntInRange(odScoreAtt, 0, math.MaxInt); err != nil { + return TribeCursor{}, ValidationError{ + Model: tribeCursorModelName, + Field: "odScoreAtt", + Err: err, + } + } + + if err := validateIntInRange(odScoreDef, 0, math.MaxInt); err != nil { + return TribeCursor{}, ValidationError{ + Model: tribeCursorModelName, + Field: "odScoreDef", + Err: err, + } + } + + if err := validateIntInRange(odScoreTotal, 0, math.MaxInt); err != nil { + return TribeCursor{}, ValidationError{ + Model: tribeCursorModelName, + Field: "odScoreTotal", + Err: err, + } + } + + if err := validateIntInRange(points, 0, math.MaxInt); err != nil { + return TribeCursor{}, ValidationError{ + Model: tribeCursorModelName, + Field: "points", + Err: err, + } + } + return TribeCursor{ - id: id, - serverKey: serverKey, + id: id, + serverKey: serverKey, + odScoreAtt: odScoreAtt, + odScoreDef: odScoreDef, + odScoreTotal: odScoreTotal, + points: points, + dominance: dominance, + deletedAt: deletedAt, }, nil } +//nolint:gocyclo func decodeTribeCursor(encoded string) (TribeCursor, error) { m, err := decodeCursor(encoded) if err != nil { return TribeCursor{}, err } - id, err := strconv.Atoi(m["id"]) + id, err := m.int("id") if err != nil { return TribeCursor{}, ErrInvalidCursor } - tc, err := NewTribeCursor(id, m["serverKey"]) + serverKey, err := m.string("serverKey") + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + odScoreAtt, err := m.int("odScoreAtt") + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + odScoreDef, err := m.int("odScoreDef") + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + odScoreTotal, err := m.int("odScoreTotal") + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + points, err := m.int("points") + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + dominance, err := m.float64("dominance") + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + deletedAt, err := m.time("deletedAt") + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + tc, err := NewTribeCursor( + id, + serverKey, + odScoreAtt, + odScoreDef, + odScoreTotal, + points, + dominance, + deletedAt, + ) if err != nil { return TribeCursor{}, ErrInvalidCursor } @@ -426,6 +535,30 @@ func (tc TribeCursor) ServerKey() string { return tc.serverKey } +func (tc TribeCursor) ODScoreAtt() int { + return tc.odScoreAtt +} + +func (tc TribeCursor) ODScoreDef() int { + return tc.odScoreDef +} + +func (tc TribeCursor) ODScoreTotal() int { + return tc.odScoreTotal +} + +func (tc TribeCursor) Points() int { + return tc.points +} + +func (tc TribeCursor) Dominance() float64 { + return tc.dominance +} + +func (tc TribeCursor) DeletedAt() time.Time { + return tc.deletedAt +} + func (tc TribeCursor) IsZero() bool { return tc == TribeCursor{} } @@ -435,9 +568,15 @@ func (tc TribeCursor) Encode() string { return "" } - return encodeCursor([][2]string{ - {"id", strconv.Itoa(tc.id)}, + return encodeCursor([]keyValuePair{ + {"id", tc.id}, {"serverKey", tc.serverKey}, + {"odScoreAtt", tc.odScoreAtt}, + {"odScoreDef", tc.odScoreDef}, + {"odScoreTotal", tc.odScoreTotal}, + {"points", tc.points}, + {"dominance", tc.dominance}, + {"deletedAt", tc.deletedAt}, }) } @@ -510,7 +649,7 @@ func (params *ListTribesParams) Sort() []TribeSort { const ( tribeSortMinLength = 1 - tribeSortMaxLength = 2 + tribeSortMaxLength = 3 ) func (params *ListTribesParams) SetSort(sort []TribeSort) error { @@ -584,7 +723,17 @@ func NewListTribesResult(tribes Tribes, next Tribe) (ListTribesResult, error) { } if len(tribes) > 0 { - res.self, err = NewTribeCursor(tribes[0].ID(), tribes[0].ServerKey()) + od := tribes[0].OD() + res.self, err = NewTribeCursor( + tribes[0].ID(), + tribes[0].ServerKey(), + od.ScoreAtt(), + od.ScoreDef(), + od.ScoreTotal(), + tribes[0].Points(), + tribes[0].Dominance(), + tribes[0].DeletedAt(), + ) if err != nil { return ListTribesResult{}, ValidationError{ Model: listTribesResultModelName, @@ -595,7 +744,17 @@ func NewListTribesResult(tribes Tribes, next Tribe) (ListTribesResult, error) { } if !next.IsZero() { - res.next, err = NewTribeCursor(next.ID(), next.ServerKey()) + od := next.OD() + res.next, err = NewTribeCursor( + next.ID(), + next.ServerKey(), + od.ScoreAtt(), + od.ScoreDef(), + od.ScoreTotal(), + next.Points(), + next.Dominance(), + next.DeletedAt(), + ) if err != nil { return ListTribesResult{}, ValidationError{ Model: listTribesResultModelName, diff --git a/internal/domain/tribe_test.go b/internal/domain/tribe_test.go index ea74a2e..6e597f2 100644 --- a/internal/domain/tribe_test.go +++ b/internal/domain/tribe_test.go @@ -186,8 +186,14 @@ func TestNewTribeCursor(t *testing.T) { validTribeCursor := domaintest.NewTribeCursor(t) type args struct { - id int - serverKey string + id int + serverKey string + odScoreAtt int + odScoreDef int + odScoreTotal int + points int + dominance float64 + deletedAt time.Time } type test struct { @@ -200,16 +206,28 @@ func TestNewTribeCursor(t *testing.T) { { name: "OK", args: args{ - id: validTribeCursor.ID(), - serverKey: validTribeCursor.ServerKey(), + id: validTribeCursor.ID(), + serverKey: validTribeCursor.ServerKey(), + odScoreAtt: validTribeCursor.ODScoreAtt(), + odScoreDef: validTribeCursor.ODScoreDef(), + odScoreTotal: validTribeCursor.ODScoreTotal(), + points: validTribeCursor.Points(), + dominance: validTribeCursor.Dominance(), + deletedAt: validTribeCursor.DeletedAt(), }, expectedErr: nil, }, { name: "ERR: id < 1", args: args{ - id: 0, - serverKey: validTribeCursor.ServerKey(), + id: 0, + serverKey: validTribeCursor.ServerKey(), + odScoreAtt: validTribeCursor.ODScoreAtt(), + odScoreDef: validTribeCursor.ODScoreDef(), + odScoreTotal: validTribeCursor.ODScoreTotal(), + points: validTribeCursor.Points(), + dominance: validTribeCursor.Dominance(), + deletedAt: validTribeCursor.DeletedAt(), }, expectedErr: domain.ValidationError{ Model: "TribeCursor", @@ -220,14 +238,104 @@ func TestNewTribeCursor(t *testing.T) { }, }, }, + { + name: "ERR: odScoreAtt < 0", + args: args{ + id: validTribeCursor.ID(), + serverKey: validTribeCursor.ServerKey(), + odScoreAtt: -1, + odScoreDef: validTribeCursor.ODScoreDef(), + odScoreTotal: validTribeCursor.ODScoreTotal(), + points: validTribeCursor.Points(), + dominance: validTribeCursor.Dominance(), + deletedAt: validTribeCursor.DeletedAt(), + }, + expectedErr: domain.ValidationError{ + Model: "TribeCursor", + Field: "odScoreAtt", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + { + name: "ERR: odScoreDef < 0", + args: args{ + id: validTribeCursor.ID(), + serverKey: validTribeCursor.ServerKey(), + odScoreAtt: validTribeCursor.ODScoreAtt(), + odScoreDef: -1, + odScoreTotal: validTribeCursor.ODScoreTotal(), + points: validTribeCursor.Points(), + dominance: validTribeCursor.Dominance(), + deletedAt: validTribeCursor.DeletedAt(), + }, + expectedErr: domain.ValidationError{ + Model: "TribeCursor", + Field: "odScoreDef", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + { + name: "ERR: odScoreTotal < 0", + args: args{ + id: validTribeCursor.ID(), + serverKey: validTribeCursor.ServerKey(), + odScoreAtt: validTribeCursor.ODScoreAtt(), + odScoreDef: validTribeCursor.ODScoreDef(), + odScoreTotal: -1, + points: validTribeCursor.Points(), + dominance: validTribeCursor.Dominance(), + deletedAt: validTribeCursor.DeletedAt(), + }, + expectedErr: domain.ValidationError{ + Model: "TribeCursor", + Field: "odScoreTotal", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + { + name: "ERR: points < 0", + args: args{ + id: validTribeCursor.ID(), + serverKey: validTribeCursor.ServerKey(), + odScoreAtt: validTribeCursor.ODScoreAtt(), + odScoreDef: validTribeCursor.ODScoreDef(), + odScoreTotal: validTribeCursor.ODScoreTotal(), + points: -1, + dominance: validTribeCursor.Dominance(), + deletedAt: validTribeCursor.DeletedAt(), + }, + expectedErr: domain.ValidationError{ + Model: "TribeCursor", + Field: "points", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, } for _, serverKeyTest := range newServerKeyValidationTests() { tests = append(tests, test{ name: serverKeyTest.name, args: args{ - id: validTribeCursor.ID(), - serverKey: serverKeyTest.key, + id: validTribeCursor.ID(), + serverKey: serverKeyTest.key, + odScoreAtt: validTribeCursor.ODScoreAtt(), + odScoreDef: validTribeCursor.ODScoreDef(), + odScoreTotal: validTribeCursor.ODScoreTotal(), + points: validTribeCursor.Points(), + dominance: validTribeCursor.Dominance(), + deletedAt: validTribeCursor.DeletedAt(), }, expectedErr: domain.ValidationError{ Model: "TribeCursor", @@ -243,7 +351,16 @@ func TestNewTribeCursor(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tc, err := domain.NewTribeCursor(tt.args.id, tt.args.serverKey) + tc, err := domain.NewTribeCursor( + tt.args.id, + tt.args.serverKey, + tt.args.odScoreAtt, + tt.args.odScoreDef, + tt.args.odScoreTotal, + tt.args.points, + tt.args.dominance, + tt.args.deletedAt, + ) require.ErrorIs(t, err, tt.expectedErr) if tt.expectedErr != nil { return @@ -333,6 +450,7 @@ func TestListTribesParams_SetSort(t *testing.T) { name: "OK", args: args{ sort: []domain.TribeSort{ + domain.TribeSortPointsASC, domain.TribeSortIDASC, domain.TribeSortServerKeyASC, }, @@ -348,18 +466,19 @@ func TestListTribesParams_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.TribeSort{ + domain.TribeSortDominanceASC, + domain.TribeSortPointsASC, domain.TribeSortIDASC, domain.TribeSortServerKeyASC, - domain.TribeSortServerKeyDESC, }, }, expectedErr: domain.ValidationError{ @@ -367,8 +486,8 @@ func TestListTribesParams_SetSort(t *testing.T) { Field: "sort", Err: domain.LenOutOfRangeError{ Min: 1, - Max: 2, - Current: 3, + Max: 3, + Current: 4, }, }, }, diff --git a/internal/domain/utils.go b/internal/domain/utils.go index 7e59d31..b2142f2 100644 --- a/internal/domain/utils.go +++ b/internal/domain/utils.go @@ -3,8 +3,11 @@ package domain import ( "bytes" "encoding/base64" + "fmt" "net/url" + "strconv" "strings" + "time" ) func parseURL(rawURL string) (*url.URL, error) { @@ -23,38 +26,76 @@ const ( cursorKeyValueSeparator = "=" ) -func encodeCursor(s [][2]string) string { - if len(s) == 0 { +type keyValuePair struct { + key string + value any +} + +func (kvp keyValuePair) valueString() string { + switch v := kvp.value.(type) { + case string: + return v + case int: + return strconv.Itoa(v) + case bool: + return strconv.FormatBool(v) + case float64: + return strconv.FormatFloat(v, 'g', -1, 64) + case time.Time: + return v.Format(time.RFC3339) + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func encodeCursor(kvps []keyValuePair) string { + if len(kvps) == 0 { return "" } - n := len(cursorSeparator) * (len(s) - 1) - for _, el := range s { - n += len(el[0]) + len(el[1]) + len(cursorKeyValueSeparator) + values := make([]string, len(kvps)) + n := len(cursorSeparator) * (len(kvps) - 1) + + for i, kvp := range kvps { + value := kvp.valueString() + n += len(kvp.key) + len(value) + len(cursorKeyValueSeparator) + values[i] = value } var b bytes.Buffer b.Grow(n) - for _, el := range s { + for i, kvp := range kvps { if b.Len() > 0 { _, _ = b.WriteString(cursorSeparator) } - _, _ = b.WriteString(el[0]) + _, _ = b.WriteString(kvp.key) _, _ = b.WriteString(cursorKeyValueSeparator) - _, _ = b.WriteString(el[1]) + _, _ = b.WriteString(values[i]) } return base64.StdEncoding.EncodeToString(b.Bytes()) } +type cursorKeyNotFoundError struct { + key string +} + +func (e cursorKeyNotFoundError) Error() string { + return "key " + e.key + " not found" +} + +type decodedCursor map[string]string + const ( encodedCursorMinLength = 1 encodedCursorMaxLength = 1000 ) -func decodeCursor(s string) (map[string]string, error) { +func decodeCursor(s string) (decodedCursor, error) { if err := validateStringLen(s, encodedCursorMinLength, encodedCursorMaxLength); err != nil { return nil, err } @@ -65,12 +106,12 @@ func decodeCursor(s string) (map[string]string, error) { } decoded := string(decodedBytes) - kvs := strings.Split(decoded, cursorSeparator) + kvps := strings.Split(decoded, cursorSeparator) - m := make(map[string]string, len(kvs)) + m := make(decodedCursor, len(kvps)) - for _, kv := range kvs { - k, v, found := strings.Cut(kv, cursorKeyValueSeparator) + for _, kvp := range kvps { + k, v, found := strings.Cut(kvp, cursorKeyValueSeparator) if !found { return nil, ErrInvalidCursor } @@ -79,3 +120,68 @@ func decodeCursor(s string) (map[string]string, error) { return m, nil } + +func (c decodedCursor) string(k string) (string, error) { + val, ok := c[k] + if !ok { + return "", cursorKeyNotFoundError{k} + } + + return val, nil +} + +func (c decodedCursor) int(k string) (int, error) { + valStr, ok := c[k] + if !ok { + return 0, cursorKeyNotFoundError{k} + } + + val, err := strconv.Atoi(valStr) + if err != nil { + return 0, fmt.Errorf("couldn't parse value for key %s: %w", k, err) + } + + return val, nil +} + +func (c decodedCursor) float64(k string) (float64, error) { + valStr, ok := c[k] + if !ok { + return 0, cursorKeyNotFoundError{k} + } + + val, err := strconv.ParseFloat(valStr, 64) + if err != nil { + return 0, fmt.Errorf("couldn't parse value for key %s: %w", k, err) + } + + return val, nil +} + +func (c decodedCursor) bool(k string) (bool, error) { + valStr, ok := c[k] + if !ok { + return false, cursorKeyNotFoundError{k} + } + + val, err := strconv.ParseBool(valStr) + if err != nil { + return false, fmt.Errorf("couldn't parse value for key %s: %w", k, err) + } + + return val, nil +} + +func (c decodedCursor) time(k string) (time.Time, error) { + valStr, ok := c[k] + if !ok { + return time.Time{}, cursorKeyNotFoundError{k} + } + + val, err := time.Parse(time.RFC3339, valStr) + if err != nil { + return time.Time{}, fmt.Errorf("couldn't parse value for key %s: %w", k, err) + } + + return val, nil +} diff --git a/internal/domain/version.go b/internal/domain/version.go index c27c24d..2b8a0b1 100644 --- a/internal/domain/version.go +++ b/internal/domain/version.go @@ -131,7 +131,12 @@ func decodeVersionCursor(encoded string) (VersionCursor, error) { return VersionCursor{}, err } - vc, err := NewVersionCursor(m["code"]) + code, err := m.string("code") + if err != nil { + return VersionCursor{}, ErrInvalidCursor + } + + vc, err := NewVersionCursor(code) if err != nil { return VersionCursor{}, ErrInvalidCursor } @@ -152,7 +157,7 @@ func (vc VersionCursor) Encode() string { return "" } - return encodeCursor([][2]string{ + return encodeCursor([]keyValuePair{ {"code", vc.code}, }) }