ZhangZhihui's Blog  

 

zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?title=godfather&genres=crime,drama&page=1&page_size=5&sort=year"
{Title:godfather Genres:[crime drama] Page:1 PageSize:5 Sort:year}

 

 

Creating a Filter struct

The page , page_size and sort query string parameters are things that you’ll potentially want to use on other endpoints in your API too. So, to help make this easier, let’s quickly split them out into a reusable Filters struct.

If you’re following along, go ahead and create a new internal/data/filter.go file:

复制代码
package data

import "greenlight.zzh.net/internal/validator"

// Filter is used for filtering, sorting and pagination.
type Filter struct {
    Page         int
    PageSize     int
    Sort         string
    SortSafeList []string
}

// ValidateFilter validates the fields of f using validator v.
func ValidateFilter(v *validator.Validator, f Filter) {
    v.Check(f.Page > 0, "page", "must be greater than 0")
    v.Check(f.Page <= 10_000_000, "page", "must be less than or equal to 10000000")
    v.Check(f.PageSize > 0, "page_size", "must be greater than 0")
    v.Check(f.PageSize <= 100, "page_size", "must be less than or equal to 100")
    v.Check(validator.PermittedValue(f.Sort, f.SortSafeList...), "sort", "invalid sort value")
}
复制代码

 

复制代码
func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {
    // Embed the Filter struct.
    var input struct {
        Title  string
        Genres []string
        data.Filter
    }

    v := validator.New()

    qs := r.URL.Query()

    input.Title = app.readString(qs, "title", "")
    input.Genres = app.readCSV(qs, "genres", []string{})

    input.Filter.Page = app.readInt(qs, "page", 1, v)
    input.Filter.PageSize = app.readInt(qs, "page_size", 20, v)
    input.Filter.Sort = app.readString(qs, "sort", "id")
    input.Filter.SortSafeList = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"}

    if data.ValidateFilter(v, input.Filter); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    fmt.Fprintf(w, "%+v\n", input)
}
复制代码

 

zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?page=-1&page_size=-1&sort=foo"
{
    "error": {
        "page": "must be greater than 0",
        "page_size": "must be greater than 0",
        "sort": "invalid sort value"
    }
}

 

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (LOWER(title) = LOWER($1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

 

https://www.postgresql.org/docs/current/functions-array.html

 

复制代码
// GetAll returns a slice of movies.
func (m MovieModel) GetAll(title string, genres []string, filter Filter) ([]*Movie, error) {
    query := `SELECT id, created_at, title, year, runtime, genres, version 
                FROM movie 
               WHERE (LOWER(title) = LOWER($1) OR $1 = '') 
                 AND (genres @> $2 OR $2 = '{}')
               ORDER BY id`

    ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
    defer cancel()

    rows, err := m.DB.Query(ctx, query, title, genres)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    movies := []*Movie{}

    for rows.Next() {
        var movie Movie

        err := rows.Scan(
            &movie.ID,
            &movie.CreatedAt,
            &movie.Title,
            &movie.Year,
            &movie.Runtime,
            &movie.Genres,
            &movie.Version,
        )
        if err != nil {
            return nil, err
        }

        movies = append(movies, &movie)
    }

    if err = rows.Err(); err != nil {
        return nil, err
    }

    return movies, nil
}
复制代码

 

复制代码
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?title=black+panther"
{
    "movies": [
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?genres=adventure"
{
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?title=moana&genres=animation,adventure"
{
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        }
    ]
}
复制代码

You can also try making a request with a filter that doesn’t match any records. In this case, you should get an empty JSON array in the response like so:

zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?genres=western"
{
    "movies": []
}

 

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

 

 

复制代码
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?title=panther"
{
    "movies": [
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?title=the+club"
{
    "movies": [
        {
            "id": 4,
            "title": "The Breakfast Club",
            "year": 1985,
            "runtime": "97 mins",
            "genres": [
                "drama"
            ],
            "version": 6
        }
    ]
}
复制代码

 

https://www.postgresql.org/docs/current/indexes-intro.html

https://www.postgresql.org/docs/current/indexes-types.html

https://www.postgresql.org/docs/current/textsearch-indexes.html

 

If you’re following along, go ahead and create a new pair of migration files:

zzh@ZZHPC:/zdata/Github/greenlight$ migrate create -seq -ext .sql -dir ./migrations add_movie_indexes
/zdata/Github/greenlight/migrations/000003_add_movie_indexes.up.sql
/zdata/Github/greenlight/migrations/000003_add_movie_indexes.down.sql

 

CREATE INDEX IF NOT EXISTS idx_movie_title ON movie USING GIN (to_tsvector('simple', title));
CREATE INDEX IF NOT EXISTS idx_movie_genres ON movie USING GIN (genres);

 

DROP INDEX IF EXISTS idx_movie_title;
DROP INDEX IF EXISTS idx_movie_genres;

 

migrate_up:
	migrate -path ./migrations -database "$(GREENLIGHT_DB_DSN)" up

 

zzh@ZZHPC:/zdata/Github/greenlight$ make migrate_up
migrate -path ./migrations -database "postgres://greenlight:greenlight@localhost/greenlight?sslmode=disable" up
3/u add_movie_indexes (13.369506ms)

 

复制代码
greenlight=> \dF
               List of text search configurations
   Schema   |    Name    |              Description              
------------+------------+---------------------------------------
 pg_catalog | arabic     | configuration for arabic language
 pg_catalog | armenian   | configuration for armenian language
 pg_catalog | basque     | configuration for basque language
 pg_catalog | catalan    | configuration for catalan language
 pg_catalog | danish     | configuration for danish language
 pg_catalog | dutch      | configuration for dutch language
 pg_catalog | english    | configuration for english language
 pg_catalog | finnish    | configuration for finnish language
 pg_catalog | french     | configuration for french language
 pg_catalog | german     | configuration for german language
 pg_catalog | greek      | configuration for greek language
 pg_catalog | hindi      | configuration for hindi language
 pg_catalog | hungarian  | configuration for hungarian language
 pg_catalog | indonesian | configuration for indonesian language
 pg_catalog | irish      | configuration for irish language
 pg_catalog | italian    | configuration for italian language
 pg_catalog | lithuanian | configuration for lithuanian language
 pg_catalog | nepali     | configuration for nepali language
 pg_catalog | norwegian  | configuration for norwegian language
 pg_catalog | portuguese | configuration for portuguese language
 pg_catalog | romanian   | configuration for romanian language
 pg_catalog | russian    | configuration for russian language
 pg_catalog | serbian    | configuration for serbian language
 pg_catalog | simple     | simple configuration
 pg_catalog | spanish    | configuration for spanish language
 pg_catalog | swedish    | configuration for swedish language
 pg_catalog | tamil      | configuration for tamil language
 pg_catalog | turkish    | configuration for turkish language
 pg_catalog | yiddish    | configuration for yiddish language
(29 rows)
复制代码

And if you wanted to use the english configuration to search our movies, you could update the SQL query like so:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('english', title) @@ plainto_tsquery('english', $1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

If you’d like to learn more about PostgreSQL full-text search, the official documentation is an excellent source of information and examples.

 

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

 

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (title ILIKE $1 OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY id

 

 

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY year DESC, id ASC

 

 

复制代码
// sortColumn checks that the client-provided filed matches one of the entries in the safelist 
// and if it does, extracts the column name from the Sort field by stripping the leading hyphen 
// character (if one exists).
func (f Filter) sortColumn() string {
    for _, safeValue := range f.SortSafeList {
        if f.Sort == safeValue {
            return strings.TrimPrefix(f.Sort, "-")
        }
    }

    panic("unsafe sort parameter: " + f.Sort)
}

// sortDirection returns the sort direction ("ASC" or "DESC") depending on the 
// prefix character of the Sort field.
func (f Filter) sortDirection() string {
    if strings.HasPrefix(f.Sort, "-") {
        return "DESC"
    }

    return "ASC"
}
复制代码

 

func (m MovieModel) GetAll(title string, genres []string, filter Filter) ([]*Movie, error) {
    query := fmt.Sprintf(`
        SELECT id, created_at, title, year, runtime, genres, version 
          FROM movie 
         WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
           AND (genres @> $2 OR $2 = '{}')
         ORDER BY %s %s, id ASC`, filter.sortColumn(), filter.sortDirection())
...

 

复制代码
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?sort=-title"
{
    "movies": [
        {
            "id": 4,
            "title": "The Breakfast Club",
            "year": 1985,
            "runtime": "97 mins",
            "genres": [
                "drama"
            ],
            "version": 6
        },
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}
复制代码

 

func (f Filter) limit() int {
    return f.PageSize
}

func (f Filter) offset() int {
    return (f.Page - 1) * f.PageSize
}

 

 

复制代码
func (m MovieModel) GetAll(title string, genres []string, filter Filter) ([]*Movie, error) {
    query := fmt.Sprintf(`
        SELECT id, created_at, title, year, runtime, genres, version 
          FROM movie 
         WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
           AND (genres @> $2 OR $2 = '{}') 
         ORDER BY %s %s, id ASC 
         LIMIT $3 
        OFFSET $4`, filter.sortColumn(), filter.sortDirection())

    ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
    defer cancel()

    args := []any{title, genres, filter.limit(), filter.offset()}

    rows, err := m.DB.Query(ctx, query, args...)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
...
复制代码

 

复制代码
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?page_size=2"
{
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}
复制代码

 

复制代码
# IMPORTANT: This URL must be surrounded with double-quotes to work correctly.
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?page_size=2&page=2"
{
    "movies": [
        {
            "id": 4,
            "title": "The Breakfast Club",
            "year": 1985,
            "runtime": "97 mins",
            "genres": [
                "drama"
            ],
            "version": 6
        }
    ]
}
复制代码

If you try to request the third page, you should get an empty JSON array in the response like so:

zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?page_size=2&page=3"
{
    "movies": []
}

 

 

SELECT count(*) OVER(), id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY %s %s, id ASC
LIMIT $3 OFFSET $4

 

复制代码
// MetaData holds the pagination metadata.
type Metadata struct {
    CurrentPage  int `json:"current_page,omitempty"`
    PageSize     int `json:"page_size,omitempty"`
    FirstPage    int `json:"first_page,omitempty"`
    LastPage     int `json:"last_page,omitempty"`
    TotalRecords int `json:"total_records,omitempty"`
}

func calculateMetadata(totalRecords, page, pageSize int) Metadata {
    if totalRecords == 0 {
        return Metadata{}
    }

    // Note that when the last page value is calculated we are dividing two int values, and
    // when dividing integer types in Go the result will also be an integer type, with
    // the modulus (or remainder) dropped. So, for example, if there were 12 records in total
    // and a page size of 5, the last page value would be (12+5-1)/5 = 3.2, which is then
    // truncated to 3 by Go.
    return Metadata{
        CurrentPage:  page,
        PageSize:     pageSize,
        FirstPage:    1,
        LastPage:     (totalRecords + pageSize - 1) / pageSize,
        TotalRecords: totalRecords,
    }
}
复制代码

 

复制代码
func (m MovieModel) GetAll(title string, genres []string, filter Filter) ([]*Movie, Metadata, error) {
    query := fmt.Sprintf(`
        SELECT count(*) OVER(), id, created_at, title, year, runtime, genres, version 
          FROM movie 
         WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
           AND (genres @> $2 OR $2 = '{}') 
         ORDER BY %s %s, id ASC 
         LIMIT $3 
        OFFSET $4`, filter.sortColumn(), filter.sortDirection())

    ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
    defer cancel()

    args := []any{title, genres, filter.limit(), filter.offset()}

    rows, err := m.DB.Query(ctx, query, args...)
    if err != nil {
        return nil, Metadata{}, err
    }
    defer rows.Close()

    totalRecords := 0
    movies := []*Movie{}

    for rows.Next() {
        var movie Movie

        err := rows.Scan(
            &totalRecords,
            &movie.ID,
            &movie.CreatedAt,
            &movie.Title,
            &movie.Year,
            &movie.Runtime,
            &movie.Genres,
            &movie.Version,
        )
        if err != nil {
            return nil, Metadata{}, err
        }

        movies = append(movies, &movie)
    }

    if err = rows.Err(); err != nil {
        return nil, Metadata{}, err
    }

    metadta := calculateMetadata(totalRecords, filter.Page, filter.PageSize)

    return movies, metadta, nil
}
复制代码

 

复制代码
func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title  string
        Genres []string
        data.Filter
    }

    v := validator.New()

    qs := r.URL.Query()

    input.Title = app.readString(qs, "title", "")
    input.Genres = app.readCSV(qs, "genres", []string{})

    input.Filter.Page = app.readInt(qs, "page", 1, v)
    input.Filter.PageSize = app.readInt(qs, "page_size", 20, v)
    input.Filter.Sort = app.readString(qs, "sort", "id")
    input.Filter.SortSafeList = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"}

    if data.ValidateFilter(v, input.Filter); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    movies, metadata, err := app.models.Movie.GetAll(input.Title, input.Genres, input.Filter)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies, "metadata": metadata}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}
复制代码

 

复制代码
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?page=1&page_size=2"
{
    "metadata": {
        "current_page": 1,
        "page_size": 2,
        "first_page": 1,
        "last_page": 2,
        "total_records": 3
    },
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}
复制代码

 

复制代码
zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?genres=adventure"
{
    "metadata": {
        "current_page": 1,
        "page_size": 20,
        "first_page": 1,
        "last_page": 1,
        "total_records": 2
    },
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}
复制代码

Lastly, if you make a request with a too-high page value, you should get a response with an empty metadata object and movies array, like this:

zzh@ZZHPC:~$ curl "localhost:4000/v1/movies?page=100"
{
    "metadata": {},
    "movies": []
}

 

posted on   ZhangZhihuiAAA  阅读(6)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
历史上的今天:
2023-11-17 Microservice - Load Balancing (LB)
2023-11-17 Docker - Run MySQL
 
点击右上角即可分享
微信分享提示