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

@ -20,3 +20,4 @@ components:
$ref: "./parameters/_index.yaml" $ref: "./parameters/_index.yaml"
schemas: schemas:
$ref: "./schemas/_index.yaml" $ref: "./schemas/_index.yaml"

View file

@ -1,5 +1,5 @@
CursorObj: CursorObj:
$ref: ./CursorObj.yaml $ref: "./CursorObj.yaml"
TitleSort: TitleSort:
$ref: "./TitleSort.yaml" $ref: "./TitleSort.yaml"
Image: Image:

View file

@ -13,6 +13,71 @@ func NewServer(db *sqlc.Queries) Server {
return Server{db: db} 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) { func parseInt64(s string) (int32, error) {
i, err := strconv.ParseInt(s, 10, 64) i, err := strconv.ParseInt(s, 10, 64)
return int32(i), err 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) { func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) {
opai_titles := make([]oapi.Title, 0) 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) word := Word2Sqlc(request.Params.Word)
status, err := TitleStatus2Sqlc(request.Params.Status) status, err := TitleStatus2Sqlc(request.Params.Status)
@ -233,17 +241,29 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitles400Response{}, 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, Word: word,
Status: status, Status: status,
Rating: request.Params.Rating, Rating: request.Params.Rating,
ReleaseYear: request.Params.ReleaseYear, ReleaseYear: request.Params.ReleaseYear,
ReleaseSeason: season, ReleaseSeason: season,
Forward: true, Forward: true,
SortBy: "id", // SortBy: "id",
Limit: request.Params.Limit, 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 { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitles500Response{}, nil return oapi.GetTitles500Response{}, nil
@ -262,5 +282,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
opai_titles = append(opai_titles, t) 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
} }

View file

@ -83,54 +83,94 @@ SELECT
FROM titles FROM titles
WHERE WHERE
CASE CASE
WHEN sqlc.narg('word')::text IS NOT NULL THEN WHEN sqlc.arg('forward')::boolean THEN
( -- forward: greater than cursor (next page)
SELECT bool_and( CASE sqlc.arg('sort_by')::text
EXISTS ( WHEN 'year' THEN
SELECT 1 (sqlc.narg('cursor_year')::int IS NULL) OR
FROM jsonb_each_text(title_names) AS t(key, val) (release_year > sqlc.narg('cursor_year')::int) OR
WHERE val ILIKE pattern (release_year = sqlc.narg('cursor_year')::int AND id > sqlc.narg('cursor_id')::bigint)
)
) WHEN 'rating' THEN
FROM unnest( (sqlc.narg('cursor_rating')::float IS NULL) OR
ARRAY( (rating > sqlc.narg('cursor_rating')::float) OR
SELECT '%' || trim(w) || '%' (rating = sqlc.narg('cursor_rating')::float AND id > sqlc.narg('cursor_id')::bigint)
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
WHERE trim(w) <> '' WHEN 'id' THEN
) (sqlc.narg('cursor_id')::bigint IS NULL) OR
) AS pattern (id > sqlc.narg('cursor_id')::bigint)
)
ELSE true ELSE true -- fallback
END
ELSE
-- backward: less than cursor (prev page)
CASE sqlc.arg('sort_by')::text
WHEN 'year' THEN
(sqlc.narg('cursor_year')::int IS NULL) OR
(release_year < sqlc.narg('cursor_year')::int) OR
(release_year = sqlc.narg('cursor_year')::int AND id < sqlc.narg('cursor_id')::bigint)
WHEN 'rating' THEN
(sqlc.narg('cursor_rating')::float IS NULL) OR
(rating < sqlc.narg('cursor_rating')::float) OR
(rating = sqlc.narg('cursor_rating')::float AND id < sqlc.narg('cursor_id')::bigint)
WHEN 'id' THEN
(sqlc.narg('cursor_id')::bigint IS NULL) OR
(id < sqlc.narg('cursor_id')::bigint)
ELSE true
END
END END
AND (
CASE
WHEN sqlc.narg('word')::text IS NOT NULL THEN
(
SELECT bool_and(
EXISTS (
SELECT 1
FROM jsonb_each_text(title_names) AS t(key, val)
WHERE val ILIKE pattern
)
)
FROM unnest(
ARRAY(
SELECT '%' || trim(w) || '%'
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
WHERE trim(w) <> ''
)
) AS pattern
)
ELSE true
END
)
AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t)
AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float)
AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int)
AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t)
ORDER BY
-- Основной ключ: выбранное поле
CASE
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
-- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
END ASC,
CASE
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
-- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
END DESC,
-- Вторичный ключ: id — только если НЕ сортируем по id ORDER BY
CASE CASE WHEN sqlc.arg('forward')::boolean THEN
WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id CASE
END ASC, WHEN sqlc.arg('sort_by')::text = 'id' THEN id
CASE WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year
WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating
END DESC END
END ASC,
CASE WHEN NOT sqlc.arg('forward')::boolean THEN
CASE
WHEN sqlc.arg('sort_by')::text = 'id' THEN id
WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year
WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating
END
END DESC,
CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN id END ASC
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
-- OFFSET sqlc.narg('offset')::int;
-- name: SearchUserTitles :many -- name: SearchUserTitles :many
SELECT SELECT

