Guide 2 of 12
Models and migrations
Rails's Post is one class that is a table, a type and a query builder. Here that is three files, each with one job, plus the migration and a test. One command writes all five:
bogie g model post title:string body:text
create db/migrate/20261003053152_create_posts.sql
create db/queries/posts.sql
create app/domain/post.go
create app/models/posts.go
create app/models/posts_test.go
run sqlc generate
The migration
goose SQL, timestamped, embedded in the binary:
-- +goose Up
CREATE TABLE posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL DEFAULT '',
body text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE posts;
Every generated column is NOT NULL with a type-appropriate default, so sqlc emits plain Go types rather than pointers. A column that truly needs null is one edit to the migration, by hand.
The queries
db/queries/posts.sql, named statements that sqlc compiles:
-- name: GetPost :one
SELECT * FROM posts WHERE id = $1;
-- name: ListPosts :many
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1;
-- name: CreatePost :one
INSERT INTO posts (title, body) VALUES ($1, $2) RETURNING *;
-- name: UpdatePost :one
UPDATE posts SET title = $2, body = $3, updated_at = now() WHERE id = $1 RETURNING *;
-- name: DeletePost :execrows
DELETE FROM posts WHERE id = $1;
A query that names a column which does not exist fails at make sqlc, not in production.
The domain type
app/domain/post.go imports nothing third-party (rule 3). Ids are strings here; the store turns them into uuids:
type Post struct {
ID string
Title string
Body string
CreatedAt time.Time
UpdatedAt time.Time
}
// Validate is the model's validations, as Rails would put them. Wrap every
// failure in ErrInvalid so a controller can answer 422 without knowing the
// rule:
//
// return fmt.Errorf("%w: title is required", ErrInvalid)
func (p Post) Validate() error {
return nil
}
Validate starts empty; fill it in as Rails's validates would. It runs on both create and update, inside the store, so every caller gets it and none can forget it.
The store
app/models/posts.go converts between the domain type and sqlc's row type, maps "no rows" to domain.ErrNotFound, and runs Validate before writing:
func (s *Store) CreatePost(ctx context.Context, p domain.Post) (domain.Post, error) {
if err := p.Validate(); err != nil {
return domain.Post{}, err
}
row, err := s.q.CreatePost(ctx, CreatePostParams{Title: p.Title, Body: p.Body})
if err != nil {
return domain.Post{}, fmt.Errorf("models: create post: %w", err)
}
return postFromRow(row), nil
}
GetPost, ListPosts, UpdatePost and DeletePost follow the same shape. app/models/posts_test.go starts with one test, that an unknown id is ErrNotFound whatever it looks like, and a TODO for the round trip. Store tests run against the real database, so they need it up: make up first.
Attribute types
string text integer bigint boolean datetime uuid jsonb references, each optionally followed by :index or :uniq:
bogie g model comment body:text post:references score:integer
post:references writes the column post_id as uuid NOT NULL REFERENCES posts (id), with its own index. slug:string:uniq adds a unique constraint.
Changing a table
bogie g migration add_slug_to_posts slug:string:uniq
bogie g migration remove_draft_from_posts draft:boolean
Three migration name shapes get their SQL written automatically; any other name gets an empty Up and Down to fill in by hand:
create_<table>: the table and its columns.add_<x>_to_<table>:ALTER TABLE ... ADD COLUMNfor each attribute, reversed on the way down.remove_<x>_from_<table>:ALTER TABLE ... DROP COLUMN, with the Down able to re-add it.
-- +goose Up
ALTER TABLE posts ADD COLUMN slug text NOT NULL DEFAULT '' UNIQUE;
-- +goose Down
ALTER TABLE posts DROP COLUMN slug;
The migration generator runs sqlc immediately too, so app/models/*.sql.go changes before the migration is ever applied, and a renamed column shows up as a compile error right away.
Running them
bogie db:migrate
bogie db:rollback
bogie db:migrate:status
bogie db:reset
bogie db:seed
bogie db:version
All through the app's own binary, never the goose CLI directly, so development runs exactly what a deployed container runs. db:reset drops the database and rebuilds it from the migrations; there is no schema.rb to load instead. A seed is authored the same way a migration is, with its own make target:
make g_seed_add_demo_posts
which writes a timestamped file under db/seeds, versioned in its own table, applied by bogie db:seed.
Taking it out
bogie d model post
Removes the migration, the queries, the domain type and the store, the same way bogie g model wrote them.