From 38f18fe05bac38e109a59d77005c6761299fb075 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Fri, 20 Feb 2026 04:15:26 +0000 Subject: [PATCH 1/4] feat: improve api for migration registration --- requestmigrations.go | 179 ++++++++++++++++++-------- requestmigrations_test.go | 261 ++++++++++++++++++++------------------ 2 files changed, 262 insertions(+), 178 deletions(-) diff --git a/requestmigrations.go b/requestmigrations.go index 0370e3c..0126169 100644 --- a/requestmigrations.go +++ b/requestmigrations.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "reflect" "sort" @@ -27,6 +28,8 @@ var ( ErrInvalidVersionFormat = errors.New("invalid version format") ErrCurrentVersionCannotBeEmpty = errors.New("current version field cannot be empty") ErrNativeTypeMigration = errors.New("cannot register migration for native Go type; use a custom type alias instead (e.g., 'type MyString string')") + ErrAlreadyBuilt = errors.New("cannot register migrations after Build() has been called") + ErrNotBuilt = errors.New("must call Build() before using RequestMigration") ) type userVersionKey struct{} @@ -73,11 +76,13 @@ type RequestMigration struct { metric *prometheus.HistogramVec iv string - mu *sync.RWMutex migrations map[reflect.Type]map[string]TypeMigration // type -> version -> migration graphBuilder *typeGraphBuilder graphCache sync.Map + + built bool + err error } func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, error) { @@ -110,7 +115,6 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro metric: me, iv: iv, versions: versions, - mu: new(sync.RWMutex), migrations: make(map[reflect.Type]map[string]TypeMigration), } @@ -121,6 +125,10 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro // For creates a request-scoped Migrator for performing migrations. func (rm *RequestMigration) For(r *http.Request) (*Migrator, error) { + if !rm.built { + return nil, ErrNotBuilt + } + if r == nil { return nil, errors.New("request cannot be nil") } @@ -168,9 +176,6 @@ func (rm *RequestMigration) WriteVersionHeader() func(next http.Handler) http.Ha // FindMigrationsForType returns all migrations applicable to a type from a given version forward. func (rm *RequestMigration) FindMigrationsForType(t reflect.Type, userVersion *Version) []TypeMigration { - rm.mu.RLock() - defer rm.mu.RUnlock() - var applicableMigrations []TypeMigration typeHistory, ok := rm.migrations[t] @@ -243,17 +248,11 @@ func (rm *RequestMigration) observeRequestLatency(from, to *Version, sT time.Tim h.Observe(latency.Seconds()) } -func (rm *RequestMigration) registerTypeMigration(version string, t reflect.Type, m TypeMigration) error { - // Copy versions for graph building (done outside the lock) - var versionsCopy []*Version - - rm.mu.Lock() - +func (rm *RequestMigration) registerTypeMigration(version string, t reflect.Type, m TypeMigration) { if rm.migrations == nil { rm.migrations = make(map[reflect.Type]map[string]TypeMigration) } - // Check if this version is already known versionKnown := false for _, v := range rm.versions { if v.Value == version { @@ -264,39 +263,16 @@ func (rm *RequestMigration) registerTypeMigration(version string, t reflect.Type if !versionKnown { rm.versions = append(rm.versions, &Version{Format: rm.opts.VersionFormat, Value: version}) - - switch rm.opts.VersionFormat { - case SemverFormat: - sort.Slice(rm.versions, semVerSorter(rm.versions)) - case DateFormat: - sort.Slice(rm.versions, dateVersionSorter(rm.versions)) - default: - rm.mu.Unlock() - return ErrInvalidVersionFormat - } } - // Internal Type-Centric Pivot: map[Type]map[Version]Migration if _, ok := rm.migrations[t]; !ok { rm.migrations[t] = make(map[string]TypeMigration) } rm.migrations[t][version] = m - - // Copy versions for graph building outside the lock - versionsCopy = make([]*Version, len(rm.versions)) - copy(versionsCopy, rm.versions) - - rm.mu.Unlock() - - // Eagerly build and cache graphs for this type across all known versions - // This is done outside the write lock since building only needs read access - rm.buildAndCacheGraphsForType(t, versionsCopy) - - return nil } // buildAndCacheGraphsForType builds and caches type graphs for all known versions. -// Called during registration to eagerly populate the cache. +// Called during Build to eagerly populate the cache. // Types with interface fields are skipped - they require runtime value inspection // and will be built lazily via buildFromValue. func (rm *RequestMigration) buildAndCacheGraphsForType(t reflect.Type, versions []*Version) { @@ -323,6 +299,52 @@ func (rm *RequestMigration) buildAndCacheGraphsForType(t reflect.Type, versions } } +// readBody converts v to a generic JSON representation (map/slice/primitive) +// by streaming the encoding directly into the decoder via an io.Pipe, +// avoiding a full intermediate []byte allocation. +func readBody(v any) (any, error) { + pr, pw := io.Pipe() + + var result any + errCh := make(chan error, 1) + go func() { + errCh <- json.NewDecoder(pr).Decode(&result) + }() + + if err := json.NewEncoder(pw).Encode(v); err != nil { + pw.CloseWithError(err) + <-errCh + return nil, err + } + pw.Close() + + if err := <-errCh; err != nil { + return nil, err + } + + return result, nil +} + +// writeBody streams a generic JSON representation into the typed destination v, +// avoiding a full intermediate []byte allocation. +func writeBody(src any, dst any) error { + pr, pw := io.Pipe() + + errCh := make(chan error, 1) + go func() { + errCh <- json.NewDecoder(pr).Decode(dst) + }() + + if err := json.NewEncoder(pw).Encode(src); err != nil { + pw.CloseWithError(err) + <-errCh + return err + } + pw.Close() + + return <-errCh +} + // Migrator is a request-scoped handle for performing migrations. type Migrator struct { rm *RequestMigration @@ -344,16 +366,11 @@ func (m *Migrator) Marshal(v interface{}) ([]byte, error) { currentVersion := m.rm.getCurrentVersion() - data, err := json.Marshal(v) + intermediate, err := readBody(v) if err != nil { return nil, err } - var intermediate any - if err := json.Unmarshal(data, &intermediate); err != nil { - return nil, err - } - if err := graph.MigrateBackward(m.ctx, &intermediate); err != nil { return nil, err } @@ -408,12 +425,7 @@ func (m *Migrator) Unmarshal(data []byte, v interface{}) error { return err } - data, err := json.Marshal(intermediate) - if err != nil { - return err - } - - if err := json.Unmarshal(data, v); err != nil { + if err := writeBody(intermediate, v); err != nil { return err } @@ -702,14 +714,75 @@ func (b *typeGraphBuilder) walkValue(v reflect.Value, userVersion *Version, visi return graph, nil } -func Register[T any](rm *RequestMigration, version string, m TypeMigration) error { - t := reflect.TypeOf((*T)(nil)).Elem() - if !isValidMigrationType(t) { - return ErrNativeTypeMigration +// VersionedTypeMigration pairs a type with a version and its migration logic. +// Construct using the Migration generic helper. +type VersionedTypeMigration struct { + version string + t reflect.Type + migration TypeMigration +} + +// Migration creates a VersionedTypeMigration entry for type T. +func Migration[T any](version string, m TypeMigration) VersionedTypeMigration { + return VersionedTypeMigration{ + version: version, + t: reflect.TypeOf((*T)(nil)).Elem(), + migration: m, + } +} + +// Register adds one or more type migrations. Returns rm for chaining. +// Errors are accumulated and surfaced when Build is called. +func (rm *RequestMigration) Register(migrations ...VersionedTypeMigration) *RequestMigration { + if rm.err != nil { + return rm + } + + if rm.built { + rm.err = ErrAlreadyBuilt + return rm + } + + for _, entry := range migrations { + if !isValidMigrationType(entry.t) { + rm.err = ErrNativeTypeMigration + return rm + } + rm.registerTypeMigration(entry.version, entry.t, entry.migration) + } + + return rm +} + +// Build sorts versions, eagerly builds type graphs, and marks the instance as +// ready for use. Must be called after all Register calls and before For/Bind. +func (rm *RequestMigration) Build() error { + if rm.err != nil { + return rm.err + } + + if rm.built { + return ErrAlreadyBuilt + } + + switch rm.opts.VersionFormat { + case SemverFormat: + sort.Slice(rm.versions, semVerSorter(rm.versions)) + case DateFormat: + sort.Slice(rm.versions, dateVersionSorter(rm.versions)) + default: + return ErrInvalidVersionFormat + } + + for t := range rm.migrations { + rm.buildAndCacheGraphsForType(t, rm.versions) } - return rm.registerTypeMigration(version, t, m) + + rm.built = true + return nil } + // isValidMigrationType returns true ONLY if the type is a user-defined named type. // It blocks built-in primitives (string, int) AND unnamed composites ([]string, map[int]int). // diff --git a/requestmigrations_test.go b/requestmigrations_test.go index 11f42e7..0cd76af 100644 --- a/requestmigrations_test.go +++ b/requestmigrations_test.go @@ -76,9 +76,9 @@ func newRequestMigration(t *testing.T) *RequestMigration { } func registerVersions(t *testing.T, rm *RequestMigration) { - // Register migrations for version 2023-03-01 - err := Register[address](rm, "2023-03-01", &addressMigration{}) - + err := rm.Register( + Migration[address]("2023-03-01", &addressMigration{}), + ).Build() if err != nil { t.Fatal(err) } @@ -185,7 +185,10 @@ func Test_CustomPrimitive(t *testing.T) { VersionFormat: DateFormat, } rm, _ := NewRequestMigration(opts) - Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err := rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() + require.NoError(t, err) tests := []struct { name string @@ -256,54 +259,52 @@ func Test_MigrationTypeValidation(t *testing.T) { CurrentVersion: "2023-03-01", VersionFormat: DateFormat, } - rm, err := NewRequestMigration(opts) - require.NoError(t, err) tests := []struct { name string - registerFunc func() error + registerFunc func(rm *RequestMigration) error wantErr bool }{ // Built-in primitives - should be REJECTED { name: "rejects native string type", - registerFunc: func() error { - return Register[string](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[string]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects native int type", - registerFunc: func() error { - return Register[int](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[int]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects native bool type", - registerFunc: func() error { - return Register[bool](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[bool]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects native float64 type", - registerFunc: func() error { - return Register[float64](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[float64]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects native int64 type", - registerFunc: func() error { - return Register[int64](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[int64]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects native uint type", - registerFunc: func() error { - return Register[uint](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[uint]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, @@ -311,50 +312,50 @@ func Test_MigrationTypeValidation(t *testing.T) { // Unnamed composite types - should be REJECTED { name: "rejects unnamed string slice", - registerFunc: func() error { - return Register[[]string](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[[]string]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects unnamed int slice", - registerFunc: func() error { - return Register[[]int](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[[]int]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects unnamed byte slice", - registerFunc: func() error { - return Register[[]byte](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[[]byte]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects unnamed map string to string", - registerFunc: func() error { - return Register[map[string]string](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[map[string]string]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects unnamed map string to int", - registerFunc: func() error { - return Register[map[string]int](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[map[string]int]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects unnamed interface type", - registerFunc: func() error { - return Register[interface{}](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[interface{}]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, { name: "rejects error interface type", - registerFunc: func() error { - return Register[error](rm, "2023-03-01", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[error]("2023-03-01", &dummyMigration{})).Build() }, wantErr: true, }, @@ -362,36 +363,36 @@ func Test_MigrationTypeValidation(t *testing.T) { // User-defined named types - should be ALLOWED { name: "allows custom string type alias", - registerFunc: func() error { - return Register[AddressString](rm, "2023-02-15", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[AddressString]("2023-02-15", &dummyMigration{})).Build() }, wantErr: false, }, { name: "allows struct type", - registerFunc: func() error { - return Register[profile](rm, "2023-02-15", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[profile]("2023-02-15", &dummyMigration{})).Build() }, wantErr: false, }, { name: "allows named slice type", - registerFunc: func() error { - return Register[NamedSlice](rm, "2023-02-15", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[NamedSlice]("2023-02-15", &dummyMigration{})).Build() }, wantErr: false, }, { name: "allows named map type", - registerFunc: func() error { - return Register[NamedMap](rm, "2023-02-15", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[NamedMap]("2023-02-15", &dummyMigration{})).Build() }, wantErr: false, }, { name: "allows named int slice type", - registerFunc: func() error { - return Register[NamedIntSlice](rm, "2023-02-15", &dummyMigration{}) + registerFunc: func(rm *RequestMigration) error { + return rm.Register(Migration[NamedIntSlice]("2023-02-15", &dummyMigration{})).Build() }, wantErr: false, }, @@ -399,7 +400,10 @@ func Test_MigrationTypeValidation(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - err := tc.registerFunc() + rm, err := NewRequestMigration(opts) + require.NoError(t, err) + + err = tc.registerFunc(rm) if tc.wantErr { require.Error(t, err) require.ErrorIs(t, err, ErrNativeTypeMigration) @@ -485,16 +489,13 @@ func Test_Cycles(t *testing.T) { VersionFormat: DateFormat, } - t.Run("build graph with cycles", func(t *testing.T) { - rm, _ := NewRequestMigration(opts) - _, err := rm.graphBuilder.buildFromType(reflect.TypeOf(CyclicUser{}), &Version{Format: DateFormat, Value: "2023-01-01"}) - require.NoError(t, err) - }) - t.Run("marshal cyclic structure with migrations", func(t *testing.T) { rm, _ := NewRequestMigration(opts) - Register[CyclicUser](rm, "2023-03-01", &cyclicUserMigration{}) - Register[CyclicWorkspace](rm, "2023-03-01", &cyclicWorkspaceMigration{}) + err := rm.Register( + Migration[CyclicUser]("2023-03-01", &cyclicUserMigration{}), + Migration[CyclicWorkspace]("2023-03-01", &cyclicWorkspaceMigration{}), + ).Build() + require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("X-Test-Version", "2023-01-01") // old version @@ -539,8 +540,11 @@ func Test_Cycles(t *testing.T) { t.Run("unmarshal cyclic structure with migrations", func(t *testing.T) { rm, _ := NewRequestMigration(opts) - Register[CyclicUser](rm, "2023-03-01", &cyclicUserMigration{}) - Register[CyclicWorkspace](rm, "2023-03-01", &cyclicWorkspaceMigration{}) + err := rm.Register( + Migration[CyclicUser]("2023-03-01", &cyclicUserMigration{}), + Migration[CyclicWorkspace]("2023-03-01", &cyclicWorkspaceMigration{}), + ).Build() + require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "/", nil) req.Header.Add("X-Test-Version", "2023-01-01") // old version @@ -582,8 +586,11 @@ func Test_Cycles(t *testing.T) { t.Run("deeply nested cycles", func(t *testing.T) { rm, _ := NewRequestMigration(opts) - Register[CyclicUser](rm, "2023-03-01", &cyclicUserMigration{}) - Register[CyclicWorkspace](rm, "2023-03-01", &cyclicWorkspaceMigration{}) + err := rm.Register( + Migration[CyclicUser]("2023-03-01", &cyclicUserMigration{}), + Migration[CyclicWorkspace]("2023-03-01", &cyclicWorkspaceMigration{}), + ).Build() + require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("X-Test-Version", "2023-01-01") @@ -649,7 +656,10 @@ func Test_RootSlice(t *testing.T) { VersionFormat: DateFormat, } rm, _ := NewRequestMigration(opts) - Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err := rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() + require.NoError(t, err) tests := []struct { name string @@ -752,9 +762,12 @@ func Test_VersionChain(t *testing.T) { // v1: 2023-01-01 (initial version, no migrations) // v2: 2023-02-01 - Register[AddressString](rm, "2023-02-01", &chainMigrationV2{}) // v3: 2023-03-01 - Register[AddressString](rm, "2023-03-01", &chainMigrationV3{}) + err := rm.Register( + Migration[AddressString]("2023-02-01", &chainMigrationV2{}), + Migration[AddressString]("2023-03-01", &chainMigrationV3{}), + ).Build() + require.NoError(t, err) t.Run("Marshal chain v3 to v1", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -841,7 +854,9 @@ func Test_InterfaceFieldMigration(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - err = Register[EndpointResponse](rm, "2024-01-01", &endpointMigration{}) + err = rm.Register( + Migration[EndpointResponse]("2024-01-01", &endpointMigration{}), + ).Build() require.NoError(t, err) t.Run("Marshal single item in interface field", func(t *testing.T) { @@ -990,7 +1005,9 @@ func Test_NestedInterfaceSliceMigration(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - err = Register[EndpointResponse](rm, "2024-01-01", &endpointMigration{}) + err = rm.Register( + Migration[EndpointResponse]("2024-01-01", &endpointMigration{}), + ).Build() require.NoError(t, err) t.Run("Marshal pointer slice in interface field", func(t *testing.T) { @@ -1030,7 +1047,10 @@ func Test_NestedPointers(t *testing.T) { VersionFormat: DateFormat, } rm, _ := NewRequestMigration(opts) - Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err := rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() + require.NoError(t, err) t.Run("Marshal with double pointer", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -1114,6 +1134,9 @@ func Test_ForNilRequest(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) + err = rm.Build() + require.NoError(t, err) + t.Run("For returns error on nil request", func(t *testing.T) { migrator, err := rm.For(nil) require.Error(t, err) @@ -1131,6 +1154,9 @@ func Test_BindAlias(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) + err = rm.Build() + require.NoError(t, err) + t.Run("Bind is alias for For", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("X-Test-Version", "2023-02-01") @@ -1141,50 +1167,49 @@ func Test_BindAlias(t *testing.T) { }) } -// Test_EagerGraphBuilding tests that graphs are built at registration time +// Test_EagerGraphBuilding tests that graphs are built at Build time func Test_EagerGraphBuilding(t *testing.T) { opts := &RequestMigrationOptions{ VersionHeader: "X-Test-Version", CurrentVersion: "2023-03-01", VersionFormat: DateFormat, } - rm, err := NewRequestMigration(opts) - require.NoError(t, err) - t.Run("graph is cached after registration", func(t *testing.T) { - // Register a migration - err := Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + t.Run("graph is cached after Build", func(t *testing.T) { + rm, err := NewRequestMigration(opts) + require.NoError(t, err) + + err = rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() require.NoError(t, err) - // Check that the graph was cached for all known versions - // The versions are: initial (0001-01-01) and 2023-03-01 key := graphCacheKey{ t: reflect.TypeOf(AddressString("")), version: "2023-03-01", } cached, ok := rm.graphCache.Load(key) - require.True(t, ok, "graph should be cached after registration") + require.True(t, ok, "graph should be cached after Build") require.NotNil(t, cached) graph := cached.(*typeGraph) require.NotNil(t, graph) }) - t.Run("immediate use after registration - no Finalize needed", func(t *testing.T) { - // Create fresh instance - rm2, err := NewRequestMigration(opts) + t.Run("use after Build", func(t *testing.T) { + rm, err := NewRequestMigration(opts) require.NoError(t, err) - // Register migration - err = Register[AddressString](rm2, "2023-03-01", &addressStringMigration{}) + err = rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() require.NoError(t, err) - // Use immediately - should work without any Finalize() call req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("X-Test-Version", "2023-02-01") - migrator, err := rm2.For(req) + migrator, err := rm.For(req) require.NoError(t, err) u := CustomUser{Address: "123 Main St"} @@ -1207,8 +1232,9 @@ func Test_LazyFallback(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - // Only register AddressString migration, NOT any container type - err = Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err = rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() require.NoError(t, err) t.Run("unregistered container with registered field - marshal", func(t *testing.T) { @@ -1289,7 +1315,7 @@ func Test_LazyFallback(t *testing.T) { }) } -// Test_ConcurrentAccess tests that concurrent access is safe +// Test_ConcurrentAccess tests that concurrent access is safe after Build func Test_ConcurrentAccess(t *testing.T) { opts := &RequestMigrationOptions{ VersionHeader: "X-Test-Version", @@ -1301,7 +1327,9 @@ func Test_ConcurrentAccess(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - err = Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err = rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -1310,7 +1338,6 @@ func Test_ConcurrentAccess(t *testing.T) { migrator, err := rm.For(req) require.NoError(t, err) - // Concurrent marshals should work safely var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) @@ -1328,7 +1355,9 @@ func Test_ConcurrentAccess(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - err = Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err = rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "/", nil) @@ -1337,13 +1366,10 @@ func Test_ConcurrentAccess(t *testing.T) { migrator, err := rm.For(req) require.NoError(t, err) - // Type defined inside test — never registered type ConcurrentUnregistered struct { Address AddressString `json:"address"` } - // Multiple goroutines hitting lazy fallback for same unregistered type - // Tests that idempotent builds + sync.Map handle races correctly var wg sync.WaitGroup for i := 0; i < 50; i++ { wg.Add(1) @@ -1359,41 +1385,17 @@ func Test_ConcurrentAccess(t *testing.T) { wg.Wait() }) - t.Run("concurrent registration and use", func(t *testing.T) { + t.Run("For returns error before Build", func(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - var wg sync.WaitGroup - - // Start registration goroutines - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - // Registration may happen multiple times (idempotent) - Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) - }() - } - - // Start usage goroutines concurrently with registration - for i := 0; i < 50; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Add("X-Test-Version", "2023-02-01") + rm.Register(Migration[AddressString]("2023-03-01", &addressStringMigration{})) - migrator, err := rm.For(req) - if err != nil { - return // Skip if version not ready yet - } - - u := CustomUser{Address: AddressString(fmt.Sprintf("Street %d", idx))} - _, _ = migrator.Marshal(&u) // May or may not have migration applied - }(i) - } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-Test-Version", "2023-02-01") - wg.Wait() + _, err = rm.For(req) + require.ErrorIs(t, err, ErrNotBuilt) }) } @@ -1407,8 +1409,9 @@ func Test_MarshalCacheLookup(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - // Register migration - this builds and caches graph for AddressString - err = Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err = rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() require.NoError(t, err) t.Run("marshal uses cached graph for registered type", func(t *testing.T) { @@ -1438,14 +1441,19 @@ func Test_MarshalCacheLookup(t *testing.T) { }) t.Run("marshal still works for types with interface fields", func(t *testing.T) { - // PagedResponse has interface{} field, so it needs runtime inspection - err := Register[EndpointResponse](rm, "2024-01-01", &endpointMigration{}) + rm2, err := NewRequestMigration(opts) + require.NoError(t, err) + + err = rm2.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + Migration[EndpointResponse]("2024-01-01", &endpointMigration{}), + ).Build() require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("X-Test-Version", "2023-01-01") - migrator, err := rm.For(req) + migrator, err := rm2.For(req) require.NoError(t, err) wrapper := &PagedResponse{ @@ -1473,7 +1481,9 @@ func Test_NoMigrationFastPath(t *testing.T) { rm, err := NewRequestMigration(opts) require.NoError(t, err) - err = Register[AddressString](rm, "2023-03-01", &addressStringMigration{}) + err = rm.Register( + Migration[AddressString]("2023-03-01", &addressStringMigration{}), + ).Build() require.NoError(t, err) t.Run("current version marshal - no transformation", func(t *testing.T) { @@ -1560,8 +1570,9 @@ func Test_GenericTypes(t *testing.T) { rm, _ := NewRequestMigration(opts) - // Register migration for the nested Product type - err := Register[Product](rm, "2023-03-01", &productMigration{}) + err := rm.Register( + Migration[Product]("2023-03-01", &productMigration{}), + ).Build() require.NoError(t, err) t.Run("generic type with migrated nested type - marshal", func(t *testing.T) { From 175084a6792c33dfb306ab002ed10ac305d2d2f0 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Fri, 20 Feb 2026 04:31:36 +0000 Subject: [PATCH 2/4] chore: clean up --- requestmigrations.go | 195 +++++++++++++++++++++---------------------- 1 file changed, 97 insertions(+), 98 deletions(-) diff --git a/requestmigrations.go b/requestmigrations.go index 0126169..da3598f 100644 --- a/requestmigrations.go +++ b/requestmigrations.go @@ -197,6 +197,57 @@ func (rm *RequestMigration) FindMigrationsForType(t reflect.Type, userVersion *V return applicableMigrations } +// Register adds one or more type migrations. Returns rm for chaining. +// Errors are accumulated and surfaced when Build is called. +func (rm *RequestMigration) Register(migrations ...VersionedTypeMigration) *RequestMigration { + if rm.err != nil { + return rm + } + + if rm.built { + rm.err = ErrAlreadyBuilt + return rm + } + + for _, entry := range migrations { + if !isValidMigrationType(entry.t) { + rm.err = ErrNativeTypeMigration + return rm + } + rm.registerTypeMigration(entry.version, entry.t, entry.migration) + } + + return rm +} + +// Build sorts versions, eagerly builds type graphs, and marks the instance as +// ready for use. Must be called after all Register calls and before For/Bind. +func (rm *RequestMigration) Build() error { + if rm.err != nil { + return rm.err + } + + if rm.built { + return ErrAlreadyBuilt + } + + switch rm.opts.VersionFormat { + case SemverFormat: + sort.Slice(rm.versions, semVerSorter(rm.versions)) + case DateFormat: + sort.Slice(rm.versions, dateVersionSorter(rm.versions)) + default: + return ErrInvalidVersionFormat + } + + for t := range rm.migrations { + rm.buildAndCacheGraphsForType(t, rm.versions) + } + + rm.built = true + return nil +} + func (rm *RequestMigration) getUserVersion(req *http.Request) (*Version, error) { var vh = req.Header.Get(rm.opts.VersionHeader) @@ -299,52 +350,6 @@ func (rm *RequestMigration) buildAndCacheGraphsForType(t reflect.Type, versions } } -// readBody converts v to a generic JSON representation (map/slice/primitive) -// by streaming the encoding directly into the decoder via an io.Pipe, -// avoiding a full intermediate []byte allocation. -func readBody(v any) (any, error) { - pr, pw := io.Pipe() - - var result any - errCh := make(chan error, 1) - go func() { - errCh <- json.NewDecoder(pr).Decode(&result) - }() - - if err := json.NewEncoder(pw).Encode(v); err != nil { - pw.CloseWithError(err) - <-errCh - return nil, err - } - pw.Close() - - if err := <-errCh; err != nil { - return nil, err - } - - return result, nil -} - -// writeBody streams a generic JSON representation into the typed destination v, -// avoiding a full intermediate []byte allocation. -func writeBody(src any, dst any) error { - pr, pw := io.Pipe() - - errCh := make(chan error, 1) - go func() { - errCh <- json.NewDecoder(pr).Decode(dst) - }() - - if err := json.NewEncoder(pw).Encode(src); err != nil { - pw.CloseWithError(err) - <-errCh - return err - } - pw.Close() - - return <-errCh -} - // Migrator is a request-scoped handle for performing migrations. type Migrator struct { rm *RequestMigration @@ -731,58 +736,6 @@ func Migration[T any](version string, m TypeMigration) VersionedTypeMigration { } } -// Register adds one or more type migrations. Returns rm for chaining. -// Errors are accumulated and surfaced when Build is called. -func (rm *RequestMigration) Register(migrations ...VersionedTypeMigration) *RequestMigration { - if rm.err != nil { - return rm - } - - if rm.built { - rm.err = ErrAlreadyBuilt - return rm - } - - for _, entry := range migrations { - if !isValidMigrationType(entry.t) { - rm.err = ErrNativeTypeMigration - return rm - } - rm.registerTypeMigration(entry.version, entry.t, entry.migration) - } - - return rm -} - -// Build sorts versions, eagerly builds type graphs, and marks the instance as -// ready for use. Must be called after all Register calls and before For/Bind. -func (rm *RequestMigration) Build() error { - if rm.err != nil { - return rm.err - } - - if rm.built { - return ErrAlreadyBuilt - } - - switch rm.opts.VersionFormat { - case SemverFormat: - sort.Slice(rm.versions, semVerSorter(rm.versions)) - case DateFormat: - sort.Slice(rm.versions, dateVersionSorter(rm.versions)) - default: - return ErrInvalidVersionFormat - } - - for t := range rm.migrations { - rm.buildAndCacheGraphsForType(t, rm.versions) - } - - rm.built = true - return nil -} - - // isValidMigrationType returns true ONLY if the type is a user-defined named type. // It blocks built-in primitives (string, int) AND unnamed composites ([]string, map[int]int). // @@ -803,3 +756,49 @@ func isValidMigrationType(t reflect.Type) bool { return true } + +// readBody converts v to a generic JSON representation (map/slice/primitive) +// by streaming the encoding directly into the decoder via an io.Pipe, +// avoiding a full intermediate []byte allocation. +func readBody(v any) (any, error) { + pr, pw := io.Pipe() + + var result any + errCh := make(chan error, 1) + go func() { + errCh <- json.NewDecoder(pr).Decode(&result) + }() + + if err := json.NewEncoder(pw).Encode(v); err != nil { + pw.CloseWithError(err) + <-errCh + return nil, err + } + pw.Close() + + if err := <-errCh; err != nil { + return nil, err + } + + return result, nil +} + +// writeBody streams a generic JSON representation into the typed destination v, +// avoiding a full intermediate []byte allocation. +func writeBody(src, dst any) error { + pr, pw := io.Pipe() + + errCh := make(chan error, 1) + go func() { + errCh <- json.NewDecoder(pr).Decode(dst) + }() + + if err := json.NewEncoder(pw).Encode(src); err != nil { + pw.CloseWithError(err) + <-errCh + return err + } + pw.Close() + + return <-errCh +} From 6da629d30efc6a66df6919c533488a76334ce604 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Fri, 20 Feb 2026 04:34:52 +0000 Subject: [PATCH 3/4] chore: add docs --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a72a948..04e9ab0 100644 --- a/README.md +++ b/README.md @@ -25,18 +25,27 @@ RequestMigrations introduces a **type-based migration system**. Instead of defin package main import ( + "log" + rms "github.com/subomi/requestmigrations/v2" ) func main() { rm, _ := rms.NewRequestMigration(&rms.RequestMigrationOptions{ VersionHeader: "X-API-Version", - CurrentVersion: "2024-01-01", + CurrentVersion: "2024-06-01", VersionFormat: rms.DateFormat, }) - // Register migrations for a specific type - rms.Register[User](rm, "2024-01-01", &UserMigration{}) + // Register all migrations, then build. + err := rm.Register( + rms.Migration[User]("2024-01-01", &UserV1Migration{}), + rms.Migration[User]("2024-06-01", &UserV2Migration{}), + rms.Migration[Address]("2024-06-01", &AddressMigration{}), + ).Build() + if err != nil { + log.Fatal(err) + } } ``` From 34e525752f849837dd6b2574a4d14516a384c596 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Fri, 20 Feb 2026 04:37:37 +0000 Subject: [PATCH 4/4] chore: update examples --- examples/advanced/main.go | 10 +++++++--- examples/basic/main.go | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/examples/advanced/main.go b/examples/advanced/main.go index 21a09d8..dad674c 100644 --- a/examples/advanced/main.go +++ b/examples/advanced/main.go @@ -95,9 +95,13 @@ func main() { log.Fatal(err) } - // Register migrations across versions - rms.Register[User](rm, "2023-06-01", &UserMigrationV20230601{}) - rms.Register[Workspace](rm, "2024-01-01", &WorkspaceMigrationV20240101{}) + err = rm.Register( + rms.Migration[User]("2023-06-01", &UserMigrationV20230601{}), + rms.Migration[Workspace]("2024-01-01", &WorkspaceMigrationV20240101{}), + ).Build() + if err != nil { + log.Fatal(err) + } // --- Scenario: Backward Migration (Marshal) --- // Current data structure diff --git a/examples/basic/main.go b/examples/basic/main.go index 8d4565c..9518df7 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -27,9 +27,13 @@ func main() { log.Fatal(err) } - // Register migrations for the User and profile types - rms.Register[User](rm, "2023-05-01", &UserMigration{}) - rms.Register[profile](rm, "2023-05-01", &ProfileMigration{}) + err = rm.Register( + rms.Migration[User]("2023-05-01", &UserMigration{}), + rms.Migration[profile]("2023-05-01", &ProfileMigration{}), + ).Build() + if err != nil { + log.Fatal(err) + } api := &API{rm: rm, store: userStore} backend := http.Server{