From bf1c306ef7f28e2ec83b786bb2776e6bf223fa4d Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 13 Mar 2026 13:19:55 +0100 Subject: [PATCH] feat: sql, migrations, sql postgress db setup, movie model --- projects/greenlight/cmd/api/main.go | 41 +++++++++++++----- projects/greenlight/cmd/api/movies.go | 23 +++++++++- .../{ => api}/greenlight-bruno/.gitignore | 0 .../greenlight-bruno/Create Movie.yml | 6 ++- .../greenlight-bruno/Get Single Movie.yml | 0 .../greenlight-bruno/Healthcheck.yml | 0 .../environments/Development.yml | 0 .../greenlight-bruno/opencollection.yml | 0 projects/greenlight/internal/data/models.go | 27 ++++++++++++ projects/greenlight/internal/data/movies.go | 42 +++++++++++++++++++ .../000001_create_movie_table.down.sql | 1 + .../000001_create_movie_table.up.sql | 10 +++++ ...0002_add_movies_check_constraints.down.sql | 5 +++ ...000002_add_movies_check_constraints.up.sql | 6 +++ 14 files changed, 148 insertions(+), 13 deletions(-) rename projects/greenlight/docs/{ => api}/greenlight-bruno/.gitignore (100%) rename projects/greenlight/docs/{ => api}/greenlight-bruno/Create Movie.yml (68%) rename projects/greenlight/docs/{ => api}/greenlight-bruno/Get Single Movie.yml (100%) rename projects/greenlight/docs/{ => api}/greenlight-bruno/Healthcheck.yml (100%) rename projects/greenlight/docs/{ => api}/greenlight-bruno/environments/Development.yml (100%) rename projects/greenlight/docs/{ => api}/greenlight-bruno/opencollection.yml (100%) create mode 100644 projects/greenlight/internal/data/models.go create mode 100644 projects/greenlight/migrations/000001_create_movie_table.down.sql create mode 100644 projects/greenlight/migrations/000001_create_movie_table.up.sql create mode 100644 projects/greenlight/migrations/000002_add_movies_check_constraints.down.sql create mode 100644 projects/greenlight/migrations/000002_add_movies_check_constraints.up.sql diff --git a/projects/greenlight/cmd/api/main.go b/projects/greenlight/cmd/api/main.go index 088da3d..4e680a6 100644 --- a/projects/greenlight/cmd/api/main.go +++ b/projects/greenlight/cmd/api/main.go @@ -12,6 +12,7 @@ import ( "github.com/joho/godotenv" _ "github.com/lib/pq" + "greenlight.debuggingjon.dev/internal/data" ) // Declare a string containing the application version number. Later in the book we'll @@ -28,7 +29,10 @@ type config struct { port int env string db struct { - dsn string + dsn string + maxOpenConns int + maxIdleConns int + maxIdleTime string } } @@ -38,6 +42,7 @@ type config struct { type application struct { config config logger *log.Logger + models data.Models } func main() { @@ -54,7 +59,12 @@ func main() { flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") // Read the DSN value from the db-dsn command-line flag into the config struct. We // default to using our development DSN if no flag is provided. - flag.StringVar(&cfg.db.dsn, "db-dsn", dsn, "PostgreSQL DSN (From DATABASE_DSN in .env)") + flag.StringVar(&cfg.db.dsn, "db-dsn", dsn, "PostgreSQL DSN (Default from DATABASE_DSN in .env)") + // Read the connection pool settings from command-line flags into the config struct. + // Notice the default values that we're using? + flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections") + flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections") + flag.StringVar(&cfg.db.maxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max connection idle time") flag.Parse() @@ -80,6 +90,7 @@ func main() { app := &application{ config: cfg, logger: logger, + models: data.NewModels(db), } // Use the httprouter instance returned by app.routes() as the server handler. @@ -97,26 +108,36 @@ func main() { } func openDB(cfg config) (*sql.DB, error) { - // Use sql.Open() to create an empty connection pool, using the DSN from the config - // struct. db, err := sql.Open("postgres", cfg.db.dsn) if err != nil { return nil, err } - // Create a context with a 5-second timeout deadline. + // Set the maximum number of open (in-use + idle) connections in the pool. Note that + // passing a value less than or equal to 0 will mean there is no limit. + db.SetMaxOpenConns(cfg.db.maxOpenConns) + + // Set the maximum number of idle connections in the pool. Again, passing a value + // less than or equal to 0 will mean there is no limit. + db.SetMaxIdleConns(cfg.db.maxIdleConns) + + // Use the time.ParseDuration() function to convert the idle timeout duration string + // to a time.Duration type. + duration, err := time.ParseDuration(cfg.db.maxIdleTime) + if err != nil { + return nil, err + } + + // Set the maximum idle timeout. + db.SetConnMaxIdleTime(duration) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Use PingContext() to establish a new connection to the database, passing in the - // context we created above as a parameter. If the connection couldn't be - // established successfully within the 5 second deadline, then this will return an - // error. err = db.PingContext(ctx) if err != nil { return nil, err } - // Return the sql.DB connection pool. return db, nil } diff --git a/projects/greenlight/cmd/api/movies.go b/projects/greenlight/cmd/api/movies.go index e911285..b6b54d2 100644 --- a/projects/greenlight/cmd/api/movies.go +++ b/projects/greenlight/cmd/api/movies.go @@ -41,7 +41,28 @@ func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Reques return } - fmt.Fprintf(w, "%+v\n", input) + // Call the Insert() method on our movies model, passing in a pointer to the + // validated movie struct. This will create a record in the database and update the + // movie struct with the system-generated information.” + err = app.models.Movies.Insert(movie) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // When sending a HTTP response, we want to include a Location header to let the + // client know which URL they can find the newly-created resource at. We make an + // empty http.Header map and then use the Set() method to add a new Location header, + // interpolating the system-generated ID for our new movie in the URL. + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/movies/%d", movie.ID)) + + // Write a JSON response with a 201 Created status code, the movie data in the + // response body, and the Location header. + err = app.writeJSON(w, http.StatusCreated, envelope{"movie": movie}, headers) + if err != nil { + app.serverErrorResponse(w, r, err) + } } func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) { diff --git a/projects/greenlight/docs/greenlight-bruno/.gitignore b/projects/greenlight/docs/api/greenlight-bruno/.gitignore similarity index 100% rename from projects/greenlight/docs/greenlight-bruno/.gitignore rename to projects/greenlight/docs/api/greenlight-bruno/.gitignore diff --git a/projects/greenlight/docs/greenlight-bruno/Create Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Create Movie.yml similarity index 68% rename from projects/greenlight/docs/greenlight-bruno/Create Movie.yml rename to projects/greenlight/docs/api/greenlight-bruno/Create Movie.yml index 57cde42..7e97970 100644 --- a/projects/greenlight/docs/greenlight-bruno/Create Movie.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Create Movie.yml @@ -10,8 +10,10 @@ http: type: json data: |- { - "title": "2313", - "runtime": "2" + "title": "The Breakfast Club", + "runtime": "96 mins", + "year": 1986, + "genres": ["drama"] } auth: inherit diff --git a/projects/greenlight/docs/greenlight-bruno/Get Single Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml similarity index 100% rename from projects/greenlight/docs/greenlight-bruno/Get Single Movie.yml rename to projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml diff --git a/projects/greenlight/docs/greenlight-bruno/Healthcheck.yml b/projects/greenlight/docs/api/greenlight-bruno/Healthcheck.yml similarity index 100% rename from projects/greenlight/docs/greenlight-bruno/Healthcheck.yml rename to projects/greenlight/docs/api/greenlight-bruno/Healthcheck.yml diff --git a/projects/greenlight/docs/greenlight-bruno/environments/Development.yml b/projects/greenlight/docs/api/greenlight-bruno/environments/Development.yml similarity index 100% rename from projects/greenlight/docs/greenlight-bruno/environments/Development.yml rename to projects/greenlight/docs/api/greenlight-bruno/environments/Development.yml diff --git a/projects/greenlight/docs/greenlight-bruno/opencollection.yml b/projects/greenlight/docs/api/greenlight-bruno/opencollection.yml similarity index 100% rename from projects/greenlight/docs/greenlight-bruno/opencollection.yml rename to projects/greenlight/docs/api/greenlight-bruno/opencollection.yml diff --git a/projects/greenlight/internal/data/models.go b/projects/greenlight/internal/data/models.go new file mode 100644 index 0000000..645eac4 --- /dev/null +++ b/projects/greenlight/internal/data/models.go @@ -0,0 +1,27 @@ +package data + +import ( + "database/sql" + "errors" +) + +// Define a custom ErrRecordNotFound error. We'll return this from our Get() method when +// looking up a movie that doesn't exist in our database. +var ( + ErrRecordNotFound = errors.New("record not found") +) + +// Create a Models struct which wraps the MovieModel. We'll add other models to this, +// like a UserModel and PermissionModel, as our build progresses. +type Models struct { + Movies MovieModel +} + +// For ease of use, we also add a New() +// method which returns a Models struct containing +// the initialized MovieModel. +func NewModels(db *sql.DB) Models { + return Models{ + Movies: MovieModel{DB: db}, + } +} diff --git a/projects/greenlight/internal/data/movies.go b/projects/greenlight/internal/data/movies.go index 598fe1b..8fe1cf8 100644 --- a/projects/greenlight/internal/data/movies.go +++ b/projects/greenlight/internal/data/movies.go @@ -1,11 +1,53 @@ package data import ( + "database/sql" "time" + "github.com/lib/pq" "greenlight.debuggingjon.dev/internal/validator" ) +// Define a MovieModel struct type which wraps a sql.DB connection pool. +type MovieModel struct { + DB *sql.DB +} + +// Add a placeholder method for 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 := []interface{}{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)} + + // 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.QueryRow(query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version) +} + +// Add a placeholder method for fetching a specific record from the movies table. +func (m MovieModel) Get(id int64) (*Movie, error) { + return nil, nil +} + +// Add a placeholder method for updating a specific record in the “movies table. +func (m MovieModel) Update(movie *Movie) error { + return nil +} + +// Add a placeholder method for deleting a specific record from the movies table. +func (m MovieModel) Delete(id int64) error { + return nil +} + type Movie struct { ID int64 `json:"id"` CreatedAt time.Time `json:"-"` // Use the - directive diff --git a/projects/greenlight/migrations/000001_create_movie_table.down.sql b/projects/greenlight/migrations/000001_create_movie_table.down.sql new file mode 100644 index 0000000..3a51876 --- /dev/null +++ b/projects/greenlight/migrations/000001_create_movie_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS movies; diff --git a/projects/greenlight/migrations/000001_create_movie_table.up.sql b/projects/greenlight/migrations/000001_create_movie_table.up.sql new file mode 100644 index 0000000..f367413 --- /dev/null +++ b/projects/greenlight/migrations/000001_create_movie_table.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS movies ( + id bigserial PRIMARY KEY, + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + title text NOT NULL, + year integer NOT NULL, + runtime integer NOT NULL, + genres text[] NOT NULL, + version integer NOT NULL DEFAULT 1 +); + diff --git a/projects/greenlight/migrations/000002_add_movies_check_constraints.down.sql b/projects/greenlight/migrations/000002_add_movies_check_constraints.down.sql new file mode 100644 index 0000000..d432ad9 --- /dev/null +++ b/projects/greenlight/migrations/000002_add_movies_check_constraints.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE movies DROP CONSTRAINT IF EXISTS movies_runtime_check; + +ALTER TABLE movies DROP CONSTRAINT IF EXISTS movies_year_check; + +ALTER TABLE movies DROP CONSTRAINT IF EXISTS genres_length_check; diff --git a/projects/greenlight/migrations/000002_add_movies_check_constraints.up.sql b/projects/greenlight/migrations/000002_add_movies_check_constraints.up.sql new file mode 100644 index 0000000..2eb5666 --- /dev/null +++ b/projects/greenlight/migrations/000002_add_movies_check_constraints.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE movies ADD CONSTRAINT movies_runtime_check CHECK (runtime >= 0); + +ALTER TABLE movies ADD CONSTRAINT movies_year_check CHECK (year BETWEEN 1888 AND date_part('year', now())); + +ALTER TABLE movies ADD CONSTRAINT genres_length_check CHECK (array_length(genres, 1) BETWEEN 1 AND 5); +