1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

feat(wip): leaderboard pagination (resolve #417) [ci-skip]

This commit is contained in:
Ferdinand Mütsch 2022-10-16 18:59:19 +02:00
parent 8a21be4306
commit 41f6db8f34
9 changed files with 85 additions and 18 deletions

View File

@ -34,6 +34,11 @@ type KeyedInterval struct {
Key *IntervalKey Key *IntervalKey
} }
type PageParams struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468) // CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
type CustomTime time.Time type CustomTime time.Time
@ -99,3 +104,17 @@ func (j CustomTime) T() time.Time {
func (j CustomTime) Valid() bool { func (j CustomTime) Valid() bool {
return j.T().Unix() >= 0 return j.T().Unix() >= 0
} }
func (p *PageParams) Limit() int {
if p.PageSize < 0 {
return 0
}
return p.PageSize
}
func (p *PageParams) Offset() int {
if p.PageSize <= 0 {
return 0
}
return (p.Page - 1) * p.PageSize
}

View File

@ -14,6 +14,7 @@ type LeaderboardViewModel struct {
TopKeys []string TopKeys []string
UserLanguages map[string][]string UserLanguages map[string][]string
ApiKey string ApiKey string
PageParams *models.PageParams
Success string Success string
Error string Error string
} }

View File

@ -33,13 +33,17 @@ func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
return count, err return count, err
} }
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) { func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItem, error) {
// TODO: distinct by (user, key) to filter out potential duplicates ? // TODO: distinct by (user, key) to filter out potential duplicates ?
var items []*models.LeaderboardItem var items []*models.LeaderboardItem
q := r.db. subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\""). Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("\"interval\" in ?", *key) Where("\"interval\" in ?", *key)
q = utils.WhereNullable(q, "\"by\"", by) subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil { if err := q.Find(&items).Error; err != nil {
return nil, err return nil, err
@ -47,13 +51,17 @@ func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalK
return items, nil return items, nil
} }
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) { func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItem, error) {
var items []*models.LeaderboardItem var items []*models.LeaderboardItem
q := r.db. subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\""). Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("user_id = ?", userId). Where("user_id = ?", userId).
Where("\"interval\" in ?", *key) Where("\"interval\" in ?", *key)
q = utils.WhereNullable(q, "\"by\"", by) subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil { if err := q.Find(&items).Error; err != nil {
return nil, err return nil, err
@ -79,3 +87,13 @@ func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *mode
} }
return nil return nil
} }
func (r *LeaderboardRepository) withPaging(q *gorm.DB, limit, skip int) *gorm.DB {
if limit > 0 {
q = q.Where("\"rank\" <= ?", skip+limit)
}
if skip > 0 {
q = q.Where("\"rank\" > ?", skip)
}
return q
}

View File

