Part 2: Database & Models¶
In this part you'll define the data models for your polls app, write a SQL migration, and create a repository for database access.
Source code: tutorial/step02/
The Polls App¶
The polls app lives in its own package. Create the directories first:
All the code in this section — models, repository, and app setup — goes into internal/polls/polls.go. We'll split it into separate files as it grows.
Models¶
Start internal/polls/polls.go with the package declaration and two models:
package polls
import (
"context"
"embed"
"io/fs"
"time"
"github.com/oliverandrich/burrow"
"github.com/uptrace/bun"
)
type Question struct {
bun.BaseModel `bun:"table:questions,alias:q"`
ID int64 `bun:",pk,autoincrement"`
Text string `bun:",notnull"`
PublishedAt time.Time `bun:",notnull,default:current_timestamp"`
Choices []Choice `bun:"rel:has-many,join:id=question_id"`
}
type Choice struct {
bun.BaseModel `bun:"table:choices,alias:c"`
ID int64 `bun:",pk,autoincrement"`
QuestionID int64 `bun:",notnull"`
Text string `bun:",notnull"`
Votes int `bun:",notnull,default:0"`
Question *Question `bun:"rel:belongs-to,join:question_id=id"`
}
Key points:
bun.BaseModelwith thebun:"table:..."tag maps the struct to a database tablealias:qgives the table a short alias for use in queries (q.idinstead ofquestions.id)- Relations are declared with
rel:has-manyandrel:belongs-to— Bun uses these for eager loading
Migration¶
Create internal/polls/migrations/001_create_polls.up.sql:
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS choices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
votes INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_choices_question_id ON choices (question_id);
Burrow runs migrations automatically at startup for apps that implement Migratable. Migrations are tracked per-app in the _migrations table — each file runs exactly once.
Repository¶
Still in internal/polls/polls.go, add the repository below the models:
type Repository struct {
db *bun.DB
}
func NewRepository(db *bun.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) ListQuestions(ctx context.Context) ([]Question, error) {
var questions []Question
err := r.db.NewSelect().
Model(&questions).
Order("published_at DESC", "id DESC").
Scan(ctx)
return questions, err
}
func (r *Repository) GetQuestion(ctx context.Context, id int64) (*Question, error) {
question := new(Question)
err := r.db.NewSelect().
Model(question).
Relation("Choices").
Where("q.id = ?", id).
Scan(ctx)
if err != nil {
return nil, err
}
return question, nil
}
Note how Relation("Choices") eagerly loads all choices for a question in a single query.
App Setup¶
Still in internal/polls/polls.go, add the app struct and the embedded migration filesystem:
//go:embed migrations
var migrationFS embed.FS
type App struct {
repo *Repository
}
func New() *App { return &App{} }
func (a *App) Name() string { return "polls" }
func (a *App) Register(cfg *burrow.AppConfig) error {
a.repo = NewRepository(cfg.DB)
return nil
}
func (a *App) MigrationFS() fs.FS {
sub, _ := fs.Sub(migrationFS, "migrations")
return sub
}
The app implements two interfaces:
| Interface | Method | Purpose |
|---|---|---|
burrow.App |
Name(), Register() |
Required for all apps |
burrow.Migratable |
MigrationFS() |
Automatic database migrations |
Update main.go¶
Add the polls app to the server:
Run It¶
After adding new imports, always run go mod tidy to fetch dependencies:
When the server starts, you'll see a log line confirming the migration ran. The questions and choices tables now exist in your SQLite database.
There are no routes yet for the polls app — we'll add those with templates in the next part.
Next¶
In Part 3, you'll add HTML templates, a layout with Bootstrap styling, and views to list and display questions.