diff --git a/database/driver_test.go b/database/driver_test.go index 7880f3208..5c3340ec7 100644 --- a/database/driver_test.go +++ b/database/driver_test.go @@ -55,7 +55,7 @@ func (m *mockDriver) Drop() error { func TestRegisterTwice(t *testing.T) { Register("mock", &mockDriver{}) - var err interface{} + var err any func() { defer func() { err = recover() diff --git a/database/neo4j/neo4j.go b/database/neo4j/neo4j.go index f6ab07a87..c0d0ed8ca 100644 --- a/database/neo4j/neo4j.go +++ b/database/neo4j/neo4j.go @@ -26,9 +26,7 @@ var ( DefaultMultiStatementMaxSize = 10 * 1 << 20 // 10 MB ) -var ( - ErrNilConfig = fmt.Errorf("no config") -) +var ErrNilConfig = fmt.Errorf("no config") type Config struct { MigrationsLabel string @@ -146,7 +144,7 @@ func (n *Neo4j) Run(migration io.Reader) (err error) { }() if n.config.MultiStatement { - _, err = session.WriteTransaction(func(transaction neo4j.Transaction) (interface{}, error) { + _, err = session.WriteTransaction(func(transaction neo4j.Transaction) (any, error) { var stmtRunErr error if err := multistmt.Parse(migration, StatementSeparator, n.config.MultiStatementMaxSize, func(stmt []byte) bool { trimStmt := bytes.TrimSpace(stmt) @@ -194,7 +192,7 @@ func (n *Neo4j) SetVersion(version int, dirty bool) (err error) { query := fmt.Sprintf("MERGE (sm:%s {version: $version}) SET sm.dirty = $dirty, sm.ts = datetime()", n.config.MigrationsLabel) - _, err = neo4j.Collect(session.Run(query, map[string]interface{}{"version": version, "dirty": dirty})) + _, err = neo4j.Collect(session.Run(query, map[string]any{"version": version, "dirty": dirty})) if err != nil { return err } @@ -220,7 +218,7 @@ func (n *Neo4j) Version() (version int, dirty bool, err error) { query := fmt.Sprintf(`MATCH (sm:%s) RETURN sm.version AS version, sm.dirty AS dirty ORDER BY COALESCE(sm.ts, datetime({year: 0})) DESC, sm.version DESC LIMIT 1`, n.config.MigrationsLabel) - result, err := session.ReadTransaction(func(transaction neo4j.Transaction) (interface{}, error) { + result, err := session.ReadTransaction(func(transaction neo4j.Transaction) (any, error) { result, err := transaction.Run(query, nil) if err != nil { return nil, err diff --git a/database/pgx/pgx_test.go b/database/pgx/pgx_test.go index d86caeb36..1e8ae0d11 100644 --- a/database/pgx/pgx_test.go +++ b/database/pgx/pgx_test.go @@ -31,7 +31,8 @@ const ( var ( opts = dktest.Options{ Env: map[string]string{"POSTGRES_PASSWORD": pgPassword}, - PortRequired: true, ReadyFunc: isReady} + PortRequired: true, ReadyFunc: isReady, + } // Supported versions: https://www.postgresql.org/support/versioning/ specs = []dktesting.ContainerSpec{ {ImageName: "postgres:13", Options: opts}, @@ -406,7 +407,6 @@ func TestMigrationTableOption(t *testing.T) { if !exists { t.Fatalf("expected table 'migrate.schema_migrations' to exist") } - }) } @@ -423,7 +423,6 @@ func TestFailToCreateTableWithoutPermissions(t *testing.T) { p := &Postgres{} d, err := p.Open(addr) - if err != nil { t.Fatal(err) } @@ -493,7 +492,6 @@ func TestCheckBeforeCreateTable(t *testing.T) { p := &Postgres{} d, err := p.Open(addr) - if err != nil { t.Fatal(err) } @@ -516,7 +514,6 @@ func TestCheckBeforeCreateTable(t *testing.T) { // re-connect using that schema d2, err := p.Open(fmt.Sprintf("postgres://not_owner:%s@%v:%v/postgres?sslmode=disable&search_path=barfoo", pgPassword, ip, port)) - if err != nil { t.Fatal(err) } @@ -534,13 +531,11 @@ func TestCheckBeforeCreateTable(t *testing.T) { // re-connect using that schema d3, err := p.Open(fmt.Sprintf("postgres://not_owner:%s@%v:%v/postgres?sslmode=disable&search_path=barfoo", pgPassword, ip, port)) - if err != nil { t.Fatal(err) } version, _, err := d3.Version() - if err != nil { t.Fatal(err) } @@ -694,7 +689,7 @@ func TestWithInstance_Concurrent(t *testing.T) { defer wg.Wait() wg.Add(concurrency) - for i := 0; i < concurrency; i++ { + for i := range concurrency { go func(i int) { defer wg.Done() _, err := WithInstance(db, &Config{}) @@ -705,6 +700,7 @@ func TestWithInstance_Concurrent(t *testing.T) { } }) } + func Test_computeLineFromPos(t *testing.T) { testcases := []struct { pos int diff --git a/database/pgx/v5/pgx_test.go b/database/pgx/v5/pgx_test.go index 9a8652768..182fc809c 100644 --- a/database/pgx/v5/pgx_test.go +++ b/database/pgx/v5/pgx_test.go @@ -32,7 +32,8 @@ const ( var ( opts = dktest.Options{ Env: map[string]string{"POSTGRES_PASSWORD": pgPassword}, - PortRequired: true, ReadyFunc: isReady} + PortRequired: true, ReadyFunc: isReady, + } // Supported versions: https://www.postgresql.org/support/versioning/ specs = []dktesting.ContainerSpec{ {ImageName: "postgres:13", Options: opts}, @@ -381,7 +382,6 @@ func TestMigrationTableOption(t *testing.T) { if !exists { t.Fatalf("expected table 'migrate.schema_migrations' to exist") } - }) } @@ -398,7 +398,6 @@ func TestFailToCreateTableWithoutPermissions(t *testing.T) { p := &Postgres{} d, err := p.Open(addr) - if err != nil { t.Fatal(err) } @@ -468,7 +467,6 @@ func TestCheckBeforeCreateTable(t *testing.T) { p := &Postgres{} d, err := p.Open(addr) - if err != nil { t.Fatal(err) } @@ -491,7 +489,6 @@ func TestCheckBeforeCreateTable(t *testing.T) { // re-connect using that schema d2, err := p.Open(fmt.Sprintf("postgres://not_owner:%s@%v:%v/postgres?sslmode=disable&search_path=barfoo", pgPassword, ip, port)) - if err != nil { t.Fatal(err) } @@ -509,13 +506,11 @@ func TestCheckBeforeCreateTable(t *testing.T) { // re-connect using that schema d3, err := p.Open(fmt.Sprintf("postgres://not_owner:%s@%v:%v/postgres?sslmode=disable&search_path=barfoo", pgPassword, ip, port)) - if err != nil { t.Fatal(err) } version, _, err := d3.Version() - if err != nil { t.Fatal(err) } @@ -669,7 +664,7 @@ func TestWithInstance_Concurrent(t *testing.T) { defer wg.Wait() wg.Add(concurrency) - for i := 0; i < concurrency; i++ { + for i := range concurrency { go func(i int) { defer wg.Done() _, err := WithInstance(db, &Config{}) @@ -680,6 +675,7 @@ func TestWithInstance_Concurrent(t *testing.T) { } }) } + func Test_computeLineFromPos(t *testing.T) { testcases := []struct { pos int diff --git a/database/postgres/postgres_test.go b/database/postgres/postgres_test.go index 3a49c50ab..a7e32ac5a 100644 --- a/database/postgres/postgres_test.go +++ b/database/postgres/postgres_test.go @@ -32,7 +32,8 @@ const ( var ( opts = dktest.Options{ Env: map[string]string{"POSTGRES_PASSWORD": pgPassword}, - PortRequired: true, ReadyFunc: isReady} + PortRequired: true, ReadyFunc: isReady, + } // Supported versions: https://www.postgresql.org/support/versioning/ specs = []dktesting.ContainerSpec{ {ImageName: "postgres:13", Options: opts}, @@ -409,7 +410,6 @@ func testMigrationTableOption(t *testing.T) { if !exists { t.Fatalf("expected table 'migrate.schema_migrations' to exist") } - }) } @@ -426,7 +426,6 @@ func testFailToCreateTableWithoutPermissions(t *testing.T) { p := &Postgres{} d, err := p.Open(addr) - if err != nil { t.Fatal(err) } @@ -496,7 +495,6 @@ func testCheckBeforeCreateTable(t *testing.T) { p := &Postgres{} d, err := p.Open(addr) - if err != nil { t.Fatal(err) } @@ -519,7 +517,6 @@ func testCheckBeforeCreateTable(t *testing.T) { // re-connect using that schema d2, err := p.Open(fmt.Sprintf("postgres://not_owner:%s@%v:%v/postgres?sslmode=disable&search_path=barfoo", pgPassword, ip, port)) - if err != nil { t.Fatal(err) } @@ -537,13 +534,11 @@ func testCheckBeforeCreateTable(t *testing.T) { // re-connect using that schema d3, err := p.Open(fmt.Sprintf("postgres://not_owner:%s@%v:%v/postgres?sslmode=disable&search_path=barfoo", pgPassword, ip, port)) - if err != nil { t.Fatal(err) } version, _, err := d3.Version() - if err != nil { t.Fatal(err) } @@ -699,7 +694,7 @@ func testWithInstanceConcurrent(t *testing.T) { defer wg.Wait() wg.Add(concurrency) - for i := 0; i < concurrency; i++ { + for i := range concurrency { go func(i int) { defer wg.Done() _, err := WithInstance(db, &Config{}) diff --git a/database/rqlite/rqlite.go b/database/rqlite/rqlite.go index fcab3f8ab..5c7c26a2d 100644 --- a/database/rqlite/rqlite.go +++ b/database/rqlite/rqlite.go @@ -186,7 +186,7 @@ func (r *Rqlite) SetVersion(version int, dirty bool) error { if version >= 0 || (version == database.NilVersion && dirty) { statements = append(statements, gorqlite.ParameterizedStatement{ Query: insertQuery, - Arguments: []interface{}{ + Arguments: []any{ version, dirty, }, diff --git a/database/spanner/spanner.go b/database/spanner/spanner.go index 914c79532..b2bd58cfe 100644 --- a/database/spanner/spanner.go +++ b/database/spanner/spanner.go @@ -180,7 +180,6 @@ func (s *Spanner) Run(migration io.Reader) error { Database: s.config.DatabaseName, Statements: stmts, }) - if err != nil { return &database.Error{OrigErr: err, Err: "migration failed", Query: migr} } @@ -200,10 +199,12 @@ func (s *Spanner) SetVersion(version int, dirty bool) error { func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { m := []*spanner.Mutation{ spanner.Delete(s.config.MigrationsTable, spanner.AllKeys()), - spanner.Insert(s.config.MigrationsTable, + spanner.Insert( + s.config.MigrationsTable, []string{"Version", "Dirty"}, - []interface{}{version, dirty}, - )} + []any{version, dirty}, + ), + } return txn.BufferWrite(m) }) if err != nil { @@ -318,7 +319,6 @@ func (s *Spanner) ensureVersionTable() (err error) { Database: s.config.DatabaseName, Statements: []string{stmt}, }) - if err != nil { return &database.Error{OrigErr: err, Query: []byte(stmt)} } diff --git a/database/stub/stub.go b/database/stub/stub.go index 39edadea2..3c6ce8749 100644 --- a/database/stub/stub.go +++ b/database/stub/stub.go @@ -14,7 +14,7 @@ func init() { type Stub struct { Url string - Instance interface{} + Instance any CurrentVersion int MigrationSequence []string LastRunMigration []byte // todo: make []string @@ -35,7 +35,7 @@ func (s *Stub) Open(url string) (database.Driver, error) { type Config struct{} -func WithInstance(instance interface{}, config *Config) (database.Driver, error) { +func WithInstance(instance any, config *Config) (database.Driver, error) { return &Stub{ Instance: instance, CurrentVersion: database.NilVersion, diff --git a/dktesting/dktesting.go b/dktesting/dktesting.go index 644f96c7c..eb6d64bae 100644 --- a/dktesting/dktesting.go +++ b/dktesting/dktesting.go @@ -45,11 +45,9 @@ func (s *ContainerSpec) Cleanup() (retErr error) { // ParallelTest runs Docker tests in parallel func ParallelTest(t *testing.T, specs []ContainerSpec, - testFunc func(*testing.T, dktest.ContainerInfo)) { - + testFunc func(*testing.T, dktest.ContainerInfo), +) { for i, spec := range specs { - spec := spec // capture range variable, see https://goo.gl/60w3p2 - // Only test against one version in short mode // TODO: order is random, maybe always pick first version instead? if i > 0 && testing.Short() { diff --git a/internal/cli/log.go b/internal/cli/log.go index b17754197..7c7174342 100644 --- a/internal/cli/log.go +++ b/internal/cli/log.go @@ -12,7 +12,7 @@ type Log struct { } // Printf prints out formatted string into a log -func (l *Log) Printf(format string, v ...interface{}) { +func (l *Log) Printf(format string, v ...any) { if l.verbose { logpkg.Printf(format, v...) } else { @@ -21,7 +21,7 @@ func (l *Log) Printf(format string, v ...interface{}) { } // Println prints out args into a log -func (l *Log) Println(args ...interface{}) { +func (l *Log) Println(args ...any) { if l.verbose { logpkg.Println(args...) } else { @@ -34,7 +34,7 @@ func (l *Log) Verbose() bool { return l.verbose } -func (l *Log) fatal(args ...interface{}) { +func (l *Log) fatal(args ...any) { l.Println(args...) os.Exit(1) } diff --git a/log.go b/log.go index cb00b7798..34c91a41d 100644 --- a/log.go +++ b/log.go @@ -1,12 +1,169 @@ package migrate +import ( + "context" + "fmt" + "log/slog" +) + // Logger is an interface so you can pass in your own // logging implementation. type Logger interface { - // Printf is like fmt.Printf - Printf(format string, v ...interface{}) + Printf(format string, v ...any) // Verbose should return true when verbose logging output is wanted Verbose() bool } + +// StructuredLogger is an optional capability a [Logger] may also implement to +// receive structured records instead of preformatted Printf strings. A [Logger] +// set as Migrate.Log that implements StructuredLogger has its Log method called +// directly; a plain Printf-only [Logger] is wrapped internally so it still +// receives the historical Printf lines. This lets a structured backend opt in +// without breaking the legacy [Logger] interface. +// +// The args carry no logging-library types, so any backend can be adapted to +// Log: an [*slog.Logger] fits directly (see [SlogLogger]), while others need a +// small adapter (e.g. zap's SugaredLogger.Logw takes the same key/value pairs; +// zerolog needs a per-field conversion). +// +// The msg passed to Log is a stable, human-readable label, but the exact +// strings are not part of the API contract: implementations should log the +// key/value args and treat msg as a description, not switch on it. +type StructuredLogger interface { + Logger + + // Log emits a single record. args are alternating key/value pairs in the + // same shape as slog.Logger.Log's args (e.g. "version", 4, "took", d). + Log(ctx context.Context, level slog.Level, msg string, args ...any) +} + +// Message strings passed to [StructuredLogger.Log]. They double as the record +// message on the structured path and as the switch key [printfLogger] uses to +// rebuild the historical Printf line, so the two must stay in sync. +const ( + // msgClosing is logged when the source and database drivers are closed. + msgClosing = "closing source and database" + // msgStartBuffering is logged when a migration starts being prefetched. + msgStartBuffering = "start buffering migration" + // msgScheduled is logged when a migration is queued without prefetching. + msgScheduled = "scheduled migration" + // msgReadExecute is logged just before a migration's body is run. + msgReadExecute = "read and execute migration" + // msgApplied is logged once a migration has been applied, with its timing. + msgApplied = "applied migration" + // msgError is logged for a migration error, carrying the "error" field. + msgError = "migration error" +) + +// SlogLogger adapts an [*slog.Logger] to the migrate [Logger] and [StructuredLogger] +// interfaces. Verbose lines are logged at [slog.LevelDebug], normal lines at +// [slog.LevelInfo], and errors at [slog.LevelError]; the underlying [slog.Handler]'s +// level decides what is actually emitted. Verbose always reports true so the +// Debug records reach the handler and level filtering happens there rather than +// in migrate. +type SlogLogger struct { + logger *slog.Logger +} + +// SlogLogger must satisfy both the legacy [Logger] and the [StructuredLogger] +// interface. These assignments fail to compile if that ever regresses. +var ( + _ Logger = (*SlogLogger)(nil) + _ StructuredLogger = (*SlogLogger)(nil) +) + +// NewSlogLogger returns a [SlogLogger] writing to logger. A nil logger falls back +// to [slog.Default] so the adapter is always safe to use. +func NewSlogLogger(logger *slog.Logger) *SlogLogger { + if logger == nil { + logger = slog.Default() + } + + return &SlogLogger{logger: logger} +} + +// Log emits a structured record at level. +func (s *SlogLogger) Log(ctx context.Context, level slog.Level, msg string, args ...any) { + s.logger.Log(ctx, level, msg, args...) +} + +// Printf satisfies the legacy [Logger] interface for callers that pass a +// [SlogLogger] where a plain [Logger] is expected. The formatted message is +// logged at [slog.LevelInfo], except messages prefixed with "error: " which are +// logged at [slog.LevelError] to match migrate's own error convention. +func (s *SlogLogger) Printf(format string, v ...any) { + msg := fmt.Sprintf(format, v...) + + level := slog.LevelInfo + if len(msg) >= len(errPrefix) && msg[:len(errPrefix)] == errPrefix { + level = slog.LevelError + } + + s.logger.Log(context.Background(), level, msg) +} + +// Verbose reports true so verbose Debug records reach the handler; the handler's +// own level then decides whether they are emitted. +func (s *SlogLogger) Verbose() bool { return true } + +// errPrefix marks Printf messages that carry an error. [SlogLogger.Printf] uses +// it to route such messages to [slog.LevelError], matching migrate's convention +// of prefixing error lines with "error: ". +const errPrefix = "error: " + +// printfLogger wraps a plain Printf-only [Logger] and satisfies StructuredLogger +// by rebuilding the historical Printf lines from each record's message and args. +// It is the compatibility shim: it owns the mapping from structured message to +// legacy format so that call sites only ever emit structured records. +type printfLogger struct{ Logger } + +// printfLogger must satisfy [StructuredLogger] too: it is how a plain Printf-only +// [Logger] gets the structured Log method. +var _ StructuredLogger = printfLogger{} + +// arg returns the value stored under key in the alternating key/value args, or +// nil when the key is absent. +func arg(key string, args []any) any { + for i := 0; i+1 < len(args); i += 2 { + if k, ok := args[i].(string); ok && k == key { + return args[i+1] + } + } + + return nil +} + +// Log turns a structured record back into the exact Printf line migrate emitted +// before structured logging existed. Verbose (Debug) lines are suppressed unless +// the wrapped [Logger] asks for verbose output; the applied-migration line also +// gains its read/ran breakdown only when verbose. +func (p printfLogger) Log(_ context.Context, level slog.Level, msg string, args ...any) { + if level == slog.LevelDebug && !p.Verbose() { + return + } + + v, d, id := arg("version", args), arg("direction", args), arg("identifier", args) + + switch msg { + case msgClosing: + p.Printf("Closing source and database\n") + case msgStartBuffering: + p.Printf("Start buffering %v/%v %v\n", v, d, id) + case msgScheduled: + p.Printf("Scheduled %v/%v %v\n", v, d, id) + case msgReadExecute: + p.Printf("Read and execute %v/%v %v\n", v, d, id) + case msgApplied: + if p.Verbose() { + p.Printf("Finished %v/%v %v (read %v, ran %v)\n", v, d, id, arg("read", args), arg("ran", args)) + } else { + p.Printf("%v/%v %v (%v)\n", v, d, id, arg("took", args)) + } + case msgError: + p.Printf(errPrefix+"%v", arg("error", args)) + default: + p.Printf("%s", msg) + } +} diff --git a/log_test.go b/log_test.go new file mode 100644 index 000000000..b770a02c4 --- /dev/null +++ b/log_test.go @@ -0,0 +1,410 @@ +package migrate + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "regexp" + "sort" + "strings" + "sync" + "testing" + + dStub "github.com/golang-migrate/migrate/v4/database/stub" + sStub "github.com/golang-migrate/migrate/v4/source/stub" +) + +// captureHandler is a [slog.Handler] that records the records it receives so +// tests can assert on level, message and attributes. +type captureHandler struct { + mu sync.Mutex + records []slog.Record + level slog.Level +} + +func (h *captureHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + + h.records = append(h.records, r.Clone()) + + return nil +} + +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +func (h *captureHandler) snapshot() []slog.Record { + h.mu.Lock() + defer h.mu.Unlock() + + return append([]slog.Record(nil), h.records...) +} + +// attr returns the value of the named attribute on r, or nil if absent. +func attr(r slog.Record, key string) any { + var out any + + r.Attrs(func(a slog.Attr) bool { + if a.Key == key { + out = a.Value.Any() + return false + } + + return true + }) + + return out +} + +func TestSlogLoggerLog(t *testing.T) { + h := &captureHandler{level: slog.LevelDebug} + l := NewSlogLogger(slog.New(h)) + + l.Log(context.Background(), slog.LevelInfo, "applied migration", "version", uint(4), "direction", "u") + + recs := h.snapshot() + if len(recs) != 1 { + t.Fatalf("expected 1 record, got %d", len(recs)) + } + + if recs[0].Level != slog.LevelInfo { + t.Errorf("level = %v, want Info", recs[0].Level) + } + + if recs[0].Message != "applied migration" { + t.Errorf("message = %q, want %q", recs[0].Message, "applied migration") + } + // slog normalizes an unsigned integer to KindUint64, so Value.Any() + // returns uint64 regardless of the caller's concrete unsigned type. + if got := attr(recs[0], "version"); got != uint64(4) { + t.Errorf("version attr = %v (%T), want uint64(4)", got, got) + } + + if got := attr(recs[0], "direction"); got != "u" { + t.Errorf("direction attr = %v, want u", got) + } +} + +func TestSlogLoggerPrintfLevel(t *testing.T) { + tests := []struct { + name string + msg string + level slog.Level + }{ + {"normal", "1/u CREATE 1 (1ms)", slog.LevelInfo}, + {"error prefixed", "error: boom", slog.LevelError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &captureHandler{level: slog.LevelDebug} + l := NewSlogLogger(slog.New(h)) + + l.Printf("%s", tt.msg) + + recs := h.snapshot() + if len(recs) != 1 { + t.Fatalf("expected 1 record, got %d", len(recs)) + } + + if recs[0].Level != tt.level { + t.Errorf("level = %v, want %v", recs[0].Level, tt.level) + } + + if recs[0].Message != tt.msg { + t.Errorf("message = %q, want %q", recs[0].Message, tt.msg) + } + }) + } +} + +func TestSlogLoggerVerbose(t *testing.T) { + // Verbose must report true so verbose Debug records reach the handler and + // level filtering happens in slog, not in migrate. + if !NewSlogLogger(slog.Default()).Verbose() { + t.Error("SlogLogger.Verbose() = false, want true") + } +} + +func TestNewSlogLoggerNilFallsBackToDefault(t *testing.T) { + if NewSlogLogger(nil).logger != slog.Default() { + t.Error("NewSlogLogger(nil) should fall back to slog.Default()") + } +} + +// TestMigrateStructuredLogger runs a real migration and asserts that a +// structured-capable logger ([SlogLogger]) receives an "applied migration" record +// with structured timing fields rather than a preformatted string. +func TestMigrateStructuredLogger(t *testing.T) { + m, _ := New("stub://", "stub://") + m.sourceDrv.(*sStub.Stub).Migrations = sourceStubMigrations + _ = m.databaseDrv.(*dStub.Stub) + + h := &captureHandler{level: slog.LevelDebug} + m.Log = NewSlogLogger(slog.New(h)) + + if err := m.Migrate(1); err != nil { + t.Fatal(err) + } + + var applied *slog.Record + + for _, r := range h.snapshot() { + if r.Message == "applied migration" { + rr := r + applied = &rr + + break + } + } + + if applied == nil { + t.Fatal("no \"applied migration\" record was emitted") + } + + if applied.Level != slog.LevelInfo { + t.Errorf("level = %v, want Info", applied.Level) + } + + if got := attr(*applied, "version"); got != uint64(1) { + t.Errorf("version attr = %v (%T), want uint64(1)", got, got) + } + + if got := attr(*applied, "direction"); got != "u" { + t.Errorf("direction attr = %v, want u", got) + } + + if attr(*applied, "took") == nil { + t.Error("expected a \"took\" duration attr") + } +} + +// TestMigrateLegacyLogger confirms that a plain Printf-only [Logger] still +// receives the historical preformatted output, unchanged by the structured path. +// +// migrate logs from multiple goroutines (Run buffers migrations concurrently), +// so recordingLogger guards its buffer with a mutex — a real Logger must be +// safe for concurrent use, and an unsynchronized buffer races and drops lines. +type recordingLogger struct { + mu sync.Mutex + buf bytes.Buffer + verbose bool +} + +func (l *recordingLogger) Printf(format string, v ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.buf.WriteString(strings.TrimRight(fmt.Sprintf(format, v...), "\n")) + l.buf.WriteString("\n") +} + +func (l *recordingLogger) Verbose() bool { return l.verbose } + +// output returns the accumulated log text under the lock. +func (l *recordingLogger) output() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.String() +} + +func TestMigrateLegacyLogger(t *testing.T) { + m, _ := New("stub://", "stub://") + m.sourceDrv.(*sStub.Stub).Migrations = sourceStubMigrations + _ = m.databaseDrv.(*dStub.Stub) + + lg := &recordingLogger{} + m.Log = lg + + if err := m.Migrate(1); err != nil { + t.Fatal(err) + } + + out := lg.output() + // Non-verbose applied line: "/ ()". + if !strings.Contains(out, "1/u 1.up.stub (") { + t.Errorf("legacy output missing applied line, got:\n%s", out) + } +} + +// TestPrintfLoggerLog checks the compatibility shim rebuilds the historical +// Printf lines from structured records, including the verbose gate. +func TestPrintfLoggerLog(t *testing.T) { + migArgs := []any{"version", uint(4), "direction", "u", "identifier", "widgets"} + + tests := []struct { + name string + verbose bool + level slog.Level + msg string + args []any + want string // "" means nothing should be logged + }{ + {"scheduled non-verbose suppressed", false, slog.LevelDebug, msgScheduled, migArgs, ""}, + {"scheduled verbose", true, slog.LevelDebug, msgScheduled, migArgs, "Scheduled 4/u widgets"}, + {"start buffering verbose", true, slog.LevelDebug, msgStartBuffering, migArgs, "Start buffering 4/u widgets"}, + {"read and execute verbose", true, slog.LevelDebug, msgReadExecute, migArgs, "Read and execute 4/u widgets"}, + {"closing verbose", true, slog.LevelDebug, msgClosing, nil, "Closing source and database"}, + { + "applied normal", false, slog.LevelInfo, msgApplied, + append(append([]any{}, migArgs...), "read", "1ms", "ran", "2ms", "took", "3ms"), + "4/u widgets (3ms)", + }, + { + "applied verbose", true, slog.LevelInfo, msgApplied, + append(append([]any{}, migArgs...), "read", "1ms", "ran", "2ms", "took", "3ms"), + "Finished 4/u widgets (read 1ms, ran 2ms)", + }, + {"error", false, slog.LevelError, msgError, []any{"error", "boom"}, "error: boom"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lg := &recordingLogger{verbose: tt.verbose} + printfLogger{lg}.Log(context.Background(), tt.level, tt.msg, tt.args...) + + got := strings.TrimRight(lg.output(), "\n") + if got != tt.want { + t.Errorf("Log() = %q, want %q", got, tt.want) + } + }) + } +} + +// legacyDurationRe matches the per-run durations in the applied-migration +// lines so the golden compares formats, not timing. +var legacyDurationRe = regexp.MustCompile(`(read |ran |\()[0-9.]+(µs|ns|ms|s)`) + +// goldenLegacyLogNonVerbose and goldenLegacyLogVerbose are the exact +// (duration-redacted) Printf lines a plain [Logger] received from migrate at +// commit 8a4b4bb, before structured logging existed. They were captured by +// running a full Up+Down of sourceStubMigrations against that commit. migrate +// buffers migrations concurrently, so line ORDER is nondeterministic (the base +// commit varies run to run); the assertion below therefore compares the sorted +// multiset of lines, which is stable and proves byte-identity of every line. +var goldenLegacyLogNonVerbose = []string{ + "1/d 1.down.stub ()", + "1/u 1.up.stub ()", + "3/d ()", + "3/u 3.up.stub ()", + "4/d 4.down.stub ()", + "4/u 4.up.stub ()", + "5/d 5.down.stub ()", + "5/u ()", + "7/d 7.down.stub ()", + "7/u 7.up.stub ()", +} + +var goldenLegacyLogVerbose = []string{ + "Closing source and database", + "Finished 1/d 1.down.stub (read , ran )", + "Finished 1/u 1.up.stub (read , ran )", + "Finished 3/d (read , ran )", + "Finished 3/u 3.up.stub (read , ran )", + "Finished 4/d 4.down.stub (read , ran )", + "Finished 4/u 4.up.stub (read , ran )", + "Finished 5/d 5.down.stub (read , ran )", + "Finished 5/u (read , ran )", + "Finished 7/d 7.down.stub (read , ran )", + "Finished 7/u 7.up.stub (read , ran )", + "Read and execute 1/d 1.down.stub", + "Read and execute 1/u 1.up.stub", + "Read and execute 3/u 3.up.stub", + "Read and execute 4/d 4.down.stub", + "Read and execute 4/u 4.up.stub", + "Read and execute 5/d 5.down.stub", + "Read and execute 7/d 7.down.stub", + "Read and execute 7/u 7.up.stub", + "Scheduled 3/d ", + "Scheduled 5/u ", + "Start buffering 1/d 1.down.stub", + "Start buffering 1/u 1.up.stub", + "Start buffering 3/u 3.up.stub", + "Start buffering 4/d 4.down.stub", + "Start buffering 4/u 4.up.stub", + "Start buffering 5/d 5.down.stub", + "Start buffering 7/d 7.down.stub", + "Start buffering 7/u 7.up.stub", +} + +// captureLegacyLog runs a full Up+Down through [printfLogger] and returns the +// emitted lines, duration-redacted and sorted. +func captureLegacyLog(t *testing.T, verbose bool) []string { + t.Helper() + + m, err := New("stub://", "stub://") + if err != nil { + t.Fatal(err) + } + + m.sourceDrv.(*sStub.Stub).Migrations = sourceStubMigrations + _ = m.databaseDrv.(*dStub.Stub) + + lg := &recordingLogger{verbose: verbose} + m.Log = lg + + if err := m.Up(); err != nil { + t.Fatalf("up: %v", err) + } + + if err := m.Down(); err != nil { + t.Fatalf("down: %v", err) + } + + if s, d := m.Close(); s != nil || d != nil { + t.Fatalf("close: %v %v", s, d) + } + + var lines []string + + for ln := range strings.SplitSeq(strings.TrimRight(lg.output(), "\n"), "\n") { + lines = append(lines, legacyDurationRe.ReplaceAllString(ln, "${1}")) + } + + sort.Strings(lines) + + return lines +} + +// TestLegacyLogByteIdentical proves the compatibility shim reproduces the +// pre-structured-logging Printf output byte-for-byte: the lines a plain [Logger] +// receives now must equal the golden captured from the base commit. +func TestLegacyLogByteIdentical(t *testing.T) { + tests := []struct { + verbose bool + golden []string + }{ + {false, goldenLegacyLogNonVerbose}, + {true, goldenLegacyLogVerbose}, + } + + for _, tt := range tests { + name := "non-verbose" + if tt.verbose { + name = "verbose" + } + + t.Run(name, func(t *testing.T) { + got := captureLegacyLog(t, tt.verbose) + + want := append([]string(nil), tt.golden...) + sort.Strings(want) + + if len(got) != len(want) { + t.Fatalf("got %d lines, want %d\ngot:\n%s\nwant:\n%s", + len(got), len(want), strings.Join(got, "\n"), strings.Join(want, "\n")) + } + + for i := range want { + if got[i] != want[i] { + t.Errorf("line %d = %q, want %q", i, got[i], want[i]) + } + } + }) + } +} diff --git a/migrate.go b/migrate.go index 7cac0ba1b..ca4e79cb8 100644 --- a/migrate.go +++ b/migrate.go @@ -5,8 +5,10 @@ package migrate import ( + "context" "errors" "fmt" + "log/slog" "os" "sync" "time" @@ -194,7 +196,7 @@ func (m *Migrate) Close() (source error, database error) { databaseSrvClose := make(chan error) sourceSrvClose := make(chan error) - m.logVerbosePrintf("Closing source and database\n") + m.logRecord(slog.LevelDebug, msgClosing) go func() { databaseSrvClose <- m.databaseDrv.Close() @@ -223,7 +225,7 @@ func (m *Migrate) Migrate(version uint) error { return m.unlockErr(ErrDirty{curVersion}) } - ret := make(chan interface{}, m.PrefetchMigrations) + ret := make(chan any, m.PrefetchMigrations) go m.read(curVersion, int(version), ret) return m.unlockErr(m.runMigrations(ret)) @@ -249,7 +251,7 @@ func (m *Migrate) Steps(n int) error { return m.unlockErr(ErrDirty{curVersion}) } - ret := make(chan interface{}, m.PrefetchMigrations) + ret := make(chan any, m.PrefetchMigrations) if n > 0 { go m.readUp(curVersion, n, ret) @@ -276,7 +278,7 @@ func (m *Migrate) Up() error { return m.unlockErr(ErrDirty{curVersion}) } - ret := make(chan interface{}, m.PrefetchMigrations) + ret := make(chan any, m.PrefetchMigrations) go m.readUp(curVersion, -1, ret) return m.unlockErr(m.runMigrations(ret)) @@ -298,7 +300,7 @@ func (m *Migrate) Down() error { return m.unlockErr(ErrDirty{curVersion}) } - ret := make(chan interface{}, m.PrefetchMigrations) + ret := make(chan any, m.PrefetchMigrations) go m.readDown(curVersion, -1, ret) return m.unlockErr(m.runMigrations(ret)) } @@ -336,15 +338,15 @@ func (m *Migrate) Run(migration ...*Migration) error { return m.unlockErr(ErrDirty{curVersion}) } - ret := make(chan interface{}, m.PrefetchMigrations) + ret := make(chan any, m.PrefetchMigrations) go func() { defer close(ret) for _, migr := range migration { if m.PrefetchMigrations > 0 && migr.Body != nil { - m.logVerbosePrintf("Start buffering %v\n", migr.LogString()) + m.logRecord(slog.LevelDebug, msgStartBuffering, migr.LogArgs()...) } else { - m.logVerbosePrintf("Scheduled %v\n", migr.LogString()) + m.logRecord(slog.LevelDebug, msgScheduled, migr.LogArgs()...) } ret <- migr @@ -397,7 +399,7 @@ func (m *Migrate) Version() (version uint, dirty bool, err error) { // Each migration is then written to the ret channel. // If an error occurs during reading, that error is written to the ret channel, too. // Once read is done reading it will close the ret channel. -func (m *Migrate) read(from int, to int, ret chan<- interface{}) { +func (m *Migrate) read(from int, to int, ret chan<- any) { defer close(ret) // check if from version exists @@ -529,7 +531,7 @@ func (m *Migrate) read(from int, to int, ret chan<- interface{}) { // Each migration is then written to the ret channel. // If an error occurs during reading, that error is written to the ret channel, too. // Once readUp is done reading it will close the ret channel. -func (m *Migrate) readUp(from int, limit int, ret chan<- interface{}) { +func (m *Migrate) readUp(from int, limit int, ret chan<- any) { defer close(ret) // check if from version exists @@ -629,7 +631,7 @@ func (m *Migrate) readUp(from int, limit int, ret chan<- interface{}) { // Each migration is then written to the ret channel. // If an error occurs during reading, that error is written to the ret channel, too. // Once readDown is done reading it will close the ret channel. -func (m *Migrate) readDown(from int, limit int, ret chan<- interface{}) { +func (m *Migrate) readDown(from int, limit int, ret chan<- any) { defer close(ret) // check if from version exists @@ -720,7 +722,7 @@ func (m *Migrate) readDown(from int, limit int, ret chan<- interface{}) { // Before running a newly received migration it will check if it's supposed // to stop execution because it might have received a stop signal on the // GracefulStop channel. -func (m *Migrate) runMigrations(ret <-chan interface{}) error { +func (m *Migrate) runMigrations(ret <-chan any) error { for r := range ret { if m.stop() { @@ -740,7 +742,7 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { } if migr.Body != nil { - m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) + m.logRecord(slog.LevelDebug, msgReadExecute, migr.LogArgs()...) if err := m.databaseDrv.Run(migr.BufferedBody); err != nil { return err } @@ -755,14 +757,11 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { readTime := migr.FinishedReading.Sub(migr.StartedBuffering) runTime := endTime.Sub(migr.FinishedReading) - // log either verbose or normal - if m.Log != nil { - if m.Log.Verbose() { - m.logPrintf("Finished %v (read %v, ran %v)\n", migr.LogString(), readTime, runTime) - } else { - m.logPrintf("%v (%v)\n", migr.LogString(), readTime+runTime) - } - } + // log the applied migration as one structured record carrying the + // full timing; printfLogger rebuilds the verbose or normal legacy + // line from these fields for plain Logger callers. + args := append(migr.LogArgs(), "read", readTime, "ran", runTime, "took", readTime+runTime) + m.logRecord(slog.LevelInfo, msgApplied, args...) default: return fmt.Errorf("unknown type: %T with value: %+v", r, r) @@ -843,7 +842,6 @@ func (m *Migrate) newMigration(version uint, targetVersion int) (*Migration, err } else if err != nil { return nil, err - } else { // create migration from up source migr, err = NewMigration(r, identifier, version, targetVersion) @@ -863,7 +861,6 @@ func (m *Migrate) newMigration(version uint, targetVersion int) (*Migration, err } else if err != nil { return nil, err - } else { // create migration from down source migr, err = NewMigration(r, identifier, version, targetVersion) @@ -874,9 +871,9 @@ func (m *Migrate) newMigration(version uint, targetVersion int) (*Migration, err } if m.PrefetchMigrations > 0 && migr.Body != nil { - m.logVerbosePrintf("Start buffering %v\n", migr.LogString()) + m.logRecord(slog.LevelDebug, msgStartBuffering, migr.LogArgs()...) } else { - m.logVerbosePrintf("Scheduled %v\n", migr.LogString()) + m.logRecord(slog.LevelDebug, msgScheduled, migr.LogArgs()...) } return migr, nil @@ -957,23 +954,24 @@ func (m *Migrate) unlockErr(prevErr error) error { return prevErr } -// logPrintf writes to m.Log if not nil -func (m *Migrate) logPrintf(format string, v ...interface{}) { - if m.Log != nil { - m.Log.Printf(format, v...) +// logRecord emits one structured record through m.Log. It is the single log +// trunk: a structured-capable Logger receives the record directly, while a +// plain Printf-only Logger is wrapped in printfLogger, which rebuilds the +// historical Printf line. A nil m.Log is a no-op. +func (m *Migrate) logRecord(level slog.Level, msg string, args ...any) { + if m.Log == nil { + return } -} -// logVerbosePrintf writes to m.Log if not nil. Use for verbose logging output. -func (m *Migrate) logVerbosePrintf(format string, v ...interface{}) { - if m.Log != nil && m.Log.Verbose() { - m.Log.Printf(format, v...) + sl, ok := m.Log.(StructuredLogger) + if !ok { + sl = printfLogger{m.Log} } + + sl.Log(context.Background(), level, msg, args...) } -// logErr writes error to m.Log if not nil +// logErr emits err as a structured error record. func (m *Migrate) logErr(err error) { - if m.Log != nil { - m.Log.Printf("error: %v", err) - } + m.logRecord(slog.LevelError, msgError, "error", err) } diff --git a/migrate_test.go b/migrate_test.go index 19a6d8820..13d54621c 100644 --- a/migrate_test.go +++ b/migrate_test.go @@ -471,7 +471,6 @@ func TestMigrate(t *testing.T) { if (v.expectErr == os.ErrNotExist && !errors.Is(err, os.ErrNotExist)) || (v.expectErr != os.ErrNotExist && err != v.expectErr) { t.Errorf("expected err %v, got %v, in %v", v.expectErr, err, i) - } else if err == nil { version, _, err := m.Version() if err != nil { @@ -526,7 +525,8 @@ func TestSteps(t *testing.T) { steps: 1, expectVersion: 1, expectSeq: migrationSequence{ - mr("CREATE 1")}, + mr("CREATE 1"), + }, }, { steps: 1, @@ -734,7 +734,6 @@ func TestSteps(t *testing.T) { if (v.expectErr == os.ErrNotExist && !errors.Is(err, os.ErrNotExist)) || (v.expectErr != os.ErrNotExist && err != v.expectErr) { t.Errorf("expected err %v, got %v, in %v", v.expectErr, err, i) - } else if err == nil { version, _, err := m.Version() if err != ErrNilVersion && err != nil { @@ -742,7 +741,6 @@ func TestSteps(t *testing.T) { } if v.expectVersion == -1 && err != ErrNilVersion { t.Errorf("expected ErrNilVersion, got %v, in %v", version, i) - } else if v.expectVersion >= 0 && version != uint(v.expectVersion) { t.Errorf("expected version %v, got %v, in %v", v.expectVersion, version, i) } @@ -1139,7 +1137,7 @@ func TestRead(t *testing.T) { } for i, v := range tt { - ret := make(chan interface{}) + ret := make(chan any) go m.read(v.from, v.to, ret) migrations, err := migrationsFromChannel(ret) @@ -1216,7 +1214,7 @@ func TestReadUp(t *testing.T) { } for i, v := range tt { - ret := make(chan interface{}) + ret := make(chan any) go m.readUp(v.from, v.limit, ret) migrations, err := migrationsFromChannel(ret) @@ -1293,7 +1291,7 @@ func TestReadDown(t *testing.T) { } for i, v := range tt { - ret := make(chan interface{}) + ret := make(chan any) go m.readDown(v.from, v.limit, ret) migrations, err := migrationsFromChannel(ret) @@ -1319,7 +1317,7 @@ func TestLock(t *testing.T) { } } -func migrationsFromChannel(ret chan interface{}) ([]*Migration, error) { +func migrationsFromChannel(ret chan any) ([]*Migration, error) { slice := make([]*Migration, 0) for r := range ret { switch t := r.(type) { @@ -1389,9 +1387,8 @@ func mr(value string) *Migration { func equalMigSeq(t *testing.T, i int, expected, got migrationSequence) { if len(expected) != len(got) { t.Errorf("expected migrations %v, got %v, in %v", expected, got, i) - } else { - for ii := 0; ii < len(expected); ii++ { + for ii := range expected { if expected[ii].Version != got[ii].Version { t.Errorf("expected version %v, got %v, in %v", expected[ii].Version, got[ii].Version, i) } diff --git a/migration.go b/migration.go index 0e733c639..f6636e3c3 100644 --- a/migration.go +++ b/migration.go @@ -75,7 +75,8 @@ type Migration struct { // last down migration, there is no next down migration, the targetVersion should // be nil. Nil in this case is represented by -1 (because type int). func NewMigration(body io.ReadCloser, identifier string, - version uint, targetVersion int) (*Migration, error) { + version uint, targetVersion int, +) (*Migration, error) { tnow := time.Now() m := &Migration{ Identifier: identifier, @@ -110,11 +111,26 @@ func (m *Migration) String() string { // LogString returns a string describing this migration to humans. func (m *Migration) LogString() string { - directionStr := "u" + return fmt.Sprintf("%v/%v %v", m.Version, m.Direction(), m.Identifier) +} + +// Direction returns "u" for an up migration and "d" for a down migration, +// matching the letter used in LogString. +func (m *Migration) Direction() string { if m.TargetVersion < int(m.Version) { - directionStr = "d" + return "d" + } + return "u" +} + +// LogArgs returns the migration's fields as alternating key/value pairs for +// structured logging (version, direction, identifier). +func (m *Migration) LogArgs() []any { + return []any{ + "version", m.Version, + "direction", m.Direction(), + "identifier", m.Identifier, } - return fmt.Sprintf("%v/%v %v", m.Version, directionStr, m.Identifier) } // Buffer buffers Body up to BufferSize. @@ -140,7 +156,6 @@ func (m *Migration) Buffer() (berr error) { if err := m.Body.Close(); err != nil { berr = errors.Join(berr, err) } - }() // start reading from body, peek won't move the read pointer though diff --git a/source/file/file_test.go b/source/file/file_test.go index 5680aa2a3..d81cfb96a 100644 --- a/source/file/file_test.go +++ b/source/file/file_test.go @@ -148,7 +148,7 @@ func TestClose(t *testing.T) { } func mustWriteFile(t testing.TB, dir, file string, body string) { - if err := os.WriteFile(path.Join(dir, file), []byte(body), 06444); err != nil { + if err := os.WriteFile(path.Join(dir, file), []byte(body), 0o6444); err != nil { t.Fatal(err) } } @@ -156,7 +156,7 @@ func mustWriteFile(t testing.TB, dir, file string, body string) { func mustCreateBenchmarkDir(t *testing.B) (dir string) { tmpDir := t.TempDir() - for i := 0; i < 1000; i++ { + for i := range 1000 { mustWriteFile(t, tmpDir, fmt.Sprintf("%v_foobar.up.sql", i), "") mustWriteFile(t, tmpDir, fmt.Sprintf("%v_foobar.down.sql", i), "") } diff --git a/source/go_bindata/examples/migrations/bindata.go b/source/go_bindata/examples/migrations/bindata.go index 0e18f2f7c..edce3c45e 100644 --- a/source/go_bindata/examples/migrations/bindata.go +++ b/source/go_bindata/examples/migrations/bindata.go @@ -54,19 +54,24 @@ type bindataFileInfo struct { func (fi bindataFileInfo) Name() string { return fi.name } + func (fi bindataFileInfo) Size() int64 { return fi.size } + func (fi bindataFileInfo) Mode() os.FileMode { return fi.mode } + func (fi bindataFileInfo) ModTime() time.Time { return fi.modTime } + func (fi bindataFileInfo) IsDir() bool { return false } -func (fi bindataFileInfo) Sys() interface{} { + +func (fi bindataFileInfo) Sys() any { return nil } @@ -227,8 +232,8 @@ func AssetDir(name string) ([]string, error) { node := _bintree if len(name) != 0 { cannonicalName := strings.Replace(name, "\\", "/", -1) - pathList := strings.Split(cannonicalName, "/") - for _, p := range pathList { + pathList := strings.SplitSeq(cannonicalName, "/") + for p := range pathList { node = node.Children[p] if node == nil { return nil, fmt.Errorf("Asset %s not found", name) @@ -251,10 +256,10 @@ type bintree struct { } var _bintree = &bintree{nil, map[string]*bintree{ - "1085649617_create_users_table.down.sql": &bintree{_1085649617_create_users_tableDownSql, map[string]*bintree{}}, - "1085649617_create_users_table.up.sql": &bintree{_1085649617_create_users_tableUpSql, map[string]*bintree{}}, - "1185749658_add_city_to_users.down.sql": &bintree{_1185749658_add_city_to_usersDownSql, map[string]*bintree{}}, - "1185749658_add_city_to_users.up.sql": &bintree{_1185749658_add_city_to_usersUpSql, map[string]*bintree{}}, + "1085649617_create_users_table.down.sql": {_1085649617_create_users_tableDownSql, map[string]*bintree{}}, + "1085649617_create_users_table.up.sql": {_1085649617_create_users_tableUpSql, map[string]*bintree{}}, + "1185749658_add_city_to_users.down.sql": {_1185749658_add_city_to_usersDownSql, map[string]*bintree{}}, + "1185749658_add_city_to_users.up.sql": {_1185749658_add_city_to_usersUpSql, map[string]*bintree{}}, }} // RestoreAsset restores an asset under the given directory @@ -267,7 +272,7 @@ func RestoreAsset(dir, name string) error { if err != nil { return err } - err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0o755)) if err != nil { return err } diff --git a/source/go_bindata/go-bindata.go b/source/go_bindata/go-bindata.go index d0d42f5af..8bedace83 100644 --- a/source/go_bindata/go-bindata.go +++ b/source/go_bindata/go-bindata.go @@ -37,11 +37,9 @@ func (b *Bindata) Open(url string) (source.Driver, error) { return nil, fmt.Errorf("not yet implemented") } -var ( - ErrNoAssetSource = fmt.Errorf("expects *AssetSource") -) +var ErrNoAssetSource = fmt.Errorf("expects *AssetSource") -func WithInstance(instance interface{}) (source.Driver, error) { +func WithInstance(instance any) (source.Driver, error) { if _, ok := instance.(*AssetSource); !ok { return nil, ErrNoAssetSource } diff --git a/source/migration.go b/source/migration.go index 74f6523cb..5ce82ff28 100644 --- a/source/migration.go +++ b/source/migration.go @@ -1,6 +1,7 @@ package source import ( + "slices" "sort" ) @@ -70,9 +71,7 @@ func (i *Migrations) buildIndex() { for version := range i.migrations { i.index = append(i.index, version) } - sort.Slice(i.index, func(x, y int) bool { - return i.index[x] < i.index[y] - }) + slices.Sort(i.index) } func (i *Migrations) First() (version uint, ok bool) { diff --git a/source/stub/stub.go b/source/stub/stub.go index ad2620ff7..cea9f6a9c 100644 --- a/source/stub/stub.go +++ b/source/stub/stub.go @@ -20,7 +20,7 @@ type Config struct{} type Stub struct { Url string - Instance interface{} + Instance any Migrations *source.Migrations Config *Config } @@ -33,7 +33,7 @@ func (s *Stub) Open(url string) (source.Driver, error) { }, nil } -func WithInstance(instance interface{}, config *Config) (source.Driver, error) { +func WithInstance(instance any, config *Config) (source.Driver, error) { return &Stub{ Instance: instance, Migrations: source.NewMigrations(), diff --git a/testing/testing.go b/testing/testing.go index 49cd77053..9c9c07712 100644 --- a/testing/testing.go +++ b/testing/testing.go @@ -27,13 +27,10 @@ func ParallelTest(t *testing.T, versions []Version, readyFn IsReadyFunc, testFn } for i, version := range versions { - version := version // capture range variable, see https://goo.gl/60w3p2 - // Only test against one version in short mode // TODO: order is random, maybe always pick first version instead? if i > 0 && testing.Short() { t.Logf("Skipping %v in short mode", version) - } else { t.Run(version.Image, func(t *testing.T) { t.Parallel()