-
Notifications
You must be signed in to change notification settings - Fork 152
Schema migration system for ZIO Schema 2 #998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Godzilla675
wants to merge
22
commits into
zio:main
from
Godzilla675:schema-migration-for-zio-schema-2-system-v3
Closed
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
a2b74b9
docs(schema): add migration docs
Godzilla675 7245fb4
feat(migration): core model and schemas
Godzilla675 2eb1b4d
feat(migration): builder and validation
Godzilla675 a94a183
feat(schema): structural derivation bindings
Godzilla675 14d335d
feat(migration): selector macros and syntax
Godzilla675 efe5765
test(migration): add migration coverage
Godzilla675 e24d7a2
Fix MigrationValidator: add Wrap path validation and migration-aware …
Godzilla675 17f403c
Apply scalafmt formatting to MigrationValidator
Godzilla675 620bd24
Add branch coverage tests and fix scalafmt
Godzilla675 f99eb13
Add 413 migration coverage tests to meet 80% branch coverage minimum
Godzilla675 f65031e
Fix scalafmt formatting for MigrationBuilderSyntax.scala
Godzilla675 89a5820
fix: scalafmt scala213 dialect, fix failing test, add coverage tests …
Godzilla675 8c5cee6
fix: JS compat, coercion assertions, add modifyAtPathRec and navigate…
Godzilla675 18908b5
fix: add targeted branch coverage tests for 3.3.x threshold
Godzilla675 97786d2
Address code review feedback
Godzilla675 94ff8e1
Revert TransformValue context change - transform must eval against fi…
Godzilla675 756d930
Polish migration docs: fix method names, add descriptions, improve ex…
Godzilla675 d603ef7
feat: add compile-time field tracking via TrackedMigrationBuilder (Sc…
Godzilla675 c3f3518
docs: add compile-time field tracking section to migration reference
Godzilla675 dbae175
refactor: remove ~30 duplicate tests across coverage spec files
Godzilla675 db0a83e
fix: resolve 7 bugs in migration system
Godzilla675 f70ecb2
fix: address PR audit - ChangeType, Join/Split validation
Godzilla675 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| # Schema Migration | ||
|
|
||
| Schema migration provides a pure, algebraic system for transforming data between schema versions. | ||
|
|
||
| ## Overview | ||
|
|
||
| The migration system enables: | ||
| - **Type-safe migrations**: Define transformations between typed schemas | ||
| - **Dynamic migrations**: Operate on untyped `DynamicValue` for flexibility | ||
| - **Reversibility**: All migrations can be structurally reversed | ||
| - **Serialization**: Migrations are pure data that can be serialized and stored | ||
| - **Path-aware errors**: Detailed error messages with exact location information | ||
|
|
||
| ## Core Types | ||
|
|
||
| ### Migration[A, B] | ||
|
|
||
| A typed migration from schema `A` to schema `B`: | ||
|
|
||
| ```scala | ||
| import zio.blocks.schema._ | ||
| import zio.blocks.schema.migration._ | ||
|
|
||
| // Needed when (de)serializing DynamicMigration / MigrationAction / DynamicSchemaExpr | ||
| import zio.blocks.schema.migration.MigrationSchemas._ | ||
|
|
||
| case class PersonV1(name: String, age: Int) | ||
| case class PersonV2(fullName: String, age: Int, country: String) | ||
|
|
||
| object PersonV1 { implicit val schema: Schema[PersonV1] = Schema.derived } | ||
| object PersonV2 { implicit val schema: Schema[PersonV2] = Schema.derived } | ||
|
|
||
| val migration: Migration[PersonV1, PersonV2] = | ||
| Migration | ||
| .newBuilder[PersonV1, PersonV2] | ||
| .renameField(MigrationBuilder.paths.field("name"), MigrationBuilder.paths.field("fullName")) | ||
| .addField(MigrationBuilder.paths.field("country"), "US") | ||
| .buildPartial | ||
| ``` | ||
|
|
||
| ### DynamicMigration | ||
|
|
||
| An untyped, serializable migration operating on `DynamicValue`: | ||
|
|
||
| ```scala | ||
| val dynamicMigration = migration.dynamicMigration | ||
|
|
||
| import zio.blocks.chunk.Chunk | ||
|
|
||
| // Apply to DynamicValue directly | ||
| val oldValue: DynamicValue = DynamicValue.Record(Chunk( | ||
| "name" -> DynamicValue.Primitive(PrimitiveValue.String("John")), | ||
| "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) | ||
| )) | ||
|
|
||
| val newValue: Either[MigrationError, DynamicValue] = dynamicMigration(oldValue) | ||
| ``` | ||
|
|
||
| ### MigrationAction | ||
|
|
||
| Individual migration actions are represented as an algebraic data type: | ||
|
|
||
| | Action | Description | | ||
| |--------|-------------| | ||
| | `AddField` | Add a new field with a default value | | ||
| | `DropField` | Remove a field | | ||
| | `RenameField` | Rename a field | | ||
| | `TransformValue` | Transform a value using an expression | | ||
| | `Mandate` | Make an optional field mandatory | | ||
| | `Optionalize` | Make a mandatory field optional | | ||
| | `ChangeType` | Convert between primitive types | | ||
| | `Join` | Combine multiple fields into one | | ||
| | `Split` | Split one field into multiple | | ||
| | `RenameCase` | Rename a case in a variant/enum | | ||
| | `TransformCase` | Transform within a specific case | | ||
| | `TransformElements` | Transform all elements in a sequence | | ||
| | `TransformKeys` | Transform all keys in a map | | ||
| | `TransformValues` | Transform all values in a map | | ||
| | `Identity` | No-op action | | ||
|
|
||
| ### DynamicSchemaExpr | ||
|
|
||
| Serializable expressions for value transformations: | ||
|
|
||
| ```scala | ||
| // Literal value | ||
| val lit = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))) | ||
|
|
||
| // Path extraction | ||
| val path = DynamicSchemaExpr.Path(DynamicOptic.root.field("name")) | ||
|
|
||
| // Arithmetic | ||
| val doubled = DynamicSchemaExpr.Arithmetic( | ||
| path, | ||
| DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), | ||
| DynamicSchemaExpr.ArithmeticOperator.Multiply | ||
| ) | ||
|
|
||
| // String operations | ||
| val concat = DynamicSchemaExpr.StringConcat(expr1, expr2) | ||
| val length = DynamicSchemaExpr.StringLength(stringExpr) | ||
|
|
||
| // Type coercion | ||
| val coerced = DynamicSchemaExpr.CoercePrimitive(intExpr, "String") | ||
| ``` | ||
|
|
||
| ## MigrationBuilder API | ||
|
|
||
| The builder provides a fluent API for constructing migrations: | ||
|
|
||
| ```scala | ||
| Migration | ||
| .newBuilder[OldType, NewType] | ||
| // Record operations | ||
| .addField(path, defaultExpr) | ||
| .dropField(path, defaultForReverse) | ||
| .renameField(fromPath, toPath) | ||
| .transformField(path, transform, reverseTransform) | ||
| .mandateField(path, default) | ||
| .optionalizeField(path) | ||
| .changeType(path, converter, reverseConverter) | ||
| .joinFields(targetPath, sourcePaths, combiner, splitter) | ||
| .splitField(sourcePath, targetPaths, splitter, combiner) | ||
| // Enum operations | ||
| .renameCase(path, from, to) | ||
| .transformCase(path, caseName, nestedActions) | ||
| // Collection operations | ||
| .transformElements(path, transform, reverseTransform) | ||
| .transformKeys(path, transform, reverseTransform) | ||
| .transformValues(path, transform, reverseTransform) | ||
| // Build | ||
| .build // Full validation | ||
| .buildPartial // Skip validation | ||
| ``` | ||
|
|
||
| ## Type-Safe Selector Syntax | ||
|
|
||
| For more ergonomic, type-safe paths, import the selector syntax extensions: | ||
|
|
||
| ```scala | ||
| import zio.blocks.schema.migration.MigrationBuilderSyntax._ | ||
|
|
||
| val migration: Migration[PersonV1, PersonV2] = | ||
| Migration | ||
| .newBuilder[PersonV1, PersonV2] | ||
| .renameField(_.name, _.fullName) | ||
| .addField(_.country, "US") | ||
| .buildPartial | ||
| ``` | ||
|
|
||
| Selector syntax supports optic-like projections such as: | ||
| - `.when[T]`, `.each`, `.eachKey`, `.eachValue`, `.wrapped[T]`, `.at(i)`, `.atIndices(is*)`, `.atKey(k)`, `.atKeys(ks*)` | ||
|
|
||
| ## Path Helpers | ||
|
|
||
| Use the `paths` object for constructing paths: | ||
|
|
||
| ```scala | ||
| import MigrationBuilder.paths | ||
|
|
||
| paths.field("name") // Single field | ||
| paths.field("address", "street") // Nested field | ||
| paths.elements // Sequence elements | ||
| paths.mapKeys // Map keys | ||
| paths.mapValues // Map values | ||
| ``` | ||
|
|
||
| ## Reversibility | ||
|
|
||
| All migrations can be reversed: | ||
|
|
||
| ```scala | ||
| val forward: Migration[A, B] = ... | ||
| val backward: Migration[B, A] = forward.reverse | ||
|
|
||
| // Law: forward ++ backward should be identity (structurally) | ||
| ``` | ||
|
|
||
| ## Composition | ||
|
|
||
| Migrations can be composed: | ||
|
|
||
| ```scala | ||
| val v1ToV2: Migration[V1, V2] = ... | ||
| val v2ToV3: Migration[V2, V3] = ... | ||
|
|
||
| val v1ToV3: Migration[V1, V3] = v1ToV2 ++ v2ToV3 | ||
| // or | ||
| val v1ToV3: Migration[V1, V3] = v1ToV2.andThen(v2ToV3) | ||
| ``` | ||
|
|
||
| ## Error Handling | ||
|
|
||
| Migrations return `Either[MigrationError, DynamicValue]`: | ||
|
|
||
| ```scala | ||
| migration.apply(value) match { | ||
| case Right(newValue) => // Success | ||
| case Left(errors) => | ||
| errors.errors.foreach { error => | ||
| println(s"At ${error.path}: ${error.message}") | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Error types include: | ||
| - `FieldNotFound` - A required field was not found in the source value | ||
| - `FieldAlreadyExists` - A field already exists when trying to add it | ||
| - `NotARecord` - Expected a record but found a different kind of value | ||
| - `NotAVariant` - Expected a variant but found a different kind of value | ||
| - `TypeConversionFailed` - Primitive type conversion failed | ||
| - `DefaultValueMissing` - Default value not resolved | ||
| - `PathNavigationFailed` - Cannot navigate the path | ||
| - `ActionFailed` - General action failure | ||
|
|
||
| ## Best Practices | ||
|
|
||
| 1. **Use `buildPartial` during development**, switch to `build` for production validation | ||
| 2. **Provide meaningful reverse transforms** for `TransformValue` actions | ||
| 3. **Keep migrations small and focused** - compose multiple simple migrations | ||
| 4. **Test both forward and reverse** directions | ||
| 5. **Store migrations alongside schema versions** for reproducibility | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The builder API shown here doesn’t match the implemented
MigrationBuildermethods (e.g..changeTypevschangeFieldType, andrenameCase(path, ...)/transformCase(path, ...)vsrenameCaseAt/transformCaseAt). Please update the docs to use the actual method names/signatures so readers can copy/paste the examples successfully.