Summary
Resolves #519 - Schema Migration System for ZIO Schema 2
/claim #519
This PR implements a comprehensive schema migration system for ZIO Schema 2, addressing issue #519. The implementation provides pure, serializable migrations that enable safe, reversible transformations of data structures as schemas evolve over time.
Key Features
- Pure Data Migrations: Migrations are represented as data structures rather than functions, enabling serialization, introspection, and automatic reversal
- Type-Safe API: Compile-time checking with schemas ensures correctness
- Macro Validation: The
build() method validates at compile-time that migrations are complete, catching missing field mappings before runtime
- Deep Nesting Support: Navigate and transform arbitrarily nested structures up to 7+ levels deep
- Comprehensive Action Set: 14 migration action types covering records, variants, and collections
- Automatic Reversal: Every migration has a structural inverse that can be generated automatically
- Composable: Combine migrations using the
++ operator for complex transformations
- Rich Error Reporting: Detailed path-based error messages for debugging
Implementation Highlights
Spec Compliance
- ✅ Selector-based API (Spec line 504): User API uses selector functions (
S => A) for type-safe field access with IDE support
- ✅ Compile-time validation: Macro-based validation ensures complete migrations (adapted from PR #659 approach)
- ✅ Structural type support: Handles case classes, sealed traits, tuples, collections, and recursive types
- ✅ Bidirectional coercion: Automatic type conversions (e.g., Int ↔ Long, Tuple ↔ Case Class)
- ✅ Pure data migrations: All migrations are serializable data structures, not functions
Cross-Platform Support
- Scala 2.13 & 3.x: Full cross-compilation with version-specific macro implementations
- JVM, JS, Native: All tests pass on all platforms
- Type-safe macros: Scala 2 uses whitebox macros, Scala 3 uses inline macros with quotes
Implementation Details
Core Architecture
The migration system is built on three layers:
- Migration[A, B]: Type-safe wrapper with source and target schemas
- DynamicMigration: Untyped core that operates on DynamicValue
- MigrationAction: Individual transformation actions (14 types)
Migration Actions
Record Actions:
- AddField: Add new fields with default values
- DropField: Remove existing fields
- Rename: Rename fields
- Optionalize: Make mandatory fields optional
- Mandate: Make optional fields mandatory
- ChangeType: Change field types with transformations
- TransformValue: Transform field values
- Join: Combine multiple fields into one
- Split: Split one field into multiple
Variant Actions:
- RenameCase: Rename variant cases
- TransformCase: Transform case values
Collection Actions:
- TransformElements: Transform sequence elements
- TransformKeys: Transform map keys
- TransformValues: Transform map values
Builder API
The MigrationBuilder provides two complementary APIs for constructing migrations:
1. Selector-Based API (Recommended)
Type-safe, IDE-friendly API using selector functions (S => A) as specified in the requirements:
val migration = MigrationBuilder(sourceSchema, targetSchema)
.renameField(_.name, _.fullName) // Type-safe field selection
.addField(_.country, "USA") // Compile-time field validation
.dropField(_.oldField) // IDE autocomplete support
.build
Benefits:
- Type safety: Compile errors if fields don’t exist
- IDE support: Autocomplete and refactoring work seamlessly
- Macro-powered: Field names extracted at compile-time from lambda expressions
- Cross-platform: Works on both Scala 2.13 and Scala 3.x
2. String-Based API (Alternative)
Traditional string-based API for dynamic scenarios:
val migration = MigrationBuilder(sourceSchema, targetSchema)
.renameFieldByName("name", "fullName")
.addFieldWithDefault("email", "unknown@example.com")
.build
Compile-Time Validation
The build() method includes compile-time macro validation that ensures migrations are complete:
- All source fields are handled (renamed, dropped, or implicitly carried over)
- All target fields are produced (added, renamed, or implicitly carried over)
- Clear compile-time error messages for incomplete migrations
A buildUnchecked() method is also available for cases where validation is not needed.
Testing
Comprehensive test suite with 66 tests covering:
- All 14 migration action types
- Selector-based API: 4 tests for type-safe field selection with different default value types
- Flat structure migrations
- Nested migrations (2-3 levels)
- Deep nested migrations (4-7 levels)
- Round-trip migrations (reversibility)
- Error cases with detailed error messages
- Composition and chaining
- Property-based tests (reversibility, composition, identity, idempotence)
All tests pass successfully on Scala 2.13 (1887 tests) and Scala 3.3 (1952 tests) across JVM, JS, and Native platforms.
Documentation
Complete documentation following the zio-blocks style:
- Overview and motivation
- Core concepts and architecture
- Complete API reference for all action types
- Real-world examples (user evolution, API responses, database migrations, event sourcing, configuration)
- Advanced patterns (bidirectional migrations, conditional migrations, migration chains, versioned data storage)
- Testing strategies (unit tests, property-based tests, integration tests)
- Best practices and guidelines
- Performance considerations
- Common pitfalls and solutions
- Comparison with other approaches
Example Usage
Selector-Based API (Recommended)
// Define schemas
case class PersonV1(name: String, age: Int)
case class PersonV2(fullName: String, age: Int, country: String)
// Create migration with type-safe selectors
val migration = MigrationBuilder(
Schema.derived[PersonV1],
Schema.derived[PersonV2]
)
.renameField(_.name, _.fullName) // Type-safe field selection
.addField(_.country, "USA") // IDE autocomplete works!
.build // Compile-time validation
// Apply migration
val v1 = PersonV1("Alice", 30)
val v2: PersonV2 = migration.migrate(v1).toOption.get
// PersonV2("Alice", 30, "USA")
String-Based API (Alternative)
// Same migration using string-based API
val migration = MigrationBuilder(
Schema.derived[PersonV1],
Schema.derived[PersonV2]
)
.renameFieldByName("name", "fullName")
.addFieldWithDefault("country", "USA")
.build
// Apply migration
val person = PersonV1("Alice", 30)
val result = migration.apply(person)
// Right(PersonV2("Alice", 30, "USA"))
// Reverse migration
val reversed = migration.reverse.apply(result.toOption.get)
// Right(PersonV1("Alice", 30))
Addresses Issue
Closes #519