diff --git a/api/openapi.yaml b/api/openapi.yaml index 281fe82..c8bdbc4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -20,3 +20,4 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" + \ No newline at end of file diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml index ac49f37..d893ced 100644 --- a/api/schemas/_index.yaml +++ b/api/schemas/_index.yaml @@ -1,5 +1,5 @@ CursorObj: - $ref: ./CursorObj.yaml + $ref: "./CursorObj.yaml" TitleSort: $ref: "./TitleSort.yaml" Image: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index b85d022..3d61b91 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -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 diff --git a/modules/backend/handlers/cursor.go b/modules/backend/handlers/cursor.go new file mode 100644 index 0000000..fd2821e --- /dev/null +++ b/modules/backend/handlers/cursor.go @@ -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 +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index f187cc4..fc3d621 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -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), ¶ms) + 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 } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index c05edff..7118781 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -81,56 +81,96 @@ WHERE id = sqlc.arg('title_id')::bigint; SELECT * FROM titles -WHERE - 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 +WHERE + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next 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 -- 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 + 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('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_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 - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id - END ASC, - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id - END DESC +ORDER BY + CASE WHEN 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 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 --- OFFSET sqlc.narg('offset')::int; -- name: SearchUserTitles :many SELECT diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 5a1d13c..7d970cb 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -179,7 +179,7 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, er return nil, err } defer rows.Close() - var items [][]byte + items := [][]byte{} for rows.Next() { var tag_names []byte if err := rows.Scan(&tag_names); err != nil { @@ -289,84 +289,131 @@ const searchTitles = `-- name: SearchTitles :many SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len FROM titles -WHERE - CASE - WHEN $1::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($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +WHERE + CASE + WHEN $1::boolean THEN + -- forward: greater than cursor (next 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 -- 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 - AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) - AND ($3::float IS NULL OR rating >= $3::float) - AND ($4::int IS NULL OR release_year = $4::int) - AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) -ORDER BY - -- Основной ключ: выбранное поле - CASE - WHEN $6::boolean AND $7::text = 'id' THEN id - WHEN $6::boolean AND $7::text = 'year' THEN release_year - 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, - CASE - WHEN NOT $6::boolean AND $7::text = 'id' THEN id - WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year - WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating - -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END DESC, + AND ( + CASE + WHEN $6::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($6::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) - -- Вторичный ключ: id — только если НЕ сортируем по id - CASE - WHEN $7::text != 'id' AND $6::boolean THEN id - END ASC, - CASE - WHEN $7::text != 'id' AND NOT $6::boolean THEN id - END DESC -LIMIT COALESCE($8::int, 100) + AND ($7::title_status_t IS NULL OR title_status = $7::title_status_t) + AND ($8::float IS NULL OR rating >= $8::float) + AND ($9::int IS NULL OR release_year = $9::int) + AND ($10::release_season_t IS NULL OR release_season = $10::release_season_t) + +ORDER BY + CASE WHEN $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 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 { + 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"` Status *TitleStatusT `json:"status"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` Limit *int32 `json:"limit"` } func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { rows, err := q.db.Query(ctx, searchTitles, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, arg.Word, arg.Status, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.Forward, - arg.SortBy, arg.Limit, ) if err != nil { return nil, err } defer rows.Close() - var items []Title + items := []Title{} for rows.Next() { var i Title if err := rows.Scan( @@ -464,7 +511,6 @@ type SearchUserTitlesRow struct { } // 100 is default limit -// OFFSET sqlc.narg('offset')::int; func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, arg.Word, @@ -479,7 +525,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara return nil, err } defer rows.Close() - var items []SearchUserTitlesRow + items := []SearchUserTitlesRow{} for rows.Next() { var i SearchUserTitlesRow if err := rows.Scan( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f74d2ad..94b9fb4 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -12,6 +12,7 @@ sql: sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: true emit_pointers_for_null_types: true + emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: - db_type: "uuid" nullable: false