first commit
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
# Lesson 4 — User Model & Repository Pattern
|
||||
|
||||
> **New Go concepts in this lesson:** applying pointers and pointer
|
||||
> receivers for real (not just toy examples), sentinel errors in
|
||||
> practice, `QueryRowContext` vs `QueryContext`. Make sure you've done
|
||||
> the pointers section of `00-go-basics-2-functions-structs-pointers.md`
|
||||
> and the errors section of `00-go-basics-3-...md` before this lesson —
|
||||
> everything here depends on both.
|
||||
|
||||
## Quick pointer refresher, applied
|
||||
|
||||
Two rules from Go Basics you'll use constantly in this lesson:
|
||||
|
||||
1. If a function needs to **write a result back** into the caller's
|
||||
variable, it must take a pointer (`*Book`), and write through it
|
||||
(`b.ID = ...`).
|
||||
2. If a struct wraps something stateful/shared (like a database
|
||||
connection pool), methods on it should use a **pointer receiver**
|
||||
(`func (r *BookRepository) ...`), so every call operates on the same
|
||||
underlying resource instead of an accidental copy.
|
||||
|
||||
Keep those two rules in mind as you read the code below — they explain
|
||||
almost every `*` you'll see in this lesson.
|
||||
|
||||
## Part A — standalone playground
|
||||
|
||||
We'll practice the **repository pattern**: separating "how do I talk to
|
||||
the database" from "what does my business logic do."
|
||||
|
||||
Reuse the MySQL container from Lesson 3, or start fresh:
|
||||
```bash
|
||||
docker run --name mysql-demo2 -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=demo -p 3306:3306 -d mysql:9
|
||||
|
||||
mkdir ~/go-playground/repo-demo && cd ~/go-playground/repo-demo
|
||||
go mod init repo-demo
|
||||
go get github.com/go-sql-driver/mysql@latest
|
||||
```
|
||||
|
||||
**`main.go`**
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// 1. The domain model - a plain struct representing one "thing" in your
|
||||
// app. No database code here at all - this is just data.
|
||||
type Book struct {
|
||||
ID int
|
||||
Title string
|
||||
Author string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// 2. The repository - a struct that wraps *sql.DB and knows how to turn
|
||||
// SQL rows into Book structs, and Book structs into SQL writes.
|
||||
type BookRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Constructor function - Go convention: NewXxx returns a *Xxx
|
||||
func NewBookRepository(db *sql.DB) *BookRepository {
|
||||
return &BookRepository{db: db}
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("book not found")
|
||||
|
||||
// 3. Pointer receiver: (r *BookRepository) - because we don't want to
|
||||
// copy the struct (it holds a *sql.DB) on every single method call.
|
||||
func (r *BookRepository) Create(ctx context.Context, b *Book) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"INSERT INTO books (title, author, created_at) VALUES (?, ?, ?)",
|
||||
b.Title, b.Author, time.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert book: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get last insert id: %w", err)
|
||||
}
|
||||
b.ID = int(id) // write the new ID back into the caller's Book
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BookRepository) FindByID(ctx context.Context, id int) (*Book, error) {
|
||||
var b Book
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
"SELECT id, title, author, created_at FROM books WHERE id = ?", id,
|
||||
).Scan(&b.ID, &b.Title, &b.Author, &b.CreatedAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find book: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("mysql", "root:devpass@tcp(127.0.0.1:3306)/demo?parseTime=true")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
author VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
)`); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
repo := NewBookRepository(db)
|
||||
|
||||
b := &Book{Title: "The Go Programming Language", Author: "Donovan & Kernighan"}
|
||||
if err := repo.Create(ctx, b); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("created book with id %d", b.ID)
|
||||
|
||||
found, err := repo.FindByID(ctx, b.ID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("found: %+v", found)
|
||||
|
||||
_, err = repo.FindByID(ctx, 999999)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
log.Println("correctly got ErrNotFound for missing book")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run it:
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
What's new here:
|
||||
- **`Book` struct has zero database knowledge** — it's pure data. Your
|
||||
handlers/business logic will work with `Book`, never with raw SQL rows
|
||||
directly.
|
||||
- **`BookRepository` wraps `*sql.DB`** and is the *only* place SQL queries
|
||||
live. Swap MySQL for Postgres later, and you change this one file, not
|
||||
every handler.
|
||||
- **`NewBookRepository(db)` constructor** — Go has no
|
||||
classes/constructors as a language feature; `NewXxx` returning `*Xxx` is
|
||||
purely a naming convention, but the entire ecosystem follows it, so you
|
||||
should too.
|
||||
- **`func (r *BookRepository) Create(...)`** — pointer receiver, per the
|
||||
refresher above. `r` is the repository itself; inside, `r.db` accesses
|
||||
the wrapped connection pool.
|
||||
- **`b.ID = int(id)`** — since `Create` takes `b *Book` (a pointer), it
|
||||
can write the newly generated ID directly back into the caller's
|
||||
struct. This is rule #1 from the refresher, applied for real.
|
||||
- **`QueryRowContext(...).Scan(...)`** — new: `QueryRowContext` (singular
|
||||
`Row`) is for when you expect exactly one result, like a lookup by ID.
|
||||
It skips the `rows.Next()`/`rows.Close()` dance from Lesson 3 since
|
||||
there's at most one row.
|
||||
- **`errors.Is(err, sql.ErrNoRows)`** — `sql.ErrNoRows` is the driver's
|
||||
own sentinel error for "query matched zero rows." We translate it into
|
||||
our own `ErrNotFound` so callers of `FindByID` don't need to know or
|
||||
care that the underlying storage is SQL at all — this is the sentinel
|
||||
error pattern from Go Basics Part 3, put to real use.
|
||||
- **`var ErrNotFound = errors.New(...)`** — a package-level sentinel error,
|
||||
so callers can check `errors.Is(err, ErrNotFound)` without caring what's
|
||||
underneath.
|
||||
|
||||
Try inserting a second book, querying it, then calling `FindByID` with an
|
||||
ID you know doesn't exist and confirm you get `ErrNotFound`, not a crash.
|
||||
|
||||
## Part B — apply it to the project
|
||||
|
||||
**`internal/models/user.go`** — the domain struct:
|
||||
```go
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Email string
|
||||
PasswordHash string
|
||||
GoogleID string // empty if the user registered with a password
|
||||
CreatedAt time.Time
|
||||
}
|
||||
```
|
||||
`PasswordHash`, not `Password` — we will **never** store or handle
|
||||
plaintext passwords beyond the brief moment they're hashed (Lesson 5).
|
||||
`GoogleID` is here now so Lesson 7 (Google OAuth) doesn't require
|
||||
restructuring this struct later.
|
||||
|
||||
**`internal/database/migrate.go`** — creates the table on startup (fine
|
||||
for a learning project; a real project would use a dedicated migration
|
||||
tool):
|
||||
```go
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL DEFAULT '',
|
||||
google_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate users table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**`internal/models/user_repository.go`** — same pattern as
|
||||
`BookRepository`:
|
||||
```go
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
type UserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, u *User) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"INSERT INTO users (email, password_hash, google_id, created_at) VALUES (?, ?, ?, ?)",
|
||||
u.Email, u.PasswordHash, u.GoogleID, time.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get last insert id: %w", err)
|
||||
}
|
||||
u.ID = int(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var u User
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE email = ?", email,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user by email: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
|
||||
var u User
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE id = ?", id,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user by id: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
```
|
||||
`FindByEmail` is used during login (users log in with an email, not an
|
||||
ID). `FindByID` is used later once sessions store just the user's ID
|
||||
(Lesson 6+).
|
||||
|
||||
**Update `cmd/api/main.go`** — run the migration and construct the
|
||||
repository on startup:
|
||||
```go
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
logger.Error("failed to migrate database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("database migrated")
|
||||
|
||||
userRepo := models.NewUserRepository(db)
|
||||
_ = userRepo // used starting Lesson 5 - silences "declared but not used" for now
|
||||
```
|
||||
(Add `"git.hamidsoltani.com/hamid/go-simple-api/internal/models"` to the
|
||||
import block.)
|
||||
|
||||
`_ = userRepo` — remember from Go Basics Part 1, the blank identifier
|
||||
`_` discards a value so the compiler doesn't complain about an unused
|
||||
variable. We're not wiring `userRepo` into any handler yet (that's
|
||||
Lesson 5), so this line is a temporary placeholder — delete it once
|
||||
`userRepo` is actually passed into `router.New(...)`.
|
||||
|
||||
## Try it
|
||||
|
||||
```bash
|
||||
go run ./cmd/api
|
||||
```
|
||||
Check your logs for `"database migrated"`, then confirm the table exists:
|
||||
```bash
|
||||
docker exec -it mysql-api mysql -uroot -pdevpass go_simple_api -e "DESCRIBE users;"
|
||||
```
|
||||
|
||||
Once both parts run, move to Lesson 5 — password-based register/login
|
||||
with bcrypt, which is where `userRepo` finally gets used for real.
|
||||
Reference in New Issue
Block a user