@ -92,6 +92,6 @@ type ILeaderboardRepository interface {
CountAllByUser(string) (int64, error) CountAllByUser(string) (int64, error)
DeleteByUser(string) error DeleteByUser(string) error
DeleteByUserAndInterval(string, *models.IntervalKey) error DeleteByUserAndInterval(string, *models.IntervalKey) error
GetAllAggregatedByInterval(*models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error) GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItem, error)
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error) GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItem, error)
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view" "github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"strings" "strings"
) )
@ -56,6 +57,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
user := middlewares.GetPrincipal(r) user := middlewares.GetPrincipal(r)
byParam := strings.ToLower(r.URL.Query().Get("by")) byParam := strings.ToLower(r.URL.Query().Get("by"))
keyParam := strings.ToLower(r.URL.Query().Get("key")) keyParam := strings.ToLower(r.URL.Query().Get("key"))
pageParams := utils.ParsePageParams(r)
var err error var err error
var leaderboard models.Leaderboard var leaderboard models.Leaderboard
@ -63,14 +65,14 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
var topKeys []string var topKeys []string
if byParam == "" { if byParam == "" {
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, true) leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, pageParams, true)
if err != nil { if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err) conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError} return &view.LeaderboardViewModel{Error: criticalError}
} }
} else { } else {
if by, ok := allowedAggregations[byParam]; ok { if by, ok := allowedAggregations[byParam]; ok {
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, true) leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, pageParams, true)
if err != nil { if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err) conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError} return &view.LeaderboardViewModel{Error: criticalError}
@ -110,6 +112,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
UserLanguages: userLanguages, UserLanguages: userLanguages,
TopKeys: topKeys, TopKeys: topKeys,
ApiKey: apiKey, ApiKey: apiKey,
PageParams: pageParams,
Success: r.URL.Query().Get("success"), Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"reflect" "reflect"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -125,18 +126,18 @@ func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
return count > 0, err return count > 0, err
} }
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, resolveUsers bool) (models.Leaderboard, error) { func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
return srv.GetAggregatedByInterval(interval, nil, resolveUsers) return srv.GetAggregatedByInterval(interval, nil, pageParams, resolveUsers)
} }
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, resolveUsers bool) (models.Leaderboard, error) { func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
// check cache // check cache
cacheKey := srv.getHash(interval, by) cacheKey := srv.getHash(interval, by, pageParams)
if cacheResult, ok := srv.cache.Get(cacheKey); ok { if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItem), nil return cacheResult.([]*models.LeaderboardItem), nil
} }
items, err := srv.repository.GetAllAggregatedByInterval(interval, by) items, err := srv.repository.GetAllAggregatedByInterval(interval, by, pageParams.Limit(), pageParams.Offset())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -208,10 +209,11 @@ func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, inter
return items, nil return items, nil
} }
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8) string { func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, pageParams *models.PageParams) string {
k := strings.Join(*interval, "__") k := strings.Join(*interval, "__")
if by != nil && !reflect.ValueOf(by).IsNil() { if by != nil && !reflect.ValueOf(by).IsNil() {
k += "__" + models.GetEntityColumn(*by) k += "__" + models.GetEntityColumn(*by)
} }
k = "__" + strconv.Itoa(pageParams.Page) + "__" + strconv.Itoa(pageParams.PageSize)
return k return k
} }

View File

@ -101,8 +101,8 @@ type ILeaderboardService interface {
ScheduleDefault() ScheduleDefault()
Run([]*models.User, *models.IntervalKey, []uint8) error Run([]*models.User, *models.IntervalKey, []uint8) error
ExistsAnyByUser(string) (bool, error) ExistsAnyByUser(string) (bool, error)
GetByInterval(*models.IntervalKey, bool) (models.Leaderboard, error) GetByInterval(*models.IntervalKey, *models.PageParams, bool) (models.Leaderboard, error)
GetAggregatedByInterval(*models.IntervalKey, *uint8, bool) (models.Leaderboard, error) GetAggregatedByInterval(*models.IntervalKey, *uint8, *models.PageParams, bool) (models.Leaderboard, error)
GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error) GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error)
GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error) GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error)
} }

View File

@ -39,3 +39,13 @@ func WhereNullable(query *gorm.DB, col string, val any) *gorm.DB {
} }
return query.Where(fmt.Sprintf("%s = ?", col), val) return query.Where(fmt.Sprintf("%s = ?", col), val)
} }
func WithPaging(query *gorm.DB, limit, skip int) *gorm.DB {
if limit >= 0 {
query = query.Limit(limit)
}
if skip >= 0 {
query = query.Offset(skip)
}
return query
}

View File

@ -3,6 +3,7 @@ package utils
import ( import (
"encoding/json" "encoding/json"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@ -42,3 +43,16 @@ func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
} }
return false return false
} }
func ParsePageParams(r *http.Request) *models.PageParams {
pageParams := &models.PageParams{}
page := r.URL.Query().Get("page")
pageSize := r.URL.Query().Get("page_size")
if p, err := strconv.Atoi(page); err == nil {
pageParams.Page = p
}
if p, err := strconv.Atoi(pageSize); err == nil && pageParams.Page > 0 {
pageParams.PageSize = p
}
return pageParams
}