feat: cursor implemented

This commit is contained in:
Iron_Felix 2025-11-22 00:01:48 +03:00
parent 9c0fada00e
commit af0492cdf1
8 changed files with 435 additions and 106 deletions

View file

@ -13,6 +13,71 @@ func NewServer(db *sqlc.Queries) Server {
return Server{db: db}
}
// type Cursor interface {
// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error)
// Values() map[string]interface{}
// // for logs only
// Type() string
// }
// type CursorByID struct {
// ID int64
// }
// func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) {
// var cur CursorByID
// if err := json.Unmarshal(data, &cur); err != nil {
// return nil, fmt.Errorf("invalid cursor (id): %w", err)
// }
// if cur.ID == 0 {
// return nil, errors.New("cursor id must be non-zero")
// }
// return cur, nil
// }
// func (c CursorByID) Values() map[string]interface{} {
// return map[string]interface{}{
// "cursor_id": c.ID,
// "cursor_year": nil,
// "cursor_rating": nil,
// }
// }
// func (c CursorByID) Type() string { return "id" }
// func NewCursor(sortBy string) (Cursor, error) {
// switch Type(sortBy) {
// case TypeID:
// return CursorByID{}, nil
// case TypeYear:
// return CursorByYear{}, nil
// case TypeRating:
// return CursorByRating{}, nil
// default:
// return nil, fmt.Errorf("unsupported sort_by: %q", sortBy)
// }
// }
// decodes a base64-encoded JSON string into a CursorObj
// Returns the parsed CursorObj and an error
// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) {
// // Decode base64
// decoded, err := base64.StdEncoding.DecodeString(encoded)
// if err != nil {
// return nil, fmt.Errorf("parseCursor: %v", err)
// }
// // Parse JSON
// var cursor oapi.CursorObj
// if err := json.Unmarshal(decoded, &cursor); err != nil {
// return nil, fmt.Errorf("parseCursor: %v", err)
// }
// return &cursor, nil
// }
func parseInt64(s string) (int32, error) {
i, err := strconv.ParseInt(s, 10, 64)
return int32(i), err

View file

@ -0,0 +1,156 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"strconv"
)
// ParseCursorInto parses an opaque base64 cursor and injects values into target struct.
//
// Supported sort types:
// - "id" → sets CursorID (must be *int64)
// - "year" → sets CursorID (*int64) + CursorYear (*int32)
// - "rating" → sets CursorID (*int64) + CursorRating (*float64)
//
// Target struct may have any subset of these fields (e.g. only CursorID).
// Unknown fields are ignored. Missing fields → values are dropped (safe).
//
// Returns error if cursor is invalid or inconsistent with sort_by.
func ParseCursorInto(sortBy, cursorStr string, target any) error {
if cursorStr == "" {
return nil // no cursor → nothing to do
}
// 1. Decode cursor
payload, err := decodeCursor(cursorStr)
if err != nil {
return err
}
// 2. Extract ID (required for all types)
id, err := extractInt64(payload, "id")
if err != nil {
return fmt.Errorf("cursor: %v", err)
}
// 3. Get reflect value of target (must be ptr to struct)
v := reflect.ValueOf(target)
if v.Kind() != reflect.Ptr || v.IsNil() {
return fmt.Errorf("target must be non-nil pointer to struct")
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return fmt.Errorf("target must be pointer to struct")
}
// 4. Helper: set field if exists and compatible
setField := func(fieldName string, value any) {
f := v.FieldByName(fieldName)
if !f.IsValid() || !f.CanSet() {
return // field not found or unexported
}
ft := f.Type()
vv := reflect.ValueOf(value)
// Case: field is *T, value is T → wrap in pointer
if ft.Kind() == reflect.Ptr {
elemType := ft.Elem()
if vv.Type().AssignableTo(elemType) {
ptr := reflect.New(elemType)
ptr.Elem().Set(vv)
f.Set(ptr)
}
// nil → leave as zero (nil pointer)
} else if vv.Type().AssignableTo(ft) {
f.Set(vv)
}
// else: type mismatch → silently skip (safe)
}
// 5. Dispatch by sort type
switch sortBy {
case "id":
setField("CursorID", id)
case "year":
setField("CursorID", id)
param, err := extractString(payload, "param")
if err != nil {
return fmt.Errorf("cursor year: %w", err)
}
year, err := strconv.Atoi(param)
if err != nil {
return fmt.Errorf("cursor year: param must be integer, got %q", param)
}
setField("CursorYear", int32(year)) // or int, depending on your schema
case "rating":
setField("CursorID", id)
param, err := extractString(payload, "param")
if err != nil {
return fmt.Errorf("cursor rating: %w", err)
}
rating, err := strconv.ParseFloat(param, 64)
if err != nil {
return fmt.Errorf("cursor rating: param must be float, got %q", param)
}
setField("CursorRating", rating)
default:
return fmt.Errorf("unsupported sort_by: %q", sortBy)
}
return nil
}
// --- helpers ---
func decodeCursor(cursorStr string) (map[string]any, error) {
data, err := base64.RawURLEncoding.DecodeString(cursorStr)
if err != nil {
data, err = base64.StdEncoding.DecodeString(cursorStr)
if err != nil {
return nil, fmt.Errorf("invalid base64 cursor")
}
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("invalid cursor JSON: %w", err)
}
return m, nil
}
func extractInt64(m map[string]any, key string) (int64, error) {
v, ok := m[key]
if !ok {
return 0, fmt.Errorf("missing %q", key)
}
switch x := v.(type) {
case float64:
if x == float64(int64(x)) {
return int64(x), nil
}
case string:
i, err := strconv.ParseInt(x, 10, 64)
if err == nil {
return i, nil
}
case int64, int, int32:
return reflect.ValueOf(x).Int(), nil
}
return 0, fmt.Errorf("%q must be integer", key)
}
func extractString(m map[string]interface{}, key string) (string, error) {
v, ok := m[key]
if !ok {
return "", fmt.Errorf("missing %q", key)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%q must be string", key)
}
return s, nil
}

