feat: sql, migrations, sql postgress db setup, movie model

This commit is contained in:
2026-03-13 13:19:55 +01:00
parent 92295654e7
commit bf1c306ef7
14 changed files with 148 additions and 13 deletions
+31 -10
View File
@@ -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
}
+22 -1
View File
@@ -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) {
@@ -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
@@ -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},
}
}
@@ -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
@@ -0,0 +1 @@
DROP TABLE IF EXISTS movies;
@@ -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
);
@@ -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;
@@ -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);