Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// Package database owns the sqlite connection and the schema. The
|
||||
// schema is embedded in the binary, so a deployment is one file: there
|
||||
// is no migrations directory to ship alongside it and no version of it
|
||||
// that can be out of step with the code that expects it.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||
|
||||
// modernc.org/sqlite is the pure-Go driver: no cgo, so the binary
|
||||
// links statically and the container needs no libc.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// schemaFS carries the migrations into the binary.
|
||||
//
|
||||
//go:embed schema/*.sql
|
||||
var schemaFS embed.FS
|
||||
|
||||
// dirPerm is the mode for the data directory: owner-only, because it
|
||||
// holds the database.
|
||||
const dirPerm = 0o700
|
||||
|
||||
// pragmas are applied to every connection. WAL is what makes concurrent
|
||||
// reads not block on a write; busy_timeout is what turns the remaining
|
||||
// contention into a short wait rather than an immediate SQLITE_BUSY;
|
||||
// foreign_keys is off by default in sqlite and has to be asked for.
|
||||
const pragmas = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
`
|
||||
|
||||
// Params defines dependencies for Database.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Database is the handle to the application's sqlite database.
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New opens the database, applies the embedded migrations, and
|
||||
// registers a close hook. Migrations run during OnStart rather than
|
||||
// lazily on first use: a schema that cannot be applied is a failure to
|
||||
// start, and the process says so before it accepts a request.
|
||||
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
||||
d := &Database{log: params.Logger.Get()}
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(params.Config.DBPath), dirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
|
||||
// New runs during graph construction, which has no request or
|
||||
// lifecycle context of its own; the pragmas are a handful of
|
||||
// in-process statements against a file that was just created.
|
||||
db, err := Open(context.Background(), params.Config.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.db = db
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
return d.Migrate(ctx)
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
d.log.Info("closing database")
|
||||
|
||||
closeErr := d.db.Close()
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("closing database: %w", closeErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Open opens a sqlite database at path and applies the connection
|
||||
// pragmas. Exported so tests can open a scratch database without the
|
||||
// fx graph.
|
||||
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database %s: %w", path, err)
|
||||
}
|
||||
|
||||
// sqlite tolerates exactly one writer. Holding the pool to a
|
||||
// single connection makes that limit explicit here rather than
|
||||
// intermittent under load, and WAL keeps readers off the writer's
|
||||
// back anyway.
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
_, err = db.ExecContext(ctx, pragmas)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
|
||||
return nil, fmt.Errorf("applying pragmas: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// NewForTest opens a scratch database in dir and migrates it. Test
|
||||
// helper, exported so that a project seeded from this template can use
|
||||
// it from any package's tests.
|
||||
func NewForTest(ctx context.Context, dir string) (*Database, error) {
|
||||
d := &Database{log: slog.New(slog.DiscardHandler)}
|
||||
|
||||
db, err := Open(ctx, filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.db = db
|
||||
|
||||
err = d.Migrate(ctx)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Migrate applies every embedded migration that has not been applied to
|
||||
// this database yet.
|
||||
func (d *Database) Migrate(ctx context.Context) error {
|
||||
set := migrationSet{fsys: schemaFS, dir: "schema"}
|
||||
|
||||
err := set.apply(ctx, d.db, d.log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DB exposes the underlying handle for packages that need to query it.
|
||||
func (d *Database) DB() *sql.DB {
|
||||
return d.db
|
||||
}
|
||||
|
||||
// AppliedVersions returns the migration versions recorded as applied,
|
||||
// ascending. The healthcheck reports the highest of them, so an
|
||||
// operator can see which schema a running instance is on without
|
||||
// shelling into it.
|
||||
func (d *Database) AppliedVersions(ctx context.Context) ([]int, error) {
|
||||
rows, err := d.db.QueryContext(ctx,
|
||||
"SELECT version FROM schema_migrations ORDER BY version",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading applied migrations: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var versions []int
|
||||
|
||||
for rows.Next() {
|
||||
var v int
|
||||
|
||||
scanErr := rows.Scan(&v)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scanning migration version: %w", scanErr)
|
||||
}
|
||||
|
||||
versions = append(versions, v)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iterating applied migrations: %w", err)
|
||||
}
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// Close releases the handle. Production uses the fx OnStop hook; tests
|
||||
// call this.
|
||||
func (d *Database) Close() error {
|
||||
err := d.db.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("closing database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/database"
|
||||
)
|
||||
|
||||
// open returns a migrated scratch database in a directory the test
|
||||
// framework removes afterwards.
|
||||
func open(t *testing.T) *database.Database {
|
||||
t.Helper()
|
||||
|
||||
db, err := database.NewForTest(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("opening test database: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
closeErr := db.Close()
|
||||
if closeErr != nil {
|
||||
t.Errorf("closing test database: %v", closeErr)
|
||||
}
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// TestMigrationsApplyFromClean is the claim the healthcheck and the
|
||||
// container both rest on: an empty directory becomes a usable schema
|
||||
// with no operator step in between.
|
||||
func TestMigrationsApplyFromClean(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := open(t)
|
||||
|
||||
versions, err := db.AppliedVersions(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("reading applied versions: %v", err)
|
||||
}
|
||||
|
||||
// 000 (the ledger) and 001 (widgets), which is every file the
|
||||
// schema directory currently embeds.
|
||||
if len(versions) != 2 || versions[0] != 0 || versions[1] != 1 {
|
||||
t.Fatalf("applied versions = %v, want [0 1]", versions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrationsAreIdempotent: a restart re-runs Migrate against a
|
||||
// database that already has the schema, and must change nothing. A
|
||||
// migration runner that fails here takes the service down on every
|
||||
// second start.
|
||||
func TestMigrationsAreIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
ctx := t.Context()
|
||||
|
||||
first, err := database.NewForTest(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("first open: %v", err)
|
||||
}
|
||||
|
||||
_, err = first.CreateWidget(ctx, "survivor", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("creating widget: %v", err)
|
||||
}
|
||||
|
||||
err = first.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("closing: %v", err)
|
||||
}
|
||||
|
||||
second, err := database.NewForTest(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopening and re-migrating: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = second.Close() }()
|
||||
|
||||
versions, err := second.AppliedVersions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("reading applied versions: %v", err)
|
||||
}
|
||||
|
||||
if len(versions) != 2 {
|
||||
t.Errorf("re-running migrations changed the ledger: %v", versions)
|
||||
}
|
||||
|
||||
// The data has to still be there: a migration runner that "fixes"
|
||||
// an already-migrated database by recreating tables is worse than
|
||||
// one that fails.
|
||||
count, err := second.CountWidgets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("counting: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("widget count = %d after reopen, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWidgetRoundTrip exercises the query layer against the real
|
||||
// schema, including the timestamp format shared between Go and the SQL
|
||||
// DEFAULT.
|
||||
func TestWidgetRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := open(t)
|
||||
ctx := t.Context()
|
||||
|
||||
created, err := db.CreateWidget(ctx, "widget one", 4096)
|
||||
if err != nil {
|
||||
t.Fatalf("creating widget: %v", err)
|
||||
}
|
||||
|
||||
if created.ID == "" {
|
||||
t.Error("created widget has no id")
|
||||
}
|
||||
|
||||
widgets, err := db.ListWidgets(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("listing widgets: %v", err)
|
||||
}
|
||||
|
||||
if len(widgets) != 1 {
|
||||
t.Fatalf("listed %d widgets, want 1", len(widgets))
|
||||
}
|
||||
|
||||
got := widgets[0]
|
||||
if got.ID != created.ID || got.Name != "widget one" || got.SizeBytes != 4096 {
|
||||
t.Errorf("round trip lost data: %+v", got)
|
||||
}
|
||||
|
||||
if got.CreatedAt.IsZero() {
|
||||
t.Error("created_at did not survive the round trip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListWidgetsRespectsLimit: the index query is bounded, and the
|
||||
// bound has to actually bind.
|
||||
func TestListWidgetsRespectsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := open(t)
|
||||
ctx := t.Context()
|
||||
|
||||
for range 5 {
|
||||
_, err := db.CreateWidget(ctx, "w", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("creating widget: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
widgets, err := db.ListWidgets(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("listing widgets: %v", err)
|
||||
}
|
||||
|
||||
if len(widgets) != 2 {
|
||||
t.Errorf("limit 2 returned %d rows", len(widgets))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMigrationVersion covers the naming contract the schema
|
||||
// directory has to keep. A file this rejects is a file that would
|
||||
// otherwise be silently skipped.
|
||||
func TestParseMigrationVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
good := map[string]int{
|
||||
"000.sql": 0,
|
||||
"001_widgets.sql": 1,
|
||||
"017_thing.sql": 17,
|
||||
}
|
||||
|
||||
for name, want := range good {
|
||||
got, err := database.ParseMigrationVersion(name)
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error %v", name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("%s: version = %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{"widgets.sql", "_001.sql", "v1_widgets.sql"} {
|
||||
_, err := database.ParseMigrationVersion(name)
|
||||
if err == nil {
|
||||
t.Errorf("%s: wanted a rejection, got none", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenCreatesFile: Open must produce a database at the path it was
|
||||
// given, not somewhere else.
|
||||
func TestOpenCreatesFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
db, err := database.Open(t.Context(), filepath.Join(dir, "explicit.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("opening: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
err = db.PingContext(t.Context())
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("pinging the opened database: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// bootstrapVersion is 000.sql: the migration that creates the ledger
|
||||
// the others are recorded in.
|
||||
const bootstrapVersion = 0
|
||||
|
||||
// errBadMigrationName is returned for a schema file whose name does not
|
||||
// start with a version number. It is a build-time mistake, not a
|
||||
// runtime condition, and it fails startup rather than being skipped —
|
||||
// a migration silently not applied is the failure mode this whole
|
||||
// mechanism exists to prevent.
|
||||
var errBadMigrationName = errors.New(
|
||||
"migration filename does not start with a version number",
|
||||
)
|
||||
|
||||
// ParseMigrationVersion extracts the leading integer from a migration
|
||||
// filename: "001_widgets.sql" is version 1. Exported so that a project
|
||||
// seeded from this template can validate its own schema directory in a
|
||||
// test.
|
||||
func ParseMigrationVersion(name string) (int, error) {
|
||||
base := name
|
||||
if i := strings.IndexAny(base, "_."); i > 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
|
||||
version, err := strconv.Atoi(base)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %q", errBadMigrationName, name)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// migrationSet is one embedded directory of numbered .sql migrations
|
||||
// (000 bootstrap plus schema files).
|
||||
type migrationSet struct {
|
||||
fsys fs.FS
|
||||
dir string
|
||||
}
|
||||
|
||||
// collect returns the set's migration filenames sorted
|
||||
// lexicographically, which is why they are zero-padded.
|
||||
func (m migrationSet) collect() ([]string, error) {
|
||||
entries, err := fs.ReadDir(m.fsys, m.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
||||
}
|
||||
|
||||
var migrations []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||
migrations = append(migrations, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(migrations)
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// bootstrap ensures the schema_migrations table exists by applying
|
||||
// 000.sql if the table is missing.
|
||||
func (m migrationSet) bootstrap(
|
||||
ctx context.Context, db *sql.DB, log *slog.Logger,
|
||||
) error {
|
||||
var tableExists int
|
||||
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||
).Scan(&tableExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||
}
|
||||
|
||||
if tableExists > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := fs.ReadFile(m.fsys, path.Join(m.dir, "000.sql"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applied reports whether the numbered migration has been recorded.
|
||||
func (m migrationSet) applied(
|
||||
ctx context.Context, db *sql.DB, version int,
|
||||
) (bool, error) {
|
||||
var count int
|
||||
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||
version,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check migration status: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// applyOne reads, executes, and records one migration file.
|
||||
func (m migrationSet) applyOne(
|
||||
ctx context.Context, db *sql.DB, migration string, version int,
|
||||
) error {
|
||||
content, err := fs.ReadFile(m.fsys, path.Join(m.dir, migration))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", migration, err)
|
||||
}
|
||||
|
||||
_, execErr := db.ExecContext(ctx, string(content))
|
||||
if execErr != nil {
|
||||
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
|
||||
}
|
||||
|
||||
_, recErr := db.ExecContext(ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||
version,
|
||||
)
|
||||
if recErr != nil {
|
||||
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply runs all pending migrations of the set, in order. Idempotent:
|
||||
// a second run over the same database applies nothing.
|
||||
func (m migrationSet) apply(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
err := m.bootstrap(ctx, db, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrations, err := m.collect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
version, parseErr := ParseMigrationVersion(migration)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
done, checkErr := m.applied(ctx, db, version)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
|
||||
if done {
|
||||
if log != nil {
|
||||
log.Debug("migration already applied", "version", version)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Info("applying migration", "version", version)
|
||||
}
|
||||
|
||||
applyErr := m.applyOne(ctx, db, migration, version)
|
||||
if applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Info("migration applied successfully", "version", version)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// Go has no UUID in the standard library as of go1.25 — checked
|
||||
// against this repo's toolchain, not assumed. Swap this import for
|
||||
// the stdlib package the moment one lands; nothing else here
|
||||
// depends on the implementation.
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// timeFormat matches the strftime pattern the schema uses for its
|
||||
// defaults, so rows written by Go and rows written by a DEFAULT sort
|
||||
// against each other correctly.
|
||||
const timeFormat = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// Widget is the example row type. It exists so that the migration
|
||||
// runner, the query layer, the templates and the tests all exercise
|
||||
// real data. Delete it when seeding a real project.
|
||||
type Widget struct {
|
||||
ID string
|
||||
Name string
|
||||
SizeBytes int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateWidget inserts a widget and returns it as stored.
|
||||
func (d *Database) CreateWidget(
|
||||
ctx context.Context, name string, size int64,
|
||||
) (*Widget, error) {
|
||||
w := &Widget{
|
||||
ID: uuid.NewString(),
|
||||
Name: name,
|
||||
SizeBytes: size,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
`INSERT INTO widgets (id, name, size_bytes, created_at) VALUES (?, ?, ?, ?)`,
|
||||
w.ID, w.Name, w.SizeBytes, w.CreatedAt.Format(timeFormat),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inserting widget: %w", err)
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// ListWidgets returns the most recently created widgets, newest first,
|
||||
// up to limit.
|
||||
func (d *Database) ListWidgets(ctx context.Context, limit int) ([]Widget, error) {
|
||||
rows, err := d.db.QueryContext(ctx,
|
||||
`SELECT id, name, size_bytes, created_at
|
||||
FROM widgets
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?`,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing widgets: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
widgets := []Widget{}
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
w Widget
|
||||
createdAt string
|
||||
)
|
||||
|
||||
scanErr := rows.Scan(&w.ID, &w.Name, &w.SizeBytes, &createdAt)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scanning widget: %w", scanErr)
|
||||
}
|
||||
|
||||
w.CreatedAt, scanErr = time.Parse(timeFormat, createdAt)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("parsing widget created_at %q: %w", createdAt, scanErr)
|
||||
}
|
||||
|
||||
widgets = append(widgets, w)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iterating widgets: %w", err)
|
||||
}
|
||||
|
||||
return widgets, nil
|
||||
}
|
||||
|
||||
// CountWidgets returns the number of widgets stored.
|
||||
func (d *Database) CountWidgets(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
|
||||
err := d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM widgets`).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("counting widgets: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 000.sql: the bootstrap migration. It creates only the ledger that
|
||||
-- records which migrations have run; every other migration is recorded
|
||||
-- in it. Applied when the schema_migrations table is missing, and never
|
||||
-- again.
|
||||
--
|
||||
-- Never edit an applied migration. Add a new numbered file instead: the
|
||||
-- ledger records versions, not contents, so an edited file is applied
|
||||
-- nowhere and diverges everywhere.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 001_widgets.sql: the example table. Delete it when seeding a real
|
||||
-- project and start your own schema at 001 — nothing has been deployed
|
||||
-- yet, so there is no ledger anywhere that would disagree.
|
||||
--
|
||||
-- It is here so that the template's migration runner, model layer and
|
||||
-- tests all exercise a real table rather than an empty database.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS widgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS widgets_created_at ON widgets (created_at);
|
||||
Reference in New Issue
Block a user