diff --git a/projects/greenlight/cmd/api/movies.go b/projects/greenlight/cmd/api/movies.go index dfa7333..5425da4 100644 --- a/projects/greenlight/cmd/api/movies.go +++ b/projects/greenlight/cmd/api/movies.go @@ -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) } diff --git a/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml b/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml index ba7d0ed..4267234 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml @@ -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: diff --git a/projects/greenlight/internal/data/filters.go b/projects/greenlight/internal/data/filters.go index 55cc00e..618d1dc 100644 --- a/projects/greenlight/internal/data/filters.go +++ b/projects/greenlight/internal/data/filters.go @@ -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") diff --git a/projects/greenlight/internal/data/movies.go b/projects/greenlight/internal/data/movies.go index b01ddd9..63f1720 100644 --- a/projects/greenlight/internal/data/movies.go +++ b/projects/greenlight/internal/data/movies.go @@ -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. diff --git a/projects/greenlight/migrations/000003_add_movies_indexes.down.sql b/projects/greenlight/migrations/000003_add_movies_indexes.down.sql new file mode 100644 index 0000000..5cafa97 --- /dev/null +++ b/projects/greenlight/migrations/000003_add_movies_indexes.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS movies_title_idx; +DROP INDEX IF EXISTS movies_genres_idx; + diff --git a/projects/greenlight/migrations/000003_add_movies_indexes.up.sql b/projects/greenlight/migrations/000003_add_movies_indexes.up.sql new file mode 100644 index 0000000..e8b9d4d --- /dev/null +++ b/projects/greenlight/migrations/000003_add_movies_indexes.up.sql @@ -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); +