View file

@ -179,7 +179,7 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, er
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items [][]byte items := [][]byte{}
for rows.Next() { for rows.Next() {
var tag_names []byte var tag_names []byte
if err := rows.Scan(&tag_names); err != nil { if err := rows.Scan(&tag_names); err != nil {
@ -291,82 +291,129 @@ SELECT
FROM titles FROM titles
WHERE WHERE
CASE CASE
WHEN $1::text IS NOT NULL THEN WHEN $1::boolean THEN
( -- forward: greater than cursor (next page)
SELECT bool_and( CASE $2::text
EXISTS ( WHEN 'year' THEN
SELECT 1 ($3::int IS NULL) OR
FROM jsonb_each_text(title_names) AS t(key, val) (release_year > $3::int) OR
WHERE val ILIKE pattern (release_year = $3::int AND id > $4::bigint)
)
) WHEN 'rating' THEN
FROM unnest( ($5::float IS NULL) OR
ARRAY( (rating > $5::float) OR
SELECT '%' || trim(w) || '%' (rating = $5::float AND id > $4::bigint)
FROM unnest(string_to_array($1::text, ' ')) AS w
WHERE trim(w) <> '' WHEN 'id' THEN
) ($4::bigint IS NULL) OR
) AS pattern (id > $4::bigint)
)
ELSE true ELSE true -- fallback
END
ELSE
-- backward: less than cursor (prev page)
CASE $2::text
WHEN 'year' THEN
($3::int IS NULL) OR
(release_year < $3::int) OR
(release_year = $3::int AND id < $4::bigint)
WHEN 'rating' THEN
($5::float IS NULL) OR
(rating < $5::float) OR
(rating = $5::float AND id < $4::bigint)
WHEN 'id' THEN
($4::bigint IS NULL) OR
(id < $4::bigint)
ELSE true
END
END END
AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) AND (
AND ($3::float IS NULL OR rating >= $3::float) CASE
AND ($4::int IS NULL OR release_year = $4::int) WHEN $6::text IS NOT NULL THEN
AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) (
ORDER BY SELECT bool_and(
-- Основной ключ: выбранное поле EXISTS (
CASE SELECT 1
WHEN $6::boolean AND $7::text = 'id' THEN id FROM jsonb_each_text(title_names) AS t(key, val)
WHEN $6::boolean AND $7::text = 'year' THEN release_year WHERE val ILIKE pattern
WHEN $6::boolean AND $7::text = 'rating' THEN rating )
-- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views )
END ASC, FROM unnest(
CASE ARRAY(
WHEN NOT $6::boolean AND $7::text = 'id' THEN id SELECT '%' || trim(w) || '%'
WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year FROM unnest(string_to_array($6::text, ' ')) AS w
WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating WHERE trim(w) <> ''
-- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views )
END DESC, ) AS pattern
)
ELSE true
END
)
-- Вторичный ключ: id только если НЕ сортируем по id AND ($7::title_status_t IS NULL OR title_status = $7::title_status_t)
CASE AND ($8::float IS NULL OR rating >= $8::float)
WHEN $7::text != 'id' AND $6::boolean THEN id AND ($9::int IS NULL OR release_year = $9::int)
END ASC, AND ($10::release_season_t IS NULL OR release_season = $10::release_season_t)
CASE
WHEN $7::text != 'id' AND NOT $6::boolean THEN id ORDER BY
END DESC CASE WHEN $1::boolean THEN
LIMIT COALESCE($8::int, 100) CASE
WHEN $2::text = 'id' THEN id
WHEN $2::text = 'year' THEN release_year
WHEN $2::text = 'rating' THEN rating
END
END ASC,
CASE WHEN NOT $1::boolean THEN
CASE
WHEN $2::text = 'id' THEN id
WHEN $2::text = 'year' THEN release_year
WHEN $2::text = 'rating' THEN rating
END
END DESC,
CASE WHEN $2::text <> 'id' THEN id END ASC
LIMIT COALESCE($11::int, 100)
` `
type SearchTitlesParams struct { type SearchTitlesParams struct {
Forward bool `json:"forward"`
SortBy string `json:"sort_by"`
CursorYear *int32 `json:"cursor_year"`
CursorID *int64 `json:"cursor_id"`
CursorRating *float64 `json:"cursor_rating"`
Word *string `json:"word"` Word *string `json:"word"`
Status *TitleStatusT `json:"status"` Status *TitleStatusT `json:"status"`
Rating *float64 `json:"rating"` Rating *float64 `json:"rating"`
ReleaseYear *int32 `json:"release_year"` ReleaseYear *int32 `json:"release_year"`
ReleaseSeason *ReleaseSeasonT `json:"release_season"` ReleaseSeason *ReleaseSeasonT `json:"release_season"`
Forward bool `json:"forward"`
SortBy string `json:"sort_by"`
Limit *int32 `json:"limit"` Limit *int32 `json:"limit"`
} }
func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) {
rows, err := q.db.Query(ctx, searchTitles, rows, err := q.db.Query(ctx, searchTitles,
arg.Forward,
arg.SortBy,
arg.CursorYear,
arg.CursorID,
arg.CursorRating,
arg.Word, arg.Word,
arg.Status, arg.Status,
arg.Rating, arg.Rating,
arg.ReleaseYear, arg.ReleaseYear,
arg.ReleaseSeason, arg.ReleaseSeason,
arg.Forward,
arg.SortBy,
arg.Limit, arg.Limit,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Title items := []Title{}
for rows.Next() { for rows.Next() {
var i Title var i Title
if err := rows.Scan( if err := rows.Scan(
@ -464,7 +511,6 @@ type SearchUserTitlesRow struct {
} }
// 100 is default limit // 100 is default limit
// OFFSET sqlc.narg('offset')::int;
func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) {
rows, err := q.db.Query(ctx, searchUserTitles, rows, err := q.db.Query(ctx, searchUserTitles,
arg.Word, arg.Word,
@ -479,7 +525,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []SearchUserTitlesRow items := []SearchUserTitlesRow{}
for rows.Next() { for rows.Next() {
var i SearchUserTitlesRow var i SearchUserTitlesRow
if err := rows.Scan( if err := rows.Scan(

View file

@ -12,6 +12,7 @@ sql:
sql_driver: "github.com/jackc/pgx/v5" sql_driver: "github.com/jackc/pgx/v5"
emit_json_tags: true emit_json_tags: true
emit_pointers_for_null_types: true emit_pointers_for_null_types: true
emit_empty_slices: true #slices returned by :many queries will be empty instead of nil
overrides: overrides:
- db_type: "uuid" - db_type: "uuid"
nullable: false nullable: false