Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 72 additions & 6 deletions internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package db

import (
"context"
"database/sql"
"embed"
"errors"
Expand All @@ -13,6 +14,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"

Expand Down Expand Up @@ -66,8 +68,63 @@ func Open(dbPath string) (*sql.DB, error) {
return db, nil
}

// OpenMemory creates an in-memory database for testing.
// OpenMemory creates an in-memory database for testing. Each call returns an
// independent database holding the fully migrated schema.
//
// Running all migrations through modernc's transpiled SQLite costs ~2.5s per
// call under -race, which is paid by hundreds of tests. So the first call
// migrates a template once per process and every call after deserializes a
// copy of it, which is byte-for-byte the state migrate would have produced.
func OpenMemory() (*sql.DB, error) {
tmplOnce.Do(func() { tmpl, tmplErr = memoryTemplate() })
if tmplErr != nil {
return nil, tmplErr
}
db, err := openMemoryConn()
if err != nil {
return nil, err
}
if err := withRawConn(db, func(c memConn) error { return c.Deserialize(tmpl) }); err != nil {
db.Close()
return nil, fmt.Errorf("load memory template: %w", err)
}
return db, nil
}

var (
tmplOnce sync.Once
tmpl []byte
tmplErr error
)

// memConn is the part of modernc.org/sqlite's driver connection OpenMemory
// uses to copy the template.
type memConn interface {
Serialize() ([]byte, error)
Deserialize([]byte) error
}

func memoryTemplate() ([]byte, error) {
db, err := openMemoryConn()
if err != nil {
return nil, err
}
defer db.Close()
if err := migrate(db); err != nil {
return nil, fmt.Errorf("run migrations: %w", err)
}
var buf []byte
err = withRawConn(db, func(c memConn) (err error) {
buf, err = c.Serialize()
return err
})
if err != nil {
return nil, fmt.Errorf("serialize memory template: %w", err)
}
return buf, nil
}

func openMemoryConn() (*sql.DB, error) {
db, err := sql.Open("sqlite", ":memory:"+connectionPragmaDSN)
if err != nil {
return nil, fmt.Errorf("open memory database: %w", err)
Expand All @@ -83,13 +140,22 @@ func OpenMemory() (*sql.DB, error) {
db.Close()
return nil, err
}
return db, nil
}

if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("run migrations: %w", err)
func withRawConn(db *sql.DB, fn func(memConn) error) error {
conn, err := db.Conn(context.Background())
if err != nil {
return err
}

return db, nil
defer conn.Close()
return conn.Raw(func(dc any) error {
c, ok := dc.(memConn)
if !ok {
return fmt.Errorf("sqlite driver conn %T cannot serialize", dc)
}
return fn(c)
})
}

// preflight validates that the directory containing dbPath exists, is a
Expand Down
113 changes: 113 additions & 0 deletions internal/db/openmemory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package db

import (
"database/sql"
"database/sql/driver"
"errors"
"strings"
"testing"
)

// OpenMemory hands out copies of one migrated template; a write to one copy
// must not show up in the next, and per-connection pragmas must survive the
// copy.
func TestOpenMemory_CopiesAreIndependent(t *testing.T) {
a, err := OpenMemory()
if err != nil {
t.Fatal(err)
}
defer a.Close()
if _, err := a.Exec(`INSERT INTO settings (key, value) VALUES ('openmemory_probe', 'x')`); err != nil {
t.Fatal(err)
}

b, err := OpenMemory()
if err != nil {
t.Fatal(err)
}
defer b.Close()
var n int
if err := b.QueryRow(`SELECT COUNT(*) FROM settings WHERE key = 'openmemory_probe'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("row written to one OpenMemory database leaked into another")
}
var fk int
if err := b.QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil || fk != 1 {
t.Fatalf("foreign_keys = %d (err %v), want 1", fk, err)
}
if got, want := len(versionSetForTest(t, b)), countMigrationFiles(t); got != want {
t.Fatalf("schema_migrations has %d versions, want %d", got, want)
}
}

func countMigrationFiles(t *testing.T) int {
t.Helper()
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
t.Fatal(err)
}
return len(entries)
}

// A failed template build is reported by every OpenMemory call, not retried
// into a half-migrated database.
//
// It swaps the package's template for the duration, so it must not call
// t.Parallel: Go holds parallel tests back until every sequential one has
// finished, which is what keeps the swap invisible to them.
func TestOpenMemory_TemplateErrorIsReturned(t *testing.T) {
db, err := OpenMemory() // the template is built by now
if err != nil {
t.Fatal(err)
}
db.Close()
saved, savedErr := tmpl, tmplErr
t.Cleanup(func() { tmpl, tmplErr = saved, savedErr })
tmplErr = errors.New("migration failed")
if db, err := OpenMemory(); err == nil || db != nil {
t.Fatalf("OpenMemory = %v, %v; want the template error", db, err)
}
// A template that cannot be loaded fails the call instead of handing out
// an empty database.
tmpl, tmplErr = nil, nil
if db, err := OpenMemory(); err == nil || db != nil {
t.Fatalf("OpenMemory with an unloadable template = %v, %v; want an error", db, err)
}
}

func TestWithRawConn_ClosedDB(t *testing.T) {
db, err := openMemoryConn()
if err != nil {
t.Fatal(err)
}
db.Close()
if err := withRawConn(db, func(memConn) error { return nil }); err == nil {
t.Fatal("withRawConn on a closed database succeeded")
}
}

// A driver connection that cannot serialize is an error, not a panic.
func TestWithRawConn_DriverWithoutSerialize(t *testing.T) {
sql.Register("openmemory-plain", plainDriver{})
db, err := sql.Open("openmemory-plain", "")
if err != nil {
t.Fatal(err)
}
defer db.Close()
err = withRawConn(db, func(memConn) error { t.Fatal("fn called for a driver without Serialize"); return nil })
if err == nil || !strings.Contains(err.Error(), "cannot serialize") {
t.Fatalf("err = %v, want a cannot-serialize error", err)
}
}

type plainDriver struct{}

func (plainDriver) Open(string) (driver.Conn, error) { return plainConn{}, nil }

type plainConn struct{}

func (plainConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not supported") }
func (plainConn) Close() error { return nil }
func (plainConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
Loading