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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ jobs:
--health-retries 5
ports:
- 5432:5432
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
options: >-
--health-cmd "mysql -uroot -proot -e 'SELECT 1'"
--health-interval 5s
--health-timeout 5s
--health-retries 12
ports:
- 3306:3306

steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955
Expand Down
24 changes: 23 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ POSTGRES_PORT := 5432
POSTGRES_USER := postgres
POSTGRES_PASSWORD := postgres
POSTGRES_DB := testdb
MYSQL_SERVICE := test-mysql
MYSQL_USER := root
MYSQL_PASSWORD := root

.PHONY: check_pgcli
check_pgcli:
Expand All @@ -83,12 +86,30 @@ define wait_for_postgres
echo "✅ Postgres is ready!"
endef

# mysqladmin ping answers before the server accepts SQL, so wait for a query.
define wait_for_mysql
@echo "Waiting for MySQL to be ready..."
@attempts=0; \
while ! docker compose -f $(1) exec -T $(2) mysql -u$(3) -p$(4) -e 'SELECT 1' >/dev/null 2>&1; do \
attempts=$$((attempts+1)); \
if [ $$attempts -gt 60 ]; then \
echo "❌ MySQL failed to become ready after 60 seconds"; \
echo "Try 'make $(5)' for a fresh start"; \
exit 1; \
fi; \
echo "⏳ Waiting... ($$attempts/60)"; \
sleep 1; \
done; \
echo "✅ MySQL is ready!"
endef

## test/db/start: Start PostgreSQL test database
.PHONY: test/db/start
test/db/start:
@echo 'Starting test database...'
docker compose -f $(POSTGRES_COMPOSE_FILE) up -d
$(call wait_for_postgres,$(POSTGRES_COMPOSE_FILE),$(POSTGRES_SERVICE),$(POSTGRES_USER),$(POSTGRES_DB),test/db/restart)
$(call wait_for_mysql,$(POSTGRES_COMPOSE_FILE),$(MYSQL_SERVICE),$(MYSQL_USER),$(MYSQL_PASSWORD),test/db/restart)

## test/db/stop: Stop PostgreSQL test database
.PHONY: test/db/stop
Expand Down Expand Up @@ -117,8 +138,9 @@ test/db/clean:
test/db/restart: test/db/clean
@echo "Starting fresh Postgres instance..."
docker compose -f $(POSTGRES_COMPOSE_FILE) build --force-rm --no-cache $(POSTGRES_SERVICE)
docker compose -f $(POSTGRES_COMPOSE_FILE) up -d $(POSTGRES_SERVICE)
docker compose -f $(POSTGRES_COMPOSE_FILE) up -d
$(call wait_for_postgres,$(POSTGRES_COMPOSE_FILE),$(POSTGRES_SERVICE),$(POSTGRES_USER),$(POSTGRES_DB),test/db/restart)
$(call wait_for_mysql,$(POSTGRES_COMPOSE_FILE),$(MYSQL_SERVICE),$(MYSQL_USER),$(MYSQL_PASSWORD),test/db/restart)

## test/db/psql: Connect to Postgres database
.PHONY: test/db/psql
Expand Down
21 changes: 21 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,24 @@ services:
- "full_page_writes=off"
- "-c"
- "max_connections=300"

test-mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: root
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysql", "-uroot", "-proot", "-e", "SELECT 1"]
interval: 5s
timeout: 3s
retries: 12
# Durability off, as for Postgres above: this server only ever holds
# throwaway test databases, and these settings make every DDL and drop
# markedly faster.
command:
- "--innodb-flush-log-at-trx-commit=0"
- "--sync-binlog=0"
- "--skip-log-bin"
- "--innodb-doublewrite=0"
- "--innodb-flush-method=O_DIRECT_NO_FSYNC"
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ module github.com/bashhack/testdb
go 1.27.0

require (
github.com/go-sql-driver/mysql v1.10.1
github.com/jackc/pgx/v5 v5.11.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
)

require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
Expand Down
4 changes: 4 additions & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
Expand Down
59 changes: 59 additions & 0 deletions helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package testdb

import (
"io"
"testing"
)

// RunConfiguredMigrations and RegisterCleanup are what every database
// package's Setup and New are built from, so all providers behave the same
// way around a test: migrations run under the test's context, cleanup closes
// the entity before dropping, and KeepOnFailure means the same thing
// everywhere.

// RunConfiguredMigrations runs the Migrator set with WithMigrator, if any.
// On failure it drops the database (or keeps it, under KeepOnFailure, since
// a half-applied migration is exactly what one wants to look at) and calls
// t.Fatalf, so it does not return. callerName is the function the test
// called, for the message.
func RunConfiguredMigrations(t testing.TB, db *TestDatabase, callerName string) {
t.Helper()
if db.Config().Migrator == nil {
return
}
err := db.RunMigrations(ContextOf(t))
if err == nil {
return
}
if db.Config().KeepOnFailure {
db.Keep()
}
if closeErr := db.Close(); closeErr != nil {
t.Logf("Warning: failed to close database after migration error: %v", closeErr)
}
t.Fatalf("%s: migrations failed: %v", callerName, err)
}

