Feature 02 task 2: Store helpers: transactions, ULIDs and time conventions #11
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/oklog/ulid/v2 v2.1.2
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
|
||||
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
idMu sync.Mutex
|
||||
idEntropy = ulid.Monotonic(rand.Reader, 0)
|
||||
idLastMS uint64
|
||||
)
|
||||
|
||||
// NewID returns a new ULID as its canonical 26-character string. IDs from
|
||||
// this process sort strictly in creation order, including within one
|
||||
// millisecond and across a backwards wall-clock step. It panics if the
|
||||
// monotonic entropy overflows within a millisecond.
|
||||
func NewID() string {
|
||||
idMu.Lock()
|
||||
defer idMu.Unlock()
|
||||
ms := max(ulid.Timestamp(time.Now()), idLastMS)
|
||||
idLastMS = ms
|
||||
return ulid.MustNew(ms, idEntropy).String()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
func TestNewIDStrictlyIncreasing(t *testing.T) {
|
||||
const n = 10000
|
||||
prev := NewID()
|
||||
for range n {
|
||||
id := NewID()
|
||||
if len(id) != ulid.EncodedSize {
|
||||
t.Fatalf("NewID() = %q, want %d characters", id, ulid.EncodedSize)
|
||||
}
|
||||
if _, err := ulid.ParseStrict(id); err != nil {
|
||||
t.Fatalf("NewID() = %q: %v", id, err)
|
||||
}
|
||||
if id <= prev {
|
||||
t.Fatalf("NewID() = %q, not after previous %q", id, prev)
|
||||
}
|
||||
prev = id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// timestampLayout is RFC 3339 in UTC with fixed millisecond precision, so
|
||||
// stored timestamps sort lexicographically in time order.
|
||||
const timestampLayout = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// Timestamp is an instant stored as UTC RFC 3339 TEXT with millisecond
|
||||
// precision. Sub-millisecond precision is dropped on write. Use
|
||||
// sql.Null[Timestamp] for a nullable column.
|
||||
type Timestamp struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// Now returns the current instant as a Timestamp, in UTC at millisecond
|
||||
// precision, so it equals its stored form.
|
||||
func Now() Timestamp {
|
||||
return Timestamp{time.Now().UTC().Truncate(time.Millisecond)}
|
||||
}
|
||||
|
||||
// String returns the stored TEXT form.
|
||||
func (t Timestamp) String() string {
|
||||
return t.UTC().Format(timestampLayout)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero Timestamp is an error.
|
||||
func (t Timestamp) Value() (driver.Value, error) {
|
||||
if t.IsZero() {
|
||||
return nil, errors.New("store: zero Timestamp")
|
||||
}
|
||||
return t.String(), nil
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner. It accepts only TEXT in the stored form.
|
||||
func (t *Timestamp) Scan(src any) error {
|
||||
s, ok := src.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("store: scan Timestamp: want TEXT, got %T", src)
|
||||
}
|
||||
parsed, err := time.Parse(timestampLayout, s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: scan Timestamp %q: %w", s, err)
|
||||
}
|
||||
t.Time = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// localDateLayout is the household-local calendar date form.
|
||||
const localDateLayout = "2006-01-02"
|
||||
|
||||
// LocalDate is a household-local calendar date with no time or zone, stored
|
||||
// as YYYY-MM-DD TEXT. The zero LocalDate is invalid and cannot be stored.
|
||||
type LocalDate struct {
|
||||
year int
|
||||
month time.Month
|
||||
day int
|
||||
}
|
||||
|
||||
// ParseLocalDate parses a YYYY-MM-DD date, rejecting any other form and any
|
||||
// date that does not exist on the calendar.
|
||||
func ParseLocalDate(s string) (LocalDate, error) {
|
||||
t, err := time.Parse(localDateLayout, s)
|
||||
if err != nil {
|
||||
return LocalDate{}, fmt.Errorf("store: invalid date %q: %w", s, err)
|
||||
}
|
||||
if t.Format(localDateLayout) != s {
|
||||
return LocalDate{}, fmt.Errorf("store: invalid date %q: want YYYY-MM-DD", s)
|
||||
}
|
||||
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
|
||||
}
|
||||
|
||||
// String returns the YYYY-MM-DD form.
|
||||
func (d LocalDate) String() string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
|
||||
}
|
||||
|
||||
// IsZero reports whether d is the zero LocalDate.
|
||||
func (d LocalDate) IsZero() bool {
|
||||
return d == LocalDate{}
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error.
|
||||
func (d LocalDate) Value() (driver.Value, error) {
|
||||
if d.IsZero() {
|
||||
return nil, errors.New("store: zero LocalDate")
|
||||
}
|
||||
return d.String(), nil
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner. It accepts only YYYY-MM-DD TEXT.
|
||||
func (d *LocalDate) Scan(src any) error {
|
||||
s, ok := src.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("store: scan LocalDate: want TEXT, got %T", src)
|
||||
}
|
||||
parsed, err := ParseLocalDate(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: scan LocalDate: %w", err)
|
||||
}
|
||||
*d = parsed
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTypesTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Writer.Exec("CREATE TABLE v (val TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// roundTrip writes in through the writer, then reads it back into out and
|
||||
// returns the stored SQLite type and raw text.
|
||||
func roundTrip(t *testing.T, s *Store, in, out any) (typ, raw string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := s.Writer.ExecContext(ctx, "DELETE FROM v"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Writer.ExecContext(ctx, "INSERT INTO v (val) VALUES (?)", in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Reader.QueryRowContext(ctx, "SELECT typeof(val), val, val FROM v").Scan(&typ, &raw, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return typ, raw
|
||||
}
|
||||
|
||||
func TestTimestampRoundTrip(t *testing.T) {
|
||||
s := openTypesTestStore(t)
|
||||
zone := time.FixedZone("EDT", -4*60*60)
|
||||
in := Timestamp{time.Date(2026, 10, 3, 8, 37, 5, 123456789, zone)}
|
||||
|
||||
var out Timestamp
|
||||
typ, raw := roundTrip(t, s, in, &out)
|
||||
if typ != "text" || raw != "2026-10-03T12:37:05.123Z" {
|
||||
t.Fatalf("stored %s %q, want text %q", typ, raw, "2026-10-03T12:37:05.123Z")
|
||||
}
|
||||
if want := in.Truncate(time.Millisecond); !out.Equal(want) {
|
||||
t.Fatalf("read back %v, want %v", out, want)
|
||||
}
|
||||
if out.Location() != time.UTC {
|
||||
t.Fatalf("read back location %v, want UTC", out.Location())
|
||||
}
|
||||
|
||||
now := Now()
|
||||
roundTrip(t, s, now, &out)
|
||||
if out != now {
|
||||
t.Fatalf("Now() round trip = %#v, want %#v", out, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampRejects(t *testing.T) {
|
||||
if _, err := (Timestamp{}).Value(); err == nil {
|
||||
t.Error("zero Timestamp Value succeeded")
|
||||
}
|
||||
for _, src := range []any{nil, int64(0), []byte("2026-10-03T12:37:05.123Z"), "2026-10-03T12:37:05Z", "2026-10-03T08:37:05.123-04:00", "garbage"} {
|
||||
var ts Timestamp
|
||||
if err := ts.Scan(src); err == nil {
|
||||
t.Errorf("Scan(%#v) succeeded", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRoundTrip(t *testing.T) {
|
||||
s := openTypesTestStore(t)
|
||||
for _, str := range []string{"2026-10-03", "2028-02-29", "0001-01-01", "9999-12-31"} {
|
||||
in, err := ParseLocalDate(str)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLocalDate(%q): %v", str, err)
|
||||
}
|
||||
if in.String() != str {
|
||||
t.Fatalf("String() = %q, want %q", in.String(), str)
|
||||
}
|
||||
var out LocalDate
|
||||
typ, raw := roundTrip(t, s, in, &out)
|
||||
if typ != "text" || raw != str {
|
||||
t.Fatalf("stored %s %q, want text %q", typ, raw, str)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("read back %v, want %v", out, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
for _, str := range []string{
|
||||
"", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00",
|
||||
"2026-10-32", "2026-1-03", "2026-10-3", "26-10-03", "2026/10/03",
|
||||
"2026-10-03T00:00:00Z", " 2026-10-03", "20261003", "not-a-date",
|
||||
} {
|
||||
if d, err := ParseLocalDate(str); err == nil {
|
||||
t.Errorf("ParseLocalDate(%q) = %v, want error", str, d)
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(str); err == nil {
|
||||
t.Errorf("Scan(%q) succeeded", str)
|
||||
}
|
||||
}
|
||||
if _, err := (LocalDate{}).Value(); err == nil {
|
||||
t.Error("zero LocalDate Value succeeded")
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(nil); err == nil {
|
||||
t.Error("Scan(nil) succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// WithTx runs fn in a transaction on the writer. It commits when fn returns
|
||||
// nil and rolls back when fn returns an error, panics or exits the goroutine.
|
||||
// A panic is not recovered: it propagates unchanged after the rollback.
|
||||
func (s *Store) WithTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := s.Writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin transaction: %w", err)
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
_ = tx.Rollback() // fn panicked; the panic carries on regardless
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
finished = true
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("store: roll back transaction: %w", rbErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
finished = true
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("store: commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openTxTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, s *Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := s.Reader.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func insertRow(tx *sql.Tx) error {
|
||||
_, err := tx.Exec("INSERT INTO t (id) VALUES (1)")
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWithTxCommitsOnNil(t *testing.T) {
|
||||
s := openTxTestStore(t)
|
||||
if err := s.WithTx(context.Background(), insertRow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countRows(t, s); n != 1 {
|
||||
t.Fatalf("rows after commit = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTxRollsBackOnError(t *testing.T) {
|
||||
s := openTxTestStore(t)
|
||||
errBoom := errors.New("boom")
|
||||
err := s.WithTx(context.Background(), func(tx *sql.Tx) error {
|
||||
if err := insertRow(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return errBoom
|
||||
})
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Fatalf("WithTx error = %v, want %v", err, errBoom)
|
||||
}
|
||||
if n := countRows(t, s); n != 0 {
|
||||
t.Fatalf("rows after rollback = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTxRollsBackOnPanic(t *testing.T) {
|
||||
s := openTxTestStore(t)
|
||||
const value = "kaboom"
|
||||
func() {
|
||||
defer func() {
|
||||
if p := recover(); p != value {
|
||||
t.Fatalf("recovered %v, want panic %q to propagate", p, value)
|
||||
}
|
||||
}()
|
||||
_ = s.WithTx(context.Background(), func(tx *sql.Tx) error {
|
||||
if err := insertRow(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panic(value)
|
||||
})
|
||||
t.Fatal("WithTx returned instead of panicking")
|
||||
}()
|
||||
if n := countRows(t, s); n != 0 {
|
||||
t.Fatalf("rows after panic = %d, want 0", n)
|
||||
}
|
||||
// The single writer connection must have been released.
|
||||
if err := s.WithTx(context.Background(), insertRow); err != nil {
|
||||
t.Fatalf("WithTx after panic: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user