View file

@ -218,9 +218,17 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl
func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) {
opai_titles := make([]oapi.Title, 0)
cursor := oapi.CursorObj{
Id: 1,
}
// old_cursor := oapi.CursorObj{
// Id: 1,
// }
// if request.Params.Cursor != nil {
// if old_cursor, err := parseCursor(*request.Params.Cursor); err != nil {
// log.Errorf("%v", err)
// return oapi.GetTitles400Response{}, err
// }
// }
word := Word2Sqlc(request.Params.Word)
status, err := TitleStatus2Sqlc(request.Params.Status)
@ -233,17 +241,29 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
log.Errorf("%v", err)
return oapi.GetTitles400Response{}, err
}
// param = nil means it will not be used
titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{
params := sqlc.SearchTitlesParams{
Word: word,
Status: status,
Rating: request.Params.Rating,
ReleaseYear: request.Params.ReleaseYear,
ReleaseSeason: season,
Forward: true,
SortBy: "id",
Limit: request.Params.Limit,
})
// SortBy: "id",
Limit: request.Params.Limit,
}
if request.Params.Sort != nil {
if request.Params.Cursor != nil {
err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), &params)
if err != nil {
log.Errorf("%v", err)
return oapi.GetTitles400Response{}, nil
}
}
}
// param = nil means it will not be used
titles, err := s.db.SearchTitles(ctx, params)
if err != nil {
log.Errorf("%v", err)
return oapi.GetTitles500Response{}, nil
@ -262,5 +282,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
opai_titles = append(opai_titles, t)
}
return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil
return oapi.GetTitles200JSONResponse{Cursor: oapi.CursorObj{}, Data: opai_titles}, nil
}