Skip to content

Commit b85a9e8

Browse files
authored
Add additional tests and improve advanced example (#26)
* test: add additional tests * improve tests, fix migrate response and improve advanced example * update dependencies * test: add additional version change tests
1 parent b688f39 commit b85a9e8

File tree

9 files changed

+2174
-255
lines changed

9 files changed

+2174
-255
lines changed

cadwyn/integration_test.go

Lines changed: 815 additions & 0 deletions
Large diffs are not rendered by default.

cadwyn/middleware_test.go

Lines changed: 501 additions & 4 deletions
Large diffs are not rendered by default.

cadwyn/schema_generator.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"sync"
1313
)
1414

15+
// SchemaGenerator is currently WIP and not used in the project.
1516
// SchemaGenerator generates version-specific Go structs with advanced AST-like capabilities
1617
type SchemaGenerator struct {
1718
versionBundle *VersionBundle

cadwyn/version_bundle.go

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@ import (
66

77
// VersionBundle manages a collection of versions and their changes
88
type VersionBundle struct {
9-
headVersion *Version
10-
versions []*Version
11-
reversedVersions []*Version
12-
versionValues []string
13-
reversedVersionValues []string
9+
headVersion *Version
10+
versions []*Version
11+
versionValues []string
1412

1513
// All versions including head
1614
allVersions []*Version
@@ -46,11 +44,9 @@ func NewVersionBundle(versions []*Version) (*VersionBundle, error) {
4644
regularVersions = versions
4745
}
4846

49-
// Single pass: collect values, check duplicates, reverse, and build mappings
47+
// Single pass: collect values, check duplicates, and build mappings
5048
numVersions := len(regularVersions)
5149
versionValues := make([]string, numVersions)
52-
reversedVersions := make([]*Version, numVersions)
53-
reversedVersionValues := make([]string, numVersions)
5450
versionValuesSet := make(map[string]bool)
5551
versionChangesToVersionMapping := make(map[interface{}]string)
5652

@@ -66,11 +62,6 @@ func NewVersionBundle(versions []*Version) (*VersionBundle, error) {
6662
// Store values
6763
versionValues[i] = versionStr
6864

69-
// Reverse version arrays
70-
reversedIndex := numVersions - 1 - i
71-
reversedVersions[reversedIndex] = v
72-
reversedVersionValues[reversedIndex] = versionStr
73-
7465
// Build version changes mapping
7566
for _, change := range v.Changes {
7667
versionChangesToVersionMapping[change] = versionStr
@@ -94,9 +85,7 @@ func NewVersionBundle(versions []*Version) (*VersionBundle, error) {
9485
vb := &VersionBundle{
9586
headVersion: headVersion,
9687
versions: regularVersions,
97-
reversedVersions: reversedVersions,
9888
versionValues: versionValues,
99-
reversedVersionValues: reversedVersionValues,
10089
allVersions: allVersions,
10190
versionChangesToVersionMapping: versionChangesToVersionMapping,
10291
versionValuesSet: versionValuesSet,

cadwyn/version_change.go

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -271,44 +271,50 @@ func (mc *MigrationChain) MigrateRequest(ctx context.Context, requestInfo *Reque
271271

272272
// MigrateResponse applies all changes in reverse for response migration
273273
func (mc *MigrationChain) MigrateResponse(ctx context.Context, responseInfo *ResponseInfo, from, to *Version, responseType reflect.Type, routeID int) error {
274-
// Find the ending point in the version chain
275-
end := -1
276-
for i, change := range mc.changes {
277-
if change.ToVersion().Equal(from) || change.ToVersion().IsNewerThan(from) {
278-
end = i
274+
// If from and to are the same, no migration needed
275+
if from.Equal(to) {
276+
return nil
277+
}
278+
279+
// First, validate that 'from' version exists in the migration chain
280+
foundFromVersion := false
281+
for _, change := range mc.changes {
282+
if change.ToVersion().Equal(from) || change.FromVersion().Equal(from) {
283+
foundFromVersion = true
279284
break
280285
}
281286
}
282-
283-
if end == -1 {
284-
return fmt.Errorf("no migration path found from version %s (available changes: %d)",
285-
from.String(), len(mc.changes))
287+
if !foundFromVersion {
288+
return fmt.Errorf("no migration path found from version %s (version not in migration chain)",
289+
from.String())
286290
}
287291

288-
// Apply changes in reverse until we reach the target version
289-
for i := end; i >= 0; i-- {
290-
change := mc.changes[i]
291-
292-
// Stop if we've reached the target version
293-
if change.FromVersion().Equal(to) {
294-
if err := change.MigrateResponse(ctx, responseInfo, responseType, routeID); err != nil {
295-
return fmt.Errorf("reverse migration failed at %s->%s: %w",
296-
change.ToVersion().String(), change.FromVersion().String(), err)
297-
}
298-
break
292+
// Collect ALL changes that need to be applied for this migration
293+
// When migrating from 'from' (e.g. HEAD/v3) to 'to' (e.g. v2):
294+
// - Apply ALL changes where FromVersion==to (e.g. all v2->v3 changes)
295+
// - These are applied in reverse (as v3->v2) to step back one version
296+
var changesToApply []*VersionChange
297+
298+
for _, change := range mc.changes {
299+
// Apply change if FromVersion matches target AND ToVersion is in our path
300+
// This ensures we apply ALL migrations at the target version level
301+
if change.FromVersion().Equal(to) &&
302+
(change.ToVersion().Equal(from) || change.ToVersion().IsNewerThan(to)) {
303+
changesToApply = append(changesToApply, change)
299304
}
305+
}
300306

301-
// Stop if this change would take us past the target (going backwards)
302-
if change.FromVersion().IsOlderThan(to) {
303-
break
304-
}
307+
// If no changes found, return error
308+
if len(changesToApply) == 0 {
309+
return fmt.Errorf("no migration path found from version %s to %s",
310+
from.String(), to.String())
311+
}
305312

306-
// Apply this change if it's part of the migration path
307-
if change.ToVersion().IsNewerThan(to) || change.ToVersion().Equal(to) {
308-
if err := change.MigrateResponse(ctx, responseInfo, responseType, routeID); err != nil {
309-
return fmt.Errorf("reverse migration failed at %s->%s: %w",
310-
change.ToVersion().String(), change.FromVersion().String(), err)
311-
}
313+
// Apply all collected changes
314+
for _, change := range changesToApply {
315+
if err := change.MigrateResponse(ctx, responseInfo, responseType, routeID); err != nil {
316+
return fmt.Errorf("reverse migration failed at %s->%s: %w",
317+
change.ToVersion().String(), change.FromVersion().String(), err)
312318
}
313319
}
314320

cadwyn/version_change_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,5 +344,169 @@ var _ = Describe("MigrationChain", func() {
344344
Expect(err).To(HaveOccurred())
345345
Expect(err.Error()).To(ContainSubstring("no migration path found"))
346346
})
347+
348+
Context("with multiple changes at the same version level", func() {
349+
var multiChain *MigrationChain
350+
var userMigrationApplied, productMigrationApplied, orderMigrationApplied bool
351+
352+
// Define realistic schema types for testing
353+
type User struct {
354+
ID int
355+
FullName string
356+
Email string
357+
Phone string
358+
}
359+
360+
type Product struct {
361+
ID int
362+
Name string
363+
Price float64
364+
Description string
365+
Currency string
366+
}
367+
368+
type Order struct {
369+
ID int
370+
UserID int
371+
ProductID int
372+
Quantity int
373+
CreatedAt string
374+
}
375+
376+
BeforeEach(func() {
377+
userMigrationApplied = false
378+
productMigrationApplied = false
379+
orderMigrationApplied = false
380+
381+
// Create multiple changes all from v2 to v3 (like User, Product, Order migrations)
382+
// Simulate realistic backward migrations (v3 -> v2)
383+
384+
// User migration: v3 has "full_name" and "phone", v2 has "name" without phone
385+
userChange := NewVersionChange("User v2->v3: Add phone, rename name to full_name", v2, v3,
386+
&AlterResponseInstruction{
387+
Schemas: []interface{}{User{}},
388+
Transformer: func(resp *ResponseInfo) error {
389+
if bodyMap, ok := resp.Body.(map[string]interface{}); ok {
390+
// Backward migration: v3 -> v2
391+
if fullName, exists := bodyMap["full_name"]; exists {
392+
bodyMap["name"] = fullName
393+
delete(bodyMap, "full_name")
394+
}
395+
delete(bodyMap, "phone") // v2 doesn't have phone
396+
userMigrationApplied = true
397+
}
398+
return nil
399+
},
400+
},
401+
)
402+
403+
// Product migration: v3 has "description" and "currency", v2 has "desc" without currency
404+
productChange := NewVersionChange("Product v2->v3: Add currency, rename desc to description", v2, v3,
405+
&AlterResponseInstruction{
406+
Schemas: []interface{}{Product{}},
407+
Transformer: func(resp *ResponseInfo) error {
408+
if bodyMap, ok := resp.Body.(map[string]interface{}); ok {
409+
// Backward migration: v3 -> v2
410+
if description, exists := bodyMap["description"]; exists {
411+
bodyMap["desc"] = description
412+
delete(bodyMap, "description")
413+
}
414+
delete(bodyMap, "currency") // v2 doesn't have currency
415+
productMigrationApplied = true
416+
}
417+
return nil
418+
},
419+
},
420+
)
421+
422+
// Order migration: v3 has "created_at", v2 doesn't
423+
orderChange := NewVersionChange("Order v2->v3: Add created_at timestamp", v2, v3,
424+
&AlterResponseInstruction{
425+
Schemas: []interface{}{Order{}},
426+
Transformer: func(resp *ResponseInfo) error {
427+
if bodyMap, ok := resp.Body.(map[string]interface{}); ok {
428+
// Backward migration: v3 -> v2
429+
delete(bodyMap, "created_at") // v2 doesn't have created_at
430+
orderMigrationApplied = true
431+
}
432+
return nil
433+
},
434+
},
435+
)
436+
437+
multiChain = NewMigrationChain([]*VersionChange{userChange, productChange, orderChange})
438+
})
439+
440+
It("should apply ALL changes with the same FromVersion when migrating backward", func() {
441+
// Setup response data representing a v3 response with all fields
442+
c, _ := gin.CreateTestContext(httptest.NewRecorder())
443+
responseData := map[string]interface{}{
444+
// User fields (v3)
445+
"id": 1,
446+
"full_name": "Alice Johnson",
447+
"email": "[email protected]",
448+
"phone": "+1-555-0100",
449+
// Product fields (v3)
450+
"product_id": 100,
451+
"name": "Laptop",
452+
"price": 999.99,
453+
"description": "High-performance laptop",
454+
"currency": "USD",
455+
// Order fields (v3)
456+
"order_id": 1000,
457+
"user_id": 1,
458+
"quantity": 2,
459+
"created_at": "2024-01-01T00:00:00Z",
460+
}
461+
responseInfo := NewResponseInfo(c, responseData)
462+
463+
// Migrate from v3 to v2 - should apply all three v2->v3 changes in reverse
464+
err := multiChain.MigrateResponse(ctx, responseInfo, v3, v2, nil, 0)
465+
Expect(err).NotTo(HaveOccurred())
466+
467+
// Verify ALL three transformations were applied
468+
Expect(userMigrationApplied).To(BeTrue(), "User migration should be applied")
469+
Expect(productMigrationApplied).To(BeTrue(), "Product migration should be applied")
470+
Expect(orderMigrationApplied).To(BeTrue(), "Order migration should be applied")
471+
472+
// Verify the response body has all v2 transformations
473+
bodyMap := responseInfo.Body.(map[string]interface{})
474+
475+
// User fields: should have "name" instead of "full_name", no "phone"
476+
Expect(bodyMap["name"]).To(Equal("Alice Johnson"), "full_name should be renamed to name")
477+
Expect(bodyMap).NotTo(HaveKey("full_name"), "full_name should not exist in v2")
478+
Expect(bodyMap).NotTo(HaveKey("phone"), "phone should not exist in v2")
479+
Expect(bodyMap["email"]).To(Equal("[email protected]"), "email should remain")
480+
481+
// Product fields: should have "desc" instead of "description", no "currency"
482+
Expect(bodyMap["desc"]).To(Equal("High-performance laptop"), "description should be renamed to desc")
483+
Expect(bodyMap).NotTo(HaveKey("description"), "description should not exist in v2")
484+
Expect(bodyMap).NotTo(HaveKey("currency"), "currency should not exist in v2")
485+
Expect(bodyMap["price"]).To(Equal(999.99), "price should remain")
486+
487+
// Order fields: should not have "created_at"
488+
Expect(bodyMap).NotTo(HaveKey("created_at"), "created_at should not exist in v2")
489+
Expect(bodyMap["quantity"]).To(Equal(2), "quantity should remain")
490+
})
491+
492+
It("should collect multiple changes at the same version level via GetMigrationPath", func() {
493+
// GetMigrationPath should return all 3 changes when going from v3 to v2
494+
path := multiChain.GetMigrationPath(v3, v2)
495+
Expect(path).To(HaveLen(3), "should include all changes from v2->v3")
496+
497+
// Verify all changes are included by checking their descriptions
498+
descriptions := []string{}
499+
for _, change := range path {
500+
descriptions = append(descriptions, change.Description())
501+
}
502+
503+
Expect(descriptions).To(ContainElement("User v2->v3: Add phone, rename name to full_name"),
504+
"User migration should be in the path")
505+
Expect(descriptions).To(ContainElement("Product v2->v3: Add currency, rename desc to description"),
506+
"Product migration should be in the path")
507+
Expect(descriptions).To(ContainElement("Order v2->v3: Add created_at timestamp"),
508+
"Order migration should be in the path")
509+
})
510+
})
347511
})
348512
})

0 commit comments

Comments
 (0)