Closes #519
Implements a complete, pure algebraic schema migration system as specified in #519. Migrations are represented entirely as serializable data — no functions, closures, or reflection anywhere in the action ADT.
Two-layer design mirroring the existing DynamicPatch / Patch[A] pattern:
DynamicMigration — untyped, pure data, fully serializable
actions: Vector[MigrationAction] applied left-to-rightapply(DynamicValue): Either[MigrationError, DynamicValue]reverse: DynamicMigration — structural inverse of all actions in reverse order++ composition, isEmpty, identityMigration[A, B] — typed wrapper
A → DynamicValue via sourceSchemaDynamicMigrationDynamicValue → B via targetSchemareverse: Migration[B, A], ++[C], andThen[C]All actions carry at: DynamicOptic for precise path addressing.
Every action implements reverse: MigrationAction.
| Action | Reverse |
|---|---|
| AddField | DropField |
| DropField | AddField |
| Rename | Rename (swapped) |
| TransformValue | Identity (best-effort) |
| Mandate | Optionalize |
| Optionalize | Mandate |
| Join | Split |
| Split | Join |
| ChangeType | ChangeType (inverse conversion) |
| RenameCase | RenameCase (swapped) |
| TransformCase | TransformCase (reversed actions) |
| TransformElements | Identity (best-effort) |
| TransformKeys | Identity (best-effort) |
| TransformValues | Identity (best-effort) |
| NestedMigration | NestedMigration (reversed) |
Non-bijective reverse semantics are documented with ScalaDoc on each case.
MigrationExpr is a separate pure-data ADT (distinct from the existing query
SchemaExpr) used for all value-level transformations:
Identity, Literal(DynamicValue), FieldAccess(DynamicOptic)Convert(expr, PrimitiveConversion) — 12 primitive coercionsConcat(exprs, separator), Compose(first, second), DefaultValueAll cases are serializable data. No lambdas or closures.
// Scala 3
inline def select[A](inline f: A => Any): DynamicOptic
// Scala 2
def select[A](f: A => Any): DynamicOptic // macro
Converts _.field.nested selectors into DynamicOptic at compile time.
Supports field access, .when[T] case selection, .each traversal.
Emits compile error on non-selector expressions.
val migration = Migration.newBuilder[PersonV0, Person]
.renameField(select(_.firstName), select(_.fullName))
.addField(select(_.age), MigrationExpr.Literal(DynamicValue.Primitive(0)))
.inField(select(_.address), select(_.address), addressMigration)
.build // compile error if any field in Person is unhandled
.build validates at compile time that every field in the target type is
accounted for. Missing fields produce a compiler error naming the unhandled fields:
error: Migration.build: unhandled target fields in Person: age
.buildPartial skips validation for partial/incremental migrations.
inField composes a sub-migration over a nested record field.
The sub-migration’s actions are encoded as a NestedMigration action,
applied recursively by DynamicMigration.apply.
val addressMigration = Migration.newBuilder[AddressV0, AddressV1]
.renameField(select(_.street), select(_.streetName))
.renameField(select(_.zip), select(_.postalCode))
.addField(select(_.country), MigrationExpr.Literal(DynamicValue.Primitive("US")))
.buildPartial
val personMigration = Migration.newBuilder[PersonV0, Person]
.renameField(select(_.name), select(_.name))
.inField(select(_.address), select(_.address), addressMigration)
.build
All new types (MigrationError, MigrationExpr, MigrationAction,
DynamicMigration) have hand-written Schema instances following the
DynamicPatch manual derivation style. No Schema.derived anywhere.
Verified with ZIO Test property-based tests in MigrationLawSpec:
Migration.identity[A].apply(a) == Right(a)(m1 ++ m2) ++ m3 and m1 ++ (m2 ++ m3) produce identical resultsm.reverse.reverse.actions matches m.actionsm.apply(a).flatMap(m.reverse.apply) == Right(a)| Spec | What it covers |
|---|---|
DynamicMigrationSpec |
Each of the 14 action types, error paths with DynamicOptic |
MigrationLawSpec |
Identity, associativity, reverse laws (property-based) |
MigrationSpec |
Typed Migration[A,B], nested via inField, enum rename, composition |
MigrationMacroSpec |
Selector macros, .build success, .build compile-fail with field name |
schema/shared/src/main/scala/zio/blocks/schema/migration/
MigrationError.scala
MigrationExpr.scala
MigrationAction.scala
DynamicMigration.scala
Migration.scala
MigrationBuilder.scala
schema/shared/src/main/scala-2/zio/blocks/schema/migration/
MigrationMacros.scala
schema/shared/src/main/scala-3/zio/blocks/schema/migration/
MigrationMacros.scala
schema/shared/src/test/scala/zio/blocks/schema/migration/
DynamicMigrationSpec.scala
MigrationLawSpec.scala
MigrationSpec.scala
schema/shared/src/test/scala-3/zio/blocks/schema/migration/
MigrationMacroSpec.scala
No existing files were modified.
/claim #519
Harshada Gawas
@harshadagawas95-gmail-com
marianaguzmanguerrero16-dev
@marianaguzmanguerrero16-dev
ZIO
@ZIO