feat: index on movie genres, metadata, filters, pagination

This commit is contained in:
2026-03-19 14:03:01 +01:00
parent 6b170a2705
commit 0cd4386c44
6 changed files with 104 additions and 24 deletions
+2 -2
View File
@@ -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
// 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 {
app.serverErrorResponse(w, r, err)
return
}
// 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 {
app.serverErrorResponse(w, r, err)
}
@@ -5,27 +5,25 @@ info:
http:
method: GET
url: "{{URL}}{{VERSION}}/movies?genres=action"
url: "{{URL}}{{VERSION}}/movies?page=1&page_size=3&sort=title"
params:
- name: page
value: "1"
type: query
- name: page_size
value: "3"
type: query
- name: sort
value: title
type: query
- name: title
value: ddd
type: query
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
value: action
type: query
disabled: true
auth: inherit
settings:
@@ -1,9 +1,41 @@
package data
import (
"math"
"slices"
"strings"
"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.
type Filters struct {
Page int
@@ -12,6 +44,35 @@ type Filters struct {
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) {
// Check that the page and page_size parameters contain sensible values.
v.Check(f.Page > 0, "page", "must be greater than zero")
+24 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"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
// using them right now, we've set this up to accept the various filter parameters as
// 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.
query := `
SELECT id, created_at, title, year, runtime, genres, version
query := fmt.Sprintf(`
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 id`
ORDER BY %s %s, id ASC
LIMIT $3 OFFSET $4`, filters.sortColumn(), filters.sortDirection())
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
args := []any{
title,
pq.Array(genres),
filters.limit(),
filters.offset(),
}
// Use QueryContext() to execute the query. This returns a sql.Rows resultset
// 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 {
return nil, err
return nil, Metadata{}, err
}
// 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()
// Initialize an empty slice to hold the movie data.
totalRecords := 0
movies := []*Movie{}
// 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
// using the pq.Array() adapter on the genres field here.
err := rows.Scan(
&totalRecords,
&movie.ID,
&movie.CreatedAt,
&movie.Title,
@@ -61,7 +72,7 @@ func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*M
&movie.Version,
)
if err != nil {
return nil, err
return nil, Metadata{}, err
}
// 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
// that was encountered during the iteration.
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.
return movies, nil
return movies, metadata, nil
}
// 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);