Files
greenlight/internal/data/movies.go
2025-11-13 18:52:59 +01:00

256 lines
9.8 KiB
Go

package data
import (
"context"
"database/sql"
"errors"
"github.com/lib/pq"
"time"
"greenlight.craftr.fr/internal/validator"
)
// Movie
/*
Annotate the Movie struct with struct tags to control how the keys
appear in the JSON-encoded output.
*/
type Movie struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"-"`
Title string `json:"title"`
Year int32 `json:"year,omitempty"`
/*
Use the Runtime type instead of int32. Note that the omitempty directive will still work on this: if the Runtime field has the underlying value 0, then it will be considered empty and omitted -- and the MarshalJSON() method we just made won't be called at all.
*/
Runtime Runtime `json:"runtime,omitempty"`
Genres []string `json:"genres,omitempty"`
Version int32 `json:"version"`
}
func ValidateMovie(v *validator.Validator, movie *Movie) {
// Use the Check() method to execute our validation checks. This will add the provided key and error message to the errors maps if the check does not evaluate to true. For example, in the first line we "check that the title is not equal to an empty string".
v.Check(movie.Title != "", "title", "must be provided")
v.Check(len(movie.Title) <= 500, "title", "must not be more than 500 bytes long")
v.Check(movie.Year != 0, "year", "must be provided")
v.Check(movie.Year >= 1888, "year", "must be greater than 1888")
v.Check(movie.Year <= int32(time.Now().Year()), "year", "must not be in the future")
v.Check(movie.Runtime != 0, "runtime", "must be provided")
v.Check(movie.Runtime > 0, "runtime", "must be a positive integer")
v.Check(movie.Genres != nil, "genres", "must be provided")
v.Check(len(movie.Genres) >= 1, "genres", "must contain at least 1 genre")
v.Check(len(movie.Genres) <= 5, "genres", "must not contain more than 5 genres")
// Not that we're using the Unique helper in the line below to check that all values in the input.Genres slice are unique
v.Check(validator.Unique(movie.Genres), "genres", "must not contain duplicate values")
}
// MovieModel struct type wraps a sql.DB connection pool
type MovieModel struct {
DB *sql.DB
}
// Insert : Inserting a new record in the movies table
func (m MovieModel) Insert(movie *Movie) error {
// Define the SQL query for inserting a new record in the movies table and returning the system-generated data.
query := `
INSERT INTO movies (title, year, runtime, genres)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, version`
// Create an args slice containing the values for the placeholder parameters from the movie struct. Declaring this slice immediately next to our SQL query helps to make it nice and clear *what values are being used where* in the query
args := []any{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}
// Create a context with a 3-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Use the QueryRow() method to execute the SQL query on our connection pool, passing in the args slice as a variadic parameter and scanning the system-generated id, created_at and version values into the movie struct.
return m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version)
}
// Get : Fetching a specific record from the movies table
func (m MovieModel) Get(id int64) (*Movie, error) {
// The PostgreSQL bigserial type that we're using for the movie ID starts auto-incrementing at 1 by default, so we know that no movies will have ID values less than that. To avoid making an unnecessary database call, we take a shortcut and return an ErrRecordNotFound error straight away.
if id < 1 {
return nil, ErrRecordNotFound
}
// Define the SQL query for retrieving the movie data.
query := `
SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE id = $1`
// Declare a Movie struct to hold the data returned by the query
var movie Movie
// Use the context.WithTimeout() function to create a context.Context which carries a 3-second timeout deadline. Note that we're using the empty context.Background() as the 'parent' context.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
// Importantly, use defer to make sure that we cancel the context before the Get() method returns.
defer cancel()
// Use the QueryRowContext() method to execute the query, passing in the context with the deadline as the first argument, then the provided id value as a placeholder parameter, and scan the response data into the fields of the Movie struct. Importantly, notice that we need to convert the scan target for the genres column using the pq.Array() adapter function again.
err := m.DB.QueryRowContext(ctx, query, id).Scan(
&movie.ID,
&movie.CreatedAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
// Handle any errors. If there was no matching movie found. Scan() will return a sql.ErrNoRows error. We check for this and return our custom ErrRecordNotFound error instead.
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
// Otherwise, return a pointer to the Movie struct.
return &movie, nil
}
// Update : Updating a specific record in the movies table
func (m MovieModel) Update(movie *Movie) error {
// Declare the SQL query for updating the record and returning the new version number.
// Add the 'AND version = $6' clause to prevent data race
query := `
UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = version +1
WHERE id = $5 AND version = $6
RETURNING version`
// Create an args slice containing the values for the placeholder parameters.
args := []any{
movie.Title,
movie.Year,
movie.Runtime,
pq.Array(movie.Genres),
movie.ID,
movie.Version,
}
// Create a context with a 3-second timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Execute the SQL query. If no matching row could be found, we know the movie version has changed (or the record has been deleted) and we return our custom ErrEditConflict error.
err := m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.Version)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
// Delete : Deleting a specific record from the movies table
func (m MovieModel) Delete(id int64) error {
// Return an ErrRecordNotFound err if the movie ID is less than 1
if id < 1 {
return ErrRecordNotFound
}
// Construct the SQL query to delete the record
query := `
DELETE FROM movies
WHERE id = $1`
// Create a context with a 3-second timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Execute the SQL query using the Exec() method, passing in the id variable as the value for the placeholder parameter. The Exec() method returns a sql.Result object.
result, err := m.DB.ExecContext(ctx, query, id)
if err != nil {
return err
}
// Call the RowsAffected() method on the sql.Result object to get the number of rows after by the query.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// If no rows were affected, we know that the movies table didn't contain a record with provided ID at the moment we tried to delete it. In that case we return an ErrRecordNotFound error
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// GetAll : 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) {
// Construct the SQL query to retrieve all movie records.
// to_tsvector('simple', title) transforms 'The Breakfast Club' into 'breakfast' 'club' 'the'. The 'simple' parameter's value is the configuration.
// plainto_tsquery('simple', $1) takes a search value and turns it into a formatted query term that PostgreSQL full-text search can understand. As an example : "The Club" would result in the query term 'the' & 'club'
// The @@ operator is the matches operator. To continue the example, the query term 'the' & 'club' will match rows which contain both lexemes 'the' and 'club'.
query := `
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`
// Create a context with a 3-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// 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))
if err != nil {
return nil, err
}
// Importantly, defer a call to rows.Close() to ensure the resultset is closed before GetAll() returns
defer rows.Close()
// Initialize an empty slice to hold the movie data.
movies := []*Movie{}
// Use rows.Next to iterate through the rows in the resultset.
for rows.Next() {
// Initialize an empty Movie struct to hold the data for an individual movie.
var movie Movie
// 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(
&movie.ID,
&movie.CreatedAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
if err != nil {
return nil, err
}
// Add the Movie struct to the slice.
movies = append(movies, &movie)
}
// 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
}
// If everything went OK, then return the slice of movies
return movies, nil
}