diff --git a/internal/adapter/repository_bun_tribe.go b/internal/adapter/repository_bun_tribe.go index 1196528..5cb9139 100644 --- a/internal/adapter/repository_bun_tribe.go +++ b/internal/adapter/repository_bun_tribe.go @@ -111,17 +111,25 @@ func (repo *TribeBunRepository) UpdateDominance(ctx context.Context, serverKey s return nil } -func (repo *TribeBunRepository) List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) { +func (repo *TribeBunRepository) List( + ctx context.Context, + params domain.ListTribesParams, +) (domain.ListTribesResult, error) { var tribes bunmodel.Tribes if err := repo.db.NewSelect(). Model(&tribes). Apply(listTribesParamsApplier{params: params}.apply). Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("couldn't select tribes from the db: %w", err) + return domain.ListTribesResult{}, fmt.Errorf("couldn't select tribes from the db: %w", err) } - return tribes.ToDomain() + converted, err := tribes.ToDomain() + if err != nil { + return domain.ListTribesResult{}, err + } + + return domain.NewListTribesResult(separateListResultAndNext(converted, params.Limit())) } func (repo *TribeBunRepository) Delete(ctx context.Context, serverKey string, ids ...int) error { @@ -153,10 +161,6 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Where("tribe.id IN (?)", bun.In(ids)) } - if idGT := a.params.IDGT(); idGT.Valid { - q = q.Where("tribe.id > ?", idGT.Value) - } - if serverKeys := a.params.ServerKeys(); len(serverKeys) > 0 { q = q.Where("tribe.server_key IN (?)", bun.In(serverKeys)) } @@ -184,5 +188,93 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { } } - return q.Limit(a.params.Limit()).Offset(a.params.Offset()) + return q.Limit(a.params.Limit() + 1).Apply(a.applyCursor) +} + +//nolint:gocyclo +func (a listTribesParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery { + if a.params.Cursor().IsZero() { + return q + } + + q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + cursorID := a.params.Cursor().ID() + cursorServerKey := a.params.Cursor().ServerKey() + + sort := a.params.Sort() + sortLen := len(sort) + + // 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: + 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++ { + 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) + default: + return q.Err(errUnsupportedSortValue) + } + } + + switch current { + 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) + } + + return q + }) + } + + return q + }) + } + + return q + }) + + return q } diff --git a/internal/adapter/repository_server_test.go b/internal/adapter/repository_server_test.go index b5924bb..bc6b3df 100644 --- a/internal/adapter/repository_server_test.go +++ b/internal/adapter/repository_server_test.go @@ -129,6 +129,8 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories assert.True(t, slices.IsSortedFunc(servers, func(a, b domain.Server) int { return cmp.Compare(a.Key(), b.Key()) })) + assert.False(t, res.Self().IsZero()) + assert.True(t, res.Next().IsZero()) }, assertError: func(t *testing.T, err error) { t.Helper() @@ -486,6 +488,7 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories assertResult: func(t *testing.T, params domain.ListServersParams, res domain.ListServersResult) { t.Helper() assert.Len(t, res.Servers(), params.Limit()) + assert.False(t, res.Next().IsZero()) }, assertError: func(t *testing.T, err error) { t.Helper() diff --git a/internal/adapter/repository_test.go b/internal/adapter/repository_test.go index fb5a589..9f7616d 100644 --- a/internal/adapter/repository_test.go +++ b/internal/adapter/repository_test.go @@ -24,7 +24,7 @@ type serverRepository interface { type tribeRepository interface { CreateOrUpdate(ctx context.Context, params ...domain.CreateTribeParams) error UpdateDominance(ctx context.Context, serverKey string, numPlayerVillages int) error - List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) + List(ctx context.Context, params domain.ListTribesParams) (domain.ListTribesResult, error) Delete(ctx context.Context, serverKey string, ids ...int) error } diff --git a/internal/adapter/repository_tribe_snapshot_test.go b/internal/adapter/repository_tribe_snapshot_test.go index d017fcc..41b5a67 100644 --- a/internal/adapter/repository_tribe_snapshot_test.go +++ b/internal/adapter/repository_tribe_snapshot_test.go @@ -91,8 +91,9 @@ func testTribeSnapshotRepository(t *testing.T, newRepos func(t *testing.T) repos Valid: true, })) - tribes, err := repos.tribe.List(ctx, listTribesParams) + res, err := repos.tribe.List(ctx, listTribesParams) require.NoError(t, err) + tribes := res.Tribes() require.NotEmpty(t, tribes) date := time.Now() diff --git a/internal/adapter/repository_tribe_test.go b/internal/adapter/repository_tribe_test.go index 5c6cf3c..96c1d8b 100644 --- a/internal/adapter/repository_tribe_test.go +++ b/internal/adapter/repository_tribe_test.go @@ -3,7 +3,6 @@ package adapter_test import ( "cmp" "context" - "fmt" "math" "slices" "testing" @@ -39,8 +38,9 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) require.NoError(t, listParams.SetIDs(ids)) require.NoError(t, listParams.SetServerKeys([]string{params[0].ServerKey()})) - tribes, err := repos.tribe.List(ctx, listParams) + res, err := repos.tribe.List(ctx, listParams) require.NoError(t, err) + tribes := res.Tribes() assert.Len(t, tribes, len(params)) for i, p := range params { idx := slices.IndexFunc(tribes, func(tribe domain.Tribe) bool { @@ -150,10 +150,10 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) })) require.NoError(t, listTribesParams.SetServerKeys([]string{tt.serverKey})) - tribes, err := repos.tribe.List(ctx, listTribesParams) + res, err := repos.tribe.List(ctx, listTribesParams) require.NoError(t, err) - assert.NotEmpty(t, tribes) - for _, tr := range tribes { + assert.NotEmpty(t, res) + for _, tr := range res.Tribes() { if tt.numPlayerVillages == 0 { assert.InDelta(t, 0.0, tr.Dominance(), 0.001) continue @@ -170,17 +170,11 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) repos := newRepos(t) - tribes, listTribesErr := repos.tribe.List(ctx, domain.NewListTribesParams()) - require.NoError(t, listTribesErr) - require.NotEmpty(t, tribes) - randTribe := tribes[0] - tests := []struct { name string params func(t *testing.T) domain.ListTribesParams - assertTribes func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) + assertResult func(t *testing.T, params domain.ListTribesParams, res domain.ListTribesResult) assertError func(t *testing.T, err error) - assertTotal func(t *testing.T, params domain.ListTribesParams, total int) }{ { name: "OK: default params", @@ -188,8 +182,9 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) t.Helper() return domain.NewListTribesParams() }, - assertTribes: func(t *testing.T, _ domain.ListTribesParams, tribes domain.Tribes) { + 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( @@ -197,15 +192,13 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) cmp.Compare(a.ID(), b.ID()), ) })) + assert.False(t, res.Self().IsZero()) + assert.True(t, res.Next().IsZero()) }, assertError: func(t *testing.T, err error) { t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListTribesParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { name: "OK: sort=[serverKey DESC, id DESC]", @@ -215,8 +208,9 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) require.NoError(t, params.SetSort([]domain.TribeSort{domain.TribeSortServerKeyDESC, domain.TribeSortIDDESC})) return params }, - assertTribes: func(t *testing.T, _ domain.ListTribesParams, tribes domain.Tribes) { + 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( @@ -229,26 +223,32 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListTribesParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { - name: fmt.Sprintf("OK: ids=[%d] serverKeys=[%s]", randTribe.ID(), randTribe.ServerKey()), + name: "OK: ids serverKeys", params: func(t *testing.T) domain.ListTribesParams { t.Helper() + params := domain.NewListTribesParams() + + res, err := repos.tribe.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, len(res.Tribes())) + randTribe := res.Tribes()[0] + require.NoError(t, params.SetIDs([]int{randTribe.ID()})) require.NoError(t, params.SetServerKeys([]string{randTribe.ServerKey()})) + return params }, - assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + assertResult: func(t *testing.T, params domain.ListTribesParams, res domain.ListTribesResult) { t.Helper() ids := params.IDs() serverKeys := params.ServerKeys() + tribes := res.Tribes() + assert.NotEmpty(t, tribes) for _, tr := range tribes { assert.True(t, slices.Contains(ids, tr.ID())) assert.True(t, slices.Contains(serverKeys, tr.ServerKey())) @@ -258,37 +258,6 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListTribesParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, - }, - { - name: fmt.Sprintf("OK: idGT=%d", randTribe.ID()), - params: func(t *testing.T) domain.ListTribesParams { - t.Helper() - params := domain.NewListTribesParams() - require.NoError(t, params.SetIDGT(domain.NullInt{ - Value: randTribe.ID(), - Valid: true, - })) - return params - }, - assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { - t.Helper() - assert.NotEmpty(t, tribes) - for _, tr := range tribes { - assert.Greater(t, tr.ID(), params.IDGT().Value, tr.ID()) - } - }, - assertError: func(t *testing.T, err error) { - t.Helper() - require.NoError(t, err) - }, - assertTotal: func(t *testing.T, _ domain.ListTribesParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { name: "OK: deleted=true", @@ -301,8 +270,9 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) })) return params }, - assertTribes: func(t *testing.T, _ domain.ListTribesParams, tribes domain.Tribes) { + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { t.Helper() + tribes := res.Tribes() assert.NotEmpty(t, tribes) for _, s := range tribes { assert.True(t, s.IsDeleted()) @@ -312,10 +282,6 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListTribesParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { name: "OK: deleted=false", @@ -328,8 +294,9 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) })) return params }, - assertTribes: func(t *testing.T, _ domain.ListTribesParams, tribes domain.Tribes) { + assertResult: func(t *testing.T, _ domain.ListTribesParams, res domain.ListTribesResult) { t.Helper() + tribes := res.Tribes() assert.NotEmpty(t, tribes) for _, s := range tribes { assert.False(t, s.IsDeleted()) @@ -339,31 +306,146 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListTribesParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { - name: "OK: offset=1 limit=2", + name: "OK: cursor serverKeys sort=[id ASC]", params: func(t *testing.T) domain.ListTribesParams { t.Helper() + params := domain.NewListTribesParams() - require.NoError(t, params.SetOffset(1)) - require.NoError(t, params.SetLimit(2)) + + res, err := repos.tribe.List(ctx, params) + require.NoError(t, err) + require.Greater(t, len(res.Tribes()), 2) + + require.NoError(t, params.SetSort([]domain.TribeSort{domain.TribeSortIDASC})) + require.NoError(t, params.SetServerKeys([]string{res.Tribes()[1].ServerKey()})) + require.NoError(t, params.SetCursor(domaintest.NewTribeCursor(t, func(cfg *domaintest.TribeCursorConfig) { + cfg.ID = res.Tribes()[1].ID() + }))) + return params }, - assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + assertResult: func(t *testing.T, params domain.ListTribesParams, res domain.ListTribesResult) { t.Helper() - assert.Len(t, tribes, params.Limit()) + + serverKeys := params.ServerKeys() + + tribes := res.Tribes() + assert.NotEmpty(t, len(tribes)) + for _, tr := range res.Tribes() { + assert.GreaterOrEqual(t, tr.ID(), params.Cursor().ID()) + assert.True(t, slices.Contains(serverKeys, tr.ServerKey())) + } + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) int { + return cmp.Compare(a.ID(), b.ID()) + })) }, assertError: func(t *testing.T, err error) { t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListTribesParams, total int) { + }, + { + name: "OK: cursor sort=[serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListTribesParams { t.Helper() - assert.NotEmpty(t, total) + + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + 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() + }))) + + 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.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + assert.GreaterOrEqual(t, tribes[0].ID(), params.Cursor().ID()) + for _, tr := range res.Tribes() { + assert.GreaterOrEqual(t, tr.ServerKey(), params.Cursor().ServerKey()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: cursor sort=[serverKey DESC, id DESC]", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{ + domain.TribeSortServerKeyDESC, + domain.TribeSortIDDESC, + })) + + 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() + }))) + + 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.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) * -1 + })) + assert.LessOrEqual(t, tribes[0].ID(), params.Cursor().ID()) + for _, tr := range res.Tribes() { + assert.LessOrEqual(t, tr.ServerKey(), params.Cursor().ServerKey()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: limit=2", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetLimit(2)) + return params + }, + assertResult: func(t *testing.T, params domain.ListTribesParams, res domain.ListTribesResult) { + t.Helper() + assert.Len(t, res.Tribes(), params.Limit()) + assert.False(t, res.Next().IsZero()) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) }, }, } @@ -378,7 +460,7 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) res, err := repos.tribe.List(ctx, params) tt.assertError(t, err) - tt.assertTribes(t, params, res) + tt.assertResult(t, params, res) }) } }) @@ -406,8 +488,9 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) require.NoError(t, listTribesParams.SetDeleted(domain.NullBool{Value: false, Valid: true})) require.NoError(t, listTribesParams.SetServerKeys(serverKeys)) - tribes, err := repos.tribe.List(ctx, listTribesParams) + res, err := repos.tribe.List(ctx, listTribesParams) require.NoError(t, err) + tribes := res.Tribes() var serverKey string var ids []int @@ -430,9 +513,9 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) require.NoError(t, listTribesParams.SetDeleted(domain.NullBool{Valid: false})) require.NoError(t, listTribesParams.SetServerKeys(serverKeys)) - tribes, err = repos.tribe.List(ctx, listTribesParams) + res, err = repos.tribe.List(ctx, listTribesParams) require.NoError(t, err) - for _, tr := range tribes { + for _, tr := range res.Tribes() { if tr.ServerKey() == serverKey && slices.Contains(ids, tr.ID()) { if slices.Contains(idsToDelete, tr.ID()) { assert.WithinDuration(t, time.Now(), tr.DeletedAt(), time.Minute) diff --git a/internal/adapter/repository_version_test.go b/internal/adapter/repository_version_test.go index fc99c00..7f91e93 100644 --- a/internal/adapter/repository_version_test.go +++ b/internal/adapter/repository_version_test.go @@ -40,6 +40,7 @@ func testVersionRepository(t *testing.T, newRepos func(t *testing.T) repositorie assert.True(t, slices.IsSortedFunc(res.Versions(), func(a, b domain.Version) int { return cmp.Compare(a.Code(), b.Code()) })) + assert.False(t, res.Self().IsZero()) assert.True(t, res.Next().IsZero()) }, assertError: func(t *testing.T, err error) { diff --git a/internal/app/service_tribe.go b/internal/app/service_tribe.go index cd3d7d2..1a9b7bc 100644 --- a/internal/app/service_tribe.go +++ b/internal/app/service_tribe.go @@ -10,7 +10,7 @@ import ( type TribeRepository interface { CreateOrUpdate(ctx context.Context, params ...domain.CreateTribeParams) error UpdateDominance(ctx context.Context, serverKey string, numPlayerVillages int) error - List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) + List(ctx context.Context, params domain.ListTribesParams) (domain.ListTribesResult, error) // Delete marks players with the given serverKey and ids as deleted (sets deleted at to now). // // https://en.wiktionary.org/wiki/soft_deletion @@ -98,12 +98,12 @@ func (svc *TribeService) createOrUpdateChunk(ctx context.Context, serverKey stri return err } - storedTribes, err := svc.repo.List(ctx, listParams) + res, err := svc.repo.List(ctx, listParams) if err != nil { return err } - createParams, err := domain.NewCreateTribeParams(serverKey, tribes, storedTribes) + createParams, err := domain.NewCreateTribeParams(serverKey, tribes, res.Tribes()) if err != nil { return err } @@ -132,21 +132,17 @@ func (svc *TribeService) delete(ctx context.Context, serverKey string, tribes do var toDelete []int for { - storedTribes, err := svc.repo.List(ctx, listParams) + res, err := svc.repo.List(ctx, listParams) if err != nil { return err } + toDelete = append(toDelete, res.Tribes().Delete(serverKey, tribes)...) - if len(storedTribes) == 0 { + if res.Next().IsZero() { break } - toDelete = append(toDelete, storedTribes.Delete(serverKey, tribes)...) - - if err = listParams.SetIDGT(domain.NullInt{ - Value: storedTribes[len(storedTribes)-1].ID(), - Valid: true, - }); err != nil { + if err = listParams.SetCursor(res.Next()); err != nil { return err } } @@ -158,6 +154,6 @@ func (svc *TribeService) UpdateDominance(ctx context.Context, payload domain.Vil return svc.repo.UpdateDominance(ctx, payload.ServerKey(), payload.NumPlayerVillages()) } -func (svc *TribeService) List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) { +func (svc *TribeService) List(ctx context.Context, params domain.ListTribesParams) (domain.ListTribesResult, error) { return svc.repo.List(ctx, params) } diff --git a/internal/app/service_tribe_snapshot.go b/internal/app/service_tribe_snapshot.go index 59097d4..c35aafd 100644 --- a/internal/app/service_tribe_snapshot.go +++ b/internal/app/service_tribe_snapshot.go @@ -53,10 +53,11 @@ func (svc *TribeSnapshotService) Create( } for { - tribes, err := svc.tribeSvc.List(ctx, listTribesParams) + res, err := svc.tribeSvc.List(ctx, listTribesParams) if err != nil { return fmt.Errorf("%s: %w", serverKey, err) } + tribes := res.Tribes() if len(tribes) == 0 { break @@ -71,10 +72,11 @@ func (svc *TribeSnapshotService) Create( return fmt.Errorf("%s: %w", serverKey, err) } - if err = listTribesParams.SetIDGT(domain.NullInt{ - Value: tribes[len(tribes)-1].ID(), - Valid: true, - }); err != nil { + if res.Next().IsZero() { + break + } + + if err = listTribesParams.SetCursor(res.Next()); err != nil { return fmt.Errorf("%s: %w", serverKey, err) } } diff --git a/internal/domain/domaintest/server.go b/internal/domain/domaintest/server.go index b1c130c..4baf1b6 100644 --- a/internal/domain/domaintest/server.go +++ b/internal/domain/domaintest/server.go @@ -30,10 +30,10 @@ func NewServerCursor(tb TestingTB, opts ...func(cfg *ServerCursorConfig)) domain opt(cfg) } - vc, err := domain.NewServerCursor(cfg.Key, cfg.Open) + sc, err := domain.NewServerCursor(cfg.Key, cfg.Open) require.NoError(tb, err) - return vc + return sc } type ServerConfig struct { diff --git a/internal/domain/domaintest/tribe.go b/internal/domain/domaintest/tribe.go index 5f33919..9bbaf7e 100644 --- a/internal/domain/domaintest/tribe.go +++ b/internal/domain/domaintest/tribe.go @@ -12,6 +12,29 @@ func RandTribeTag() string { return gofakeit.LetterN(5) } +type TribeCursorConfig struct { + ID int + ServerKey string +} + +func NewTribeCursor(tb TestingTB, opts ...func(cfg *TribeCursorConfig)) domain.TribeCursor { + tb.Helper() + + cfg := &TribeCursorConfig{ + ID: RandID(), + ServerKey: RandServerKey(), + } + + for _, opt := range opts { + opt(cfg) + } + + tc, err := domain.NewTribeCursor(cfg.ID, cfg.ServerKey) + require.NoError(tb, err) + + return tc +} + type TribeConfig struct { ID int ServerKey string diff --git a/internal/domain/tribe.go b/internal/domain/tribe.go index eb19bf2..8309794 100644 --- a/internal/domain/tribe.go +++ b/internal/domain/tribe.go @@ -6,6 +6,7 @@ import ( "math" "net/url" "slices" + "strconv" "time" ) @@ -368,14 +369,85 @@ const ( TribeSortServerKeyDESC ) +type TribeCursor struct { + id int + serverKey string +} + +const tribeCursorModelName = "TribeCursor" + +func NewTribeCursor(id int, serverKey string) (TribeCursor, error) { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return TribeCursor{}, ValidationError{ + Model: tribeCursorModelName, + Field: "id", + Err: err, + } + } + + if err := validateServerKey(serverKey); err != nil { + return TribeCursor{}, ValidationError{ + Model: tribeCursorModelName, + Field: "serverKey", + Err: err, + } + } + + return TribeCursor{ + id: id, + serverKey: serverKey, + }, nil +} + +func decodeTribeCursor(encoded string) (TribeCursor, error) { + m, err := decodeCursor(encoded) + if err != nil { + return TribeCursor{}, err + } + + id, err := strconv.Atoi(m["id"]) + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + tc, err := NewTribeCursor(id, m["serverKey"]) + if err != nil { + return TribeCursor{}, ErrInvalidCursor + } + + return tc, nil +} + +func (tc TribeCursor) ID() int { + return tc.id +} + +func (tc TribeCursor) ServerKey() string { + return tc.serverKey +} + +func (tc TribeCursor) IsZero() bool { + return tc == TribeCursor{} +} + +func (tc TribeCursor) Encode() string { + if tc.IsZero() { + return "" + } + + return encodeCursor([][2]string{ + {"id", strconv.Itoa(tc.id)}, + {"serverKey", tc.serverKey}, + }) +} + type ListTribesParams struct { ids []int - idGT NullInt serverKeys []string deleted NullBool sort []TribeSort + cursor TribeCursor limit int - offset int } const ( @@ -414,26 +486,6 @@ func (params *ListTribesParams) SetIDs(ids []int) error { return nil } -func (params *ListTribesParams) IDGT() NullInt { - return params.idGT -} - -func (params *ListTribesParams) SetIDGT(idGT NullInt) error { - if idGT.Valid { - if err := validateIntInRange(idGT.Value, 0, math.MaxInt); err != nil { - return ValidationError{ - Model: listTribesParamsModelName, - Field: "idGT", - Err: err, - } - } - } - - params.idGT = idGT - - return nil -} - func (params *ListTribesParams) ServerKeys() []string { return params.serverKeys } @@ -475,6 +527,30 @@ func (params *ListTribesParams) SetSort(sort []TribeSort) error { return nil } +func (params *ListTribesParams) Cursor() TribeCursor { + return params.cursor +} + +func (params *ListTribesParams) SetCursor(cursor TribeCursor) error { + params.cursor = cursor + return nil +} + +func (params *ListTribesParams) SetEncodedCursor(encoded string) error { + decoded, err := decodeTribeCursor(encoded) + if err != nil { + return ValidationError{ + Model: listTribesParamsModelName, + Field: "cursor", + Err: err, + } + } + + params.cursor = decoded + + return nil +} + func (params *ListTribesParams) Limit() int { return params.limit } @@ -493,20 +569,53 @@ func (params *ListTribesParams) SetLimit(limit int) error { return nil } -func (params *ListTribesParams) Offset() int { - return params.offset +type ListTribesResult struct { + tribes Tribes + self TribeCursor + next TribeCursor } -func (params *ListTribesParams) SetOffset(offset int) error { - if err := validateIntInRange(offset, 0, math.MaxInt); err != nil { - return ValidationError{ - Model: listTribesParamsModelName, - Field: "offset", - Err: err, +const listTribesResultModelName = "ListTribesResult" + +func NewListTribesResult(tribes Tribes, next Tribe) (ListTribesResult, error) { + var err error + res := ListTribesResult{ + tribes: tribes, + } + + if len(tribes) > 0 { + res.self, err = NewTribeCursor(tribes[0].ID(), tribes[0].ServerKey()) + if err != nil { + return ListTribesResult{}, ValidationError{ + Model: listTribesResultModelName, + Field: "self", + Err: err, + } } } - params.offset = offset + if !next.IsZero() { + res.next, err = NewTribeCursor(next.ID(), next.ServerKey()) + if err != nil { + return ListTribesResult{}, ValidationError{ + Model: listTribesResultModelName, + Field: "next", + Err: err, + } + } + } - return nil + return res, nil +} + +func (res ListTribesResult) Tribes() Tribes { + return res.tribes +} + +func (res ListTribesResult) Self() TribeCursor { + return res.self +} + +func (res ListTribesResult) Next() TribeCursor { + return res.next } diff --git a/internal/domain/tribe_test.go b/internal/domain/tribe_test.go index bced21b..ea74a2e 100644 --- a/internal/domain/tribe_test.go +++ b/internal/domain/tribe_test.go @@ -9,6 +9,7 @@ import ( "gitea.dwysokinski.me/twhelp/corev3/internal/domain" "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -179,6 +180,81 @@ func TestNewCreateTribeParams(t *testing.T) { } } +func TestNewTribeCursor(t *testing.T) { + t.Parallel() + + validTribeCursor := domaintest.NewTribeCursor(t) + + type args struct { + id int + serverKey string + } + + type test struct { + name string + args args + expectedErr error + } + + tests := []test{ + { + name: "OK", + args: args{ + id: validTribeCursor.ID(), + serverKey: validTribeCursor.ServerKey(), + }, + expectedErr: nil, + }, + { + name: "ERR: id < 1", + args: args{ + id: 0, + serverKey: validTribeCursor.ServerKey(), + }, + expectedErr: domain.ValidationError{ + Model: "TribeCursor", + Field: "id", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + } + + for _, serverKeyTest := range newServerKeyValidationTests() { + tests = append(tests, test{ + name: serverKeyTest.name, + args: args{ + id: validTribeCursor.ID(), + serverKey: serverKeyTest.key, + }, + expectedErr: domain.ValidationError{ + Model: "TribeCursor", + Field: "serverKey", + Err: serverKeyTest.expectedErr, + }, + }) + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tc, err := domain.NewTribeCursor(tt.args.id, tt.args.serverKey) + require.ErrorIs(t, err, tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.id, tc.ID()) + assert.Equal(t, tt.args.serverKey, tc.ServerKey()) + assert.NotEmpty(t, tc.Encode()) + }) + } +} + func TestListTribesParams_SetIDs(t *testing.T) { t.Parallel() @@ -241,63 +317,6 @@ func TestListTribesParams_SetIDs(t *testing.T) { } } -func TestListTribesParams_SetIDGT(t *testing.T) { - t.Parallel() - - type args struct { - idGT domain.NullInt - } - - tests := []struct { - name string - args args - expectedErr error - }{ - { - name: "OK", - args: args{ - idGT: domain.NullInt{ - Value: domaintest.RandID(), - Valid: true, - }, - }, - }, - { - name: "ERR: value < 0", - args: args{ - idGT: domain.NullInt{ - Value: -1, - Valid: true, - }, - }, - expectedErr: domain.ValidationError{ - Model: "ListTribesParams", - Field: "idGT", - Err: domain.MinGreaterEqualError{ - Min: 0, - Current: -1, - }, - }, - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - params := domain.NewListTribesParams() - - require.ErrorIs(t, params.SetIDGT(tt.args.idGT), tt.expectedErr) - if tt.expectedErr != nil { - return - } - assert.Equal(t, tt.args.idGT, params.IDGT()) - }) - } -} - func TestListTribesParams_SetSort(t *testing.T) { t.Parallel() @@ -372,6 +391,90 @@ func TestListTribesParams_SetSort(t *testing.T) { } } +func TestListTribesParams_SetEncodedCursor(t *testing.T) { + t.Parallel() + + validCursor := domaintest.NewTribeCursor(t) + + type args struct { + cursor string + } + + tests := []struct { + name string + args args + expectedCursor domain.TribeCursor + expectedErr error + }{ + { + name: "OK", + args: args{ + cursor: validCursor.Encode(), + }, + expectedCursor: validCursor, + }, + { + name: "ERR: len(cursor) < 1", + args: args{ + cursor: "", + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "cursor", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 1000, + Current: 0, + }, + }, + }, + { + name: "ERR: len(cursor) > 1000", + args: args{ + cursor: gofakeit.LetterN(1001), + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "cursor", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 1000, + Current: 1001, + }, + }, + }, + { + name: "ERR: malformed base64", + args: args{ + cursor: "112345", + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "cursor", + Err: domain.ErrInvalidCursor, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListTribesParams() + + require.ErrorIs(t, params.SetEncodedCursor(tt.args.cursor), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.expectedCursor.ID(), params.Cursor().ID()) + assert.Equal(t, tt.expectedCursor.ServerKey(), params.Cursor().ServerKey()) + assert.Equal(t, tt.args.cursor, params.Cursor().Encode()) + }) + } +} + func TestListTribesParams_SetLimit(t *testing.T) { t.Parallel() @@ -437,53 +540,46 @@ func TestListTribesParams_SetLimit(t *testing.T) { } } -func TestListTribesParams_SetOffset(t *testing.T) { +func TestNewListTribesResult(t *testing.T) { t.Parallel() - type args struct { - offset int + servers := domain.Tribes{ + domaintest.NewTribe(t), + domaintest.NewTribe(t), + domaintest.NewTribe(t), } + next := domaintest.NewTribe(t) - tests := []struct { - name string - args args - expectedErr error - }{ - { - name: "OK", - args: args{ - offset: 100, - }, - }, - { - name: "ERR: offset < 0", - args: args{ - offset: -1, - }, - expectedErr: domain.ValidationError{ - Model: "ListTribesParams", - Field: "offset", - Err: domain.MinGreaterEqualError{ - Min: 0, - Current: -1, - }, - }, - }, - } + t.Run("OK: with next", func(t *testing.T) { + t.Parallel() - for _, tt := range tests { - tt := tt + res, err := domain.NewListTribesResult(servers, next) + require.NoError(t, err) + assert.Equal(t, servers, res.Tribes()) + assert.Equal(t, servers[0].ID(), res.Self().ID()) + assert.Equal(t, servers[0].ServerKey(), res.Self().ServerKey()) + assert.Equal(t, next.ID(), res.Next().ID()) + assert.Equal(t, next.ServerKey(), res.Next().ServerKey()) + }) - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + t.Run("OK: without next", func(t *testing.T) { + t.Parallel() - params := domain.NewListTribesParams() + res, err := domain.NewListTribesResult(servers, domain.Tribe{}) + require.NoError(t, err) + assert.Equal(t, servers, res.Tribes()) + assert.Equal(t, servers[0].ID(), res.Self().ID()) + assert.Equal(t, servers[0].ServerKey(), res.Self().ServerKey()) + assert.True(t, res.Next().IsZero()) + }) - require.ErrorIs(t, params.SetOffset(tt.args.offset), tt.expectedErr) - if tt.expectedErr != nil { - return - } - assert.Equal(t, tt.args.offset, params.Offset()) - }) - } + t.Run("OK: 0 versions", func(t *testing.T) { + t.Parallel() + + res, err := domain.NewListTribesResult(nil, domain.Tribe{}) + require.NoError(t, err) + assert.Zero(t, res.Tribes()) + assert.True(t, res.Self().IsZero()) + assert.True(t, res.Next().IsZero()) + }) } diff --git a/internal/service/data_sync_test.go b/internal/service/data_sync_test.go index 56f1028..a1f7b88 100644 --- a/internal/service/data_sync_test.go +++ b/internal/service/data_sync_test.go @@ -290,19 +290,16 @@ func TestDataSync(t *testing.T) { allTribes := make(domain.Tribes, 0, len(expectedTribes)) for { - tribes, err := tribeRepo.List(ctx, listParams) + res, err := tribeRepo.List(ctx, listParams) require.NoError(collect, err) - if len(tribes) == 0 { + allTribes = append(allTribes, res.Tribes()...) + + if res.Next().IsZero() { break } - allTribes = append(allTribes, tribes...) - - require.NoError(collect, listParams.SetIDGT(domain.NullInt{ - Value: tribes[len(tribes)-1].ID(), - Valid: true, - })) + require.NoError(collect, listParams.SetCursor(res.Next())) } if !assert.Len(collect, allTribes, len(expectedTribes)) { diff --git a/internal/service/snapshot_creation_test.go b/internal/service/snapshot_creation_test.go index 3417162..6f5f5a2 100644 --- a/internal/service/snapshot_creation_test.go +++ b/internal/service/snapshot_creation_test.go @@ -190,19 +190,16 @@ func TestSnapshotCreation(t *testing.T) { var allTribes domain.Tribes for { - tribes, err := tribeRepo.List(ctx, listTribesParams) + res, err := tribeRepo.List(ctx, listTribesParams) require.NoError(collect, err) - if len(tribes) == 0 { + allTribes = append(allTribes, res.Tribes()...) + + if res.Next().IsZero() { break } - allTribes = append(allTribes, tribes...) - - require.NoError(collect, listTribesParams.SetIDGT(domain.NullInt{ - Value: tribes[len(tribes)-1].ID(), - Valid: true, - })) + require.NoError(collect, listTribesParams.SetCursor(res.Next())) } listSnapshotsParams := domain.NewListTribeSnapshotsParams()