Initial commit
This commit is contained in:
commit
afed023152
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,68 @@
|
||||
// Package database owns the Bun connection and the migration registry.
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
"github.com/uptrace/bun/driver/pgdriver"
|
||||
"github.com/uptrace/bun/driver/sqliteshim"
|
||||
"github.com/uptrace/bun/extra/bundebug"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
"github.com/uptrace/bun/schema"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/config"
|
||||
migrations "git.k6n.net/mats/fullstack-template/internal/database/migrations"
|
||||
)
|
||||
|
||||
// Open connects to the database named by cfg.DatabaseURL:
|
||||
//
|
||||
// sqlite:FILE?params Bun + modernc sqlite (pure Go, no CGO) — dev default
|
||||
// postgres://… Bun + pgdriver against Postgres
|
||||
//
|
||||
// It does not ping — call PingContext during startup to fail fast.
|
||||
func Open(cfg config.Config) (*bun.DB, error) {
|
||||
var sqldb *sql.DB
|
||||
var dialect schema.Dialect
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(cfg.DatabaseURL, "sqlite:"):
|
||||
dsn := strings.TrimPrefix(cfg.DatabaseURL, "sqlite:")
|
||||
// Sensible dev defaults when the DSN brings no pragmas of its own:
|
||||
// WAL keeps readers unblocked during writes, busy_timeout waits out
|
||||
// the remaining write locks instead of erroring, foreign_keys is
|
||||
// what everyone expects to be on.
|
||||
if !strings.Contains(dsn, "_pragma") {
|
||||
sep := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
dsn += sep + "_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
}
|
||||
var err error
|
||||
sqldb, err = sql.Open(sqliteshim.ShimName, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", dsn, err)
|
||||
}
|
||||
dialect = sqlitedialect.New()
|
||||
default:
|
||||
sqldb = sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(cfg.DatabaseURL)))
|
||||
dialect = pgdialect.New()
|
||||
}
|
||||
|
||||
db := bun.NewDB(sqldb, dialect)
|
||||
if cfg.DebugQueries {
|
||||
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// NewMigrator returns a bun migrator over the migrations registered in
|
||||
// internal/database/migrations. See cmd/migrate for the CLI around it.
|
||||
func NewMigrator(db *bun.DB) *migrate.Migrator {
|
||||
return migrate.NewMigrator(db, migrations.Migrations)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/config"
|
||||
"git.k6n.net/mats/fullstack-template/internal/database"
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
// The whole persistence stack against an in-memory SQLite database:
|
||||
// migrations produce a working schema and the real BunRepository
|
||||
// round-trips items. This is also the cheapest way to catch dialect drift
|
||||
// between the model and the migrations — no Postgres container required.
|
||||
func TestSQLiteSchemaRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.Open(config.Config{DatabaseURL: "sqlite:file:mem?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
// One connection keeps the shared in-memory database stable across
|
||||
// the pool (and mirrors how single-writer sqlite behaves anyway).
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
migrator := database.NewMigrator(db)
|
||||
if err := migrator.Init(ctx); err != nil {
|
||||
t.Fatalf("migrator init: %v", err)
|
||||
}
|
||||
if _, err := migrator.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate up: %v", err)
|
||||
}
|
||||
|
||||
svc := items.NewService(items.NewBunRepository(db))
|
||||
|
||||
created, err := svc.Create(ctx, items.CreateInput{Title: "first", Description: "from sqlite"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.ID == 0 || created.Status != items.StatusActive {
|
||||
t.Fatalf("create returned %+v", created)
|
||||
}
|
||||
|
||||
got, err := svc.Get(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Title != "first" || got.CreatedAt.IsZero() {
|
||||
t.Fatalf("get returned %+v", got)
|
||||
}
|
||||
|
||||
archived, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: ptr(items.StatusArchived)})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if archived.Status != items.StatusArchived {
|
||||
t.Fatalf("update returned %+v", archived)
|
||||
}
|
||||
|
||||
listed, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(listed) != 1 {
|
||||
t.Fatalf("list archived len = %d, want 1", len(listed))
|
||||
}
|
||||
|
||||
if err := svc.Delete(ctx, created.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := svc.Get(ctx, created.ID); !errors.Is(err, items.ErrNotFound) {
|
||||
t.Fatalf("get after delete: got %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// The down migration must undo the up migration cleanly.
|
||||
if _, err := migrator.Rollback(ctx); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,31 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.NewCreateTable().
|
||||
Model((*items.Item)(nil)).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.NewCreateIndex().
|
||||
Model((*items.Item)(nil)).
|
||||
IfNotExists().
|
||||
Index("items_status_idx").
|
||||
Column("status").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}, func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.NewDropTable().Model((*items.Item)(nil)).IfExists().Exec(ctx)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package migrations holds the schema migrations: one Go file per
|
||||
// migration, named <YYYYMMDDHHMMSS>_<snake_name>.go, each registering
|
||||
// itself into Migrations via init(). `make migrate-new` scaffolds the next
|
||||
// one with a fresh timestamp.
|
||||
//
|
||||
// Prefer model-driven DDL (db.NewCreateTable().Model((*pkg.Thing)(nil))) —
|
||||
// bun emits dialect-correct SQL for both sqlite and postgres, so migrations
|
||||
// stay portable. Reach for db.Exec with dialect-specific SQL only when a
|
||||
// model can't express it.
|
||||
package migrations
|
||||
|
||||
import "github.com/uptrace/bun/migrate"
|
||||
|
||||
// Migrations is the registry consumed by internal/database.NewMigrator.
|
||||
// The directory option tells `migrate new` where to write (from backend/).
|
||||
var Migrations = migrate.NewMigrations(
|
||||
migrate.WithMigrationsDirectory("internal/database/migrations"),
|
||||
)
|
||||
Reference in new issue
Block a user