// RegisterCleanup arranges for the database to go away when the test ends:
// the entity is closed first if it implements io.Closer, then Close drops
// the database. With KeepOnFailure, a failed test's database is kept
// instead (see Keep).
func RegisterCleanup(t testing.TB, db *TestDatabase) {
t.Cleanup(func() {
if entity := db.Entity(); entity != nil {
if closer, ok := entity.(io.Closer); ok {
if err := closer.Close(); err != nil {
t.Logf("Warning: failed to close entity: %v", err)
}
}
}

if db.Config().KeepOnFailure && t.Failed() {
db.Keep()
}

if err := db.Close(); err != nil {
t.Errorf("testdb cleanup failed: %v", err)
}
})
}
30 changes: 28 additions & 2 deletions keep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ func (p *countingProvider) Cleanup(ctx context.Context) error {
return p.mockProvider.Cleanup(ctx)
}

// InspectCommand stands in for a database client invocation.
func (p *countingProvider) InspectCommand(dsn string) string {
return "connect-with " + dsn
}

func TestCloseDropsByDefault(t *testing.T) {
provider := &countingProvider{}
db, err := New(t, provider, nil)
Expand Down Expand Up @@ -74,8 +79,29 @@ func TestKeepLeavesDatabaseAndLogsHowToConnect(t *testing.T) {
if kept == "" {
t.Fatalf("expected a 'kept database' log line, got %v", spy.logs)
}
if !strings.Contains(kept, db.Name()) || !strings.Contains(kept, "psql") || !strings.Contains(kept, db.DSN()) {
t.Errorf("kept-database log should name the database and give a psql command with its DSN, got: %s", kept)
if !strings.Contains(kept, db.Name()) || !strings.Contains(kept, "connect-with "+db.DSN()) {
t.Errorf("kept-database log should name the database and give the provider's connect command, got: %s", kept)
}
}

func TestKeepLogsDSNWhenProviderHasNoInspectCommand(t *testing.T) {
spy := &verboseSpyTB{TB: t}
db, err := New(spy, &mockProvider{}, nil) // mockProvider has no InspectCommand
if err != nil {
t.Fatal(err)
}
db.Keep()
if err := db.Close(); err != nil {
t.Fatal(err)
}
var kept string
for _, msg := range spy.logs {
if strings.Contains(msg, "kept database") {
kept = msg
}
}
if !strings.Contains(kept, db.DSN()) {
t.Errorf("without an InspectCommander the log should show the DSN, got: %s", kept)
}
}

Expand Down
4 changes: 3 additions & 1 deletion migrate/golangmigrate/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ go 1.27.0

require (
github.com/bashhack/testdb v0.0.0
github.com/go-sql-driver/mysql v1.10.1
github.com/golang-migrate/migrate/v4 v4.20.1
github.com/jackc/pgx/v5 v5.9.2
github.com/jackc/pgx/v5 v5.11.0
)

require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
Expand Down
8 changes: 6 additions & 2 deletions migrate/golangmigrate/go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
Expand Down Expand Up @@ -27,6 +29,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
Expand All @@ -35,8 +39,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
Expand Down
29 changes: 21 additions & 8 deletions migrate/golangmigrate/golangmigrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@ import (
"os"
"strings"

mysqldriver "github.com/go-sql-driver/mysql"
"github.com/golang-migrate/migrate/v4"
// Registers the pgx5:// database driver that this package connects with.
// Registers the mysql:// database driver.
_ "github.com/golang-migrate/migrate/v4/database/mysql"
// Registers the pgx5:// database driver.
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
)

// Migrator applies golang-migrate migrations to a PostgreSQL database.
// Create one with Dir or FS.
// Migrator applies golang-migrate migrations to a PostgreSQL or MySQL
// database. Create one with Dir or FS.
type Migrator struct {
fsys fs.FS
path string
Expand Down Expand Up @@ -101,18 +104,28 @@ func (m *Migrator) Migrate(ctx context.Context, dsn string) (err error) {
return nil
}

// databaseURL rewrites the DSN's scheme to pgx5, the one golang-migrate's
// pgx/v5 driver registers; the rest of the URL is the same.
// databaseURL turns a testdb DSN into the URL golang-migrate's drivers
// register for: a PostgreSQL URL gets the pgx5 scheme, and go-sql-driver's
// native MySQL DSN (which has no scheme) is prefixed with mysql://, which is
// the form golang-migrate's MySQL driver parses.
func databaseURL(dsn string) (string, error) {
if !strings.Contains(dsn, "://") {
if _, err := mysqldriver.ParseDSN(dsn); err == nil {
return "mysql://" + dsn, nil
}
return "", errors.New("golangmigrate: the DSN must be a PostgreSQL URL (postgres://...) or a native MySQL DSN (user:pass@tcp(host:port)/db)")
}
u, err := url.Parse(dsn)
if err != nil || u.Scheme == "" {
return "", errors.New("golangmigrate: the DSN must be a URL (postgres://...)")
if err != nil {
return "", fmt.Errorf("golangmigrate: parse DSN: %w", err)
}
switch strings.ToLower(u.Scheme) {
case "postgres", "postgresql", "pgx", "pgx5":
u.Scheme = "pgx5"
case "mysql":
return "", errors.New("golangmigrate: give MySQL DSNs in go-sql-driver's native form (user:pass@tcp(host:port)/db), as testdb's mysql package does")
default:
return "", fmt.Errorf("golangmigrate: unsupported DSN scheme %q; only PostgreSQL is supported", u.Scheme)
return "", fmt.Errorf("golangmigrate: unsupported DSN scheme %q", u.Scheme)
}
return u.String(), nil
}
Loading
Loading