diff --git a/api/openapi.yaml b/api/openapi.yaml index c8bdbc4..281fe82 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -20,4 +20,3 @@ 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 d893ced..ac49f37 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 3d61b91..b85d022 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -13,71 +13,6 @@ 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 deleted file mode 100644 index fd2821e..0000000 --- a/modules/backend/handlers/cursor.go +++ /dev/null @@ -1,156 +0,0 @@ -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 fc3d621..f187cc4 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -218,17 +218,9 @@ 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) - - // 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 - // } - // } + cursor := oapi.CursorObj{ + Id: 1, + } word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -241,29 +233,17 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } - - params := sqlc.SearchTitlesParams{ + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ Word: word, Status: status, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, Forward: true, - // 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) + SortBy: "id", + Limit: request.Params.Limit, + }) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil @@ -282,5 +262,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles = append(opai_titles, t) } - return oapi.GetTitles200JSONResponse{Cursor: oapi.CursorObj{}, Data: opai_titles}, nil + return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7118781..c05edff 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -81,96 +81,56 @@ WHERE id = sqlc.arg('title_id')::bigint; SELECT * FROM titles -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 - ) +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 - END - ) + 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 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 + -- Основной ключ: выбранное поле + 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 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 7d970cb..5a1d13c 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() - items := [][]byte{} + var items [][]byte for rows.Next() { var tag_names []byte if err := rows.Scan(&tag_names); err != nil { @@ -289,131 +289,84 @@ 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::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 +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 END - 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 - ) - - 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) - + 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 $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 $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, - CASE WHEN $2::text <> 'id' THEN id END ASC - -LIMIT COALESCE($11::int, 100) + -- Вторичный ключ: 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) ` 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() - items := []Title{} + var items []Title for rows.Next() { var i Title if err := rows.Scan( @@ -511,6 +464,7 @@ 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, @@ -525,7 +479,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara return nil, err } defer rows.Close() - items := []SearchUserTitlesRow{} + var items []SearchUserTitlesRow for rows.Next() { var i SearchUserTitlesRow if err := rows.Scan( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 94b9fb4..f74d2ad 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -12,7 +12,6 @@ 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