feat: index on movie genres, metadata, filters, pagination
This commit is contained in:
@@ -253,14 +253,14 @@ func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
// Call the GetAll() method to retrieve the movies, passing in the various filter
|
// Call the GetAll() method to retrieve the movies, passing in the various filter
|
||||||
// parameters.
|
// parameters.
|
||||||
movies, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters)
|
movies, metadata, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverErrorResponse(w, r, err)
|
app.serverErrorResponse(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send a JSON response containing the movie data.
|
// Send a JSON response containing the movie data.
|
||||||
err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies}, nil)
|
err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies, "metadata": metadata}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverErrorResponse(w, r, err)
|
app.serverErrorResponse(w, r, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,25 @@ info:
|
|||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
url: "{{URL}}{{VERSION}}/movies?genres=action"
|
url: "{{URL}}{{VERSION}}/movies?page=1&page_size=3&sort=title"
|
||||||
params:
|
params:
|
||||||
|
- name: page
|
||||||
|
value: "1"
|
||||||
|
type: query
|
||||||
|
- name: page_size
|
||||||
|
value: "3"
|
||||||
|
type: query
|
||||||
|
- name: sort
|
||||||
|
value: title
|
||||||
|
type: query
|
||||||
- name: title
|
- name: title
|
||||||
value: ddd
|
value: ddd
|
||||||
type: query
|
type: query
|
||||||
disabled: true
|
disabled: true
|
||||||
- name: page
|
|
||||||
value: "9999999"
|
|
||||||
type: query
|
|
||||||
disabled: true
|
|
||||||
- name: page_size
|
|
||||||
value: "10"
|
|
||||||
type: query
|
|
||||||
disabled: true
|
|
||||||
- name: sort
|
|
||||||
value: year
|
|
||||||
type: query
|
|
||||||
disabled: true
|
|
||||||
- name: genres
|
- name: genres
|
||||||
value: action
|
value: action
|
||||||
type: query
|
type: query
|
||||||
|
disabled: true
|
||||||
auth: inherit
|
auth: inherit
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -1,9 +1,41 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"greenlight.debuggingjon.dev/internal/validator"
|
"greenlight.debuggingjon.dev/internal/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Define a new Metadata struct for holding 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// values given the total number of records, current page, and page size values. Note
|
||||||
|
// that the last page value is calculated using the math.Ceil() function, which rounds
|
||||||
|
// up a float to the nearest integer. So, for example, if there were 12 records in total
|
||||||
|
// and a page size of 5, the last page value would be math.Ceil(12/5) = 3.
|
||||||
|
func calculateMetadata(totalRecords, page, pageSize int) Metadata {
|
||||||
|
if totalRecords == 0 {
|
||||||
|
// Note that we return an empty Metadata struct if there are no records.
|
||||||
|
return Metadata{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Metadata{
|
||||||
|
CurrentPage: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
FirstPage: 1,
|
||||||
|
LastPage: int(math.Ceil(float64(totalRecords) / float64(pageSize))),
|
||||||
|
TotalRecords: totalRecords,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add a SortSafelist field to hold the supported sort values.
|
// Add a SortSafelist field to hold the supported sort values.
|
||||||
type Filters struct {
|
type Filters struct {
|
||||||
Page int
|
Page int
|
||||||
@@ -12,6 +44,35 @@ type Filters struct {
|
|||||||
SortSafelist []string
|
SortSafelist []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f Filters) limit() int {
|
||||||
|
return f.PageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Filters) offset() int {
|
||||||
|
return (f.Page - 1) * f.PageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the client-provided Sort field matches one of the entries in our safelist
|
||||||
|
// and if it does, extract the column name from the Sort field by stripping the leading
|
||||||
|
// hyphen character (if one exists).
|
||||||
|
func (f Filters) sortColumn() string {
|
||||||
|
if slices.Contains(f.SortSafelist, f.Sort) {
|
||||||
|
return strings.TrimPrefix(f.Sort, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("unsafe sort parameter: " + f.Sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the sort direction ("ASC" or "DESC") depending on the prefix character of the
|
||||||
|
// Sort field.
|
||||||
|
func (f Filters) sortDirection() string {
|
||||||
|
if strings.HasPrefix(f.Sort, "-") {
|
||||||
|
return "DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "ASC"
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateFilters(v *validator.Validator, f Filters) {
|
func ValidateFilters(v *validator.Validator, f Filters) {
|
||||||
// Check that the page and page_size parameters contain sensible values.
|
// Check that the page and page_size parameters contain sensible values.
|
||||||
v.Check(f.Page > 0, "page", "must be greater than zero")
|
v.Check(f.Page > 0, "page", "must be greater than zero")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
@@ -18,23 +19,31 @@ type MovieModel struct {
|
|||||||
// Create a new GetAll() method which returns a slice of movies. Although we're not
|
// Create a new GetAll() method which returns a slice of movies. Although we're not
|
||||||
// using them right now, we've set this up to accept the various filter parameters as
|
// using them right now, we've set this up to accept the various filter parameters as
|
||||||
// arguments.
|
// arguments.
|
||||||
func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
|
func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, Metadata, error) {
|
||||||
// Use full-text search for the title filter.
|
// Use full-text search for the title filter.
|
||||||
query := `
|
query := fmt.Sprintf(`
|
||||||
SELECT id, created_at, title, year, runtime, genres, version
|
SELECT count(*) OVER(), id, created_at, title, year, runtime, genres, version
|
||||||
FROM movies
|
FROM movies
|
||||||
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
|
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '')
|
||||||
AND (genres @> $2 OR $2 = '{}')
|
AND (genres @> $2 OR $2 = '{}')
|
||||||
ORDER BY id`
|
ORDER BY %s %s, id ASC
|
||||||
|
LIMIT $3 OFFSET $4`, filters.sortColumn(), filters.sortDirection())
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
args := []any{
|
||||||
|
title,
|
||||||
|
pq.Array(genres),
|
||||||
|
filters.limit(),
|
||||||
|
filters.offset(),
|
||||||
|
}
|
||||||
|
|
||||||
// Use QueryContext() to execute the query. This returns a sql.Rows resultset
|
// Use QueryContext() to execute the query. This returns a sql.Rows resultset
|
||||||
// containing the result.
|
// containing the result.
|
||||||
rows, err := m.DB.QueryContext(ctx, query, title, pq.Array(genres))
|
rows, err := m.DB.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, Metadata{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Importantly, defer a call to rows.Close() to ensure that the resultset is closed
|
// Importantly, defer a call to rows.Close() to ensure that the resultset is closed
|
||||||
@@ -42,6 +51,7 @@ func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*M
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
// Initialize an empty slice to hold the movie data.
|
// Initialize an empty slice to hold the movie data.
|
||||||
|
totalRecords := 0
|
||||||
movies := []*Movie{}
|
movies := []*Movie{}
|
||||||
|
|
||||||
// Use rows.Next to iterate through the rows in the resultset.
|
// Use rows.Next to iterate through the rows in the resultset.
|
||||||
@@ -52,6 +62,7 @@ func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*M
|
|||||||
// Scan the values from the row into the Movie struct. Again, note that we're
|
// Scan the values from the row into the Movie struct. Again, note that we're
|
||||||
// using the pq.Array() adapter on the genres field here.
|
// using the pq.Array() adapter on the genres field here.
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
|
&totalRecords,
|
||||||
&movie.ID,
|
&movie.ID,
|
||||||
&movie.CreatedAt,
|
&movie.CreatedAt,
|
||||||
&movie.Title,
|
&movie.Title,
|
||||||
@@ -61,7 +72,7 @@ func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*M
|
|||||||
&movie.Version,
|
&movie.Version,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, Metadata{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the Movie struct to the slice.
|
// Add the Movie struct to the slice.
|
||||||
@@ -71,11 +82,15 @@ func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*M
|
|||||||
// When the rows.Next() loop has finished, call rows.Err() to retrieve any error
|
// When the rows.Next() loop has finished, call rows.Err() to retrieve any error
|
||||||
// that was encountered during the iteration.
|
// that was encountered during the iteration.
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, Metadata{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate a Metadata struct, passing in the total record count and pagination
|
||||||
|
// parameters from the client.
|
||||||
|
metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize)
|
||||||
|
|
||||||
// If everything went OK, then return the slice of movies.
|
// If everything went OK, then return the slice of movies.
|
||||||
return movies, nil
|
return movies, metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a placeholder method for inserting a new record in the movies table.
|
// Add a placeholder method for inserting a new record in the movies table.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS movies_title_idx;
|
||||||
|
DROP INDEX IF EXISTS movies_genres_idx;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS movies_title_idx ON movies USING GIN (to_tsvector('simple', title));
|
||||||
|
CREATE INDEX IF NOT EXISTS movies_genres_idx ON movies USING GIN (genres);
|
||||||
|
|
||||||
Reference in New Issue
Block a user