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
@@ -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.