Files
pocket-id/backend/internal/utils/list_request_util.go
2025-10-13 09:12:55 +00:00

206 lines
5.1 KiB
Go

package utils
import (
"reflect"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PaginationResponse struct {
TotalPages int64 `json:"totalPages"`
TotalItems int64 `json:"totalItems"`
CurrentPage int `json:"currentPage"`
ItemsPerPage int `json:"itemsPerPage"`
}
type ListRequestOptions struct {
Pagination struct {
Page int `form:"pagination[page]"`
Limit int `form:"pagination[limit]"`
} `form:"pagination"`
Sort struct {
Column string `form:"sort[column]"`
Direction string `form:"sort[direction]"`
} `form:"sort"`
Filters map[string][]any
}
type FieldMeta struct {
ColumnName string
IsSortable bool
IsFilterable bool
}
func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) {
if err := ctx.ShouldBindQuery(&listRequestOptions); err != nil {
return listRequestOptions
}
listRequestOptions.Filters = parseNestedFilters(ctx)
return listRequestOptions
}
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result interface{}) (PaginationResponse, error) {
meta := extractModelMetadata(result)
query = applyFilters(params.Filters, query, meta)
query = applySorting(params.Sort.Column, params.Sort.Direction, query, meta)
return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
} else if pageSize > 100 {
pageSize = 100
}
var totalItems int64
if err := query.Count(&totalItems).Error; err != nil {
return PaginationResponse{}, err
}
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
if totalItems == 0 {
totalPages = 1
}
if int64(page) > totalPages {
page = int(totalPages)
}
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
return PaginationResponse{}, err
}
return PaginationResponse{
TotalPages: totalPages,
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,
}, nil
}
func NormalizeSortDirection(direction string) string {
d := strings.ToLower(strings.TrimSpace(direction))
if d != "asc" && d != "desc" {
return "asc"
}
return d
}
func IsValidSortDirection(direction string) bool {
d := strings.ToLower(strings.TrimSpace(direction))
return d == "asc" || d == "desc"
}
// parseNestedFilters handles ?filters[field][0]=val1&filters[field][1]=val2
func parseNestedFilters(ctx *gin.Context) map[string][]any {
result := make(map[string][]any)
query := ctx.Request.URL.Query()
for key, values := range query {
if !strings.HasPrefix(key, "filters[") {
continue
}
// Keys can be "filters[field]" or "filters[field][0]"
raw := strings.TrimPrefix(key, "filters[")
// Take everything up to the first closing bracket
if idx := strings.IndexByte(raw, ']'); idx != -1 {
field := raw[:idx]
for _, v := range values {
result[field] = append(result[field], ConvertStringToType(v))
}
}
}
return result
}
// applyFilters applies filtering to the GORM query based on the provided filters
func applyFilters(filters map[string][]any, query *gorm.DB, meta map[string]FieldMeta) *gorm.DB {
for key, values := range filters {
if key == "" || len(values) == 0 {
continue
}
fieldName := CapitalizeFirstLetter(key)
fieldMeta, ok := meta[fieldName]
if !ok || !fieldMeta.IsFilterable {
continue
}
query = query.Where(fieldMeta.ColumnName+" IN ?", values)
}
return query
}
// applySorting applies sorting to the GORM query based on the provided column and direction
func applySorting(sortColumn string, sortDirection string, query *gorm.DB, meta map[string]FieldMeta) *gorm.DB {
fieldName := CapitalizeFirstLetter(sortColumn)
fieldMeta, ok := meta[fieldName]
if !ok || !fieldMeta.IsSortable {
return query
}
sortDirection = NormalizeSortDirection(sortDirection)
query = query.Clauses(clause.OrderBy{
Columns: []clause.OrderByColumn{
{Column: clause.Column{Name: fieldMeta.ColumnName}, Desc: sortDirection == "desc"},
},
})
return query
}
// extractModelMetadata extracts FieldMeta from the model struct using reflection
func extractModelMetadata(model interface{}) map[string]FieldMeta {
meta := make(map[string]FieldMeta)
// Unwrap pointers and slices to get the element struct type
t := reflect.TypeOf(model)
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem()
if t == nil {
return meta
}
}
// recursive parser that merges fields from embedded structs
var parseStruct func(reflect.Type)
parseStruct = func(st reflect.Type) {
for i := 0; i < st.NumField(); i++ {
field := st.Field(i)
ft := field.Type
// If the field is an embedded/anonymous struct, recurse into it
if field.Anonymous && ft.Kind() == reflect.Struct {
parseStruct(ft)
continue
}
// Normal field: record metadata
name := field.Name
meta[name] = FieldMeta{
ColumnName: CamelCaseToSnakeCase(name),
IsSortable: field.Tag.Get("sortable") == "true",
IsFilterable: field.Tag.Get("filterable") == "true",
}
}
}
parseStruct(t)
return meta
}