feat(schema): implement Schema Migration System (#519)#966
feat(schema): implement Schema Migration System (#519)#966
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements a comprehensive Schema Migration System for ZIO Schema 2, enabling pure, serializable schema migrations with type-safe selector syntax. The implementation provides 14 migration actions for records, enums, and collections, along with extensive test coverage across multiple test suites.
Changes:
- Implemented core migration system with
DynamicMigration,Migration[A, B], and 14 serializable action types - Added type-safe builder API with macro-validated selector expressions (
S => Asyntax) - Created comprehensive test suites covering Into/As conversions, validation, structural types, and edge cases
Reviewed changes
Copilot reviewed 106 out of 106 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| selector-syntax-summary.md | Documents selector syntax implementation and infrastructure |
| schema/shared/src/test/scala/* | Extensive test coverage for Into/As conversions, validation, and type coercion |
| schema/shared/src/test/scala-3/* | Scala 3-specific tests for opaque types, enums, and large tuples |
| schema/shared/src/test/scala-2/* | Scala 2-specific tests for ZIO Prelude newtypes |
| schema/shared/src/main/scala/zio/blocks/schema/migration/* | Core migration system implementation |
| schema/shared/src/main/scala/zio/blocks/schema/* | Extended PrimitiveType, SchemaError, IsNumeric for migration support |
| docs/reference/structural-schemas.md | Comprehensive documentation for structural schema support |
| IMPLEMENTATION_INDEX.md, COMPLETION_REPORT.md, 519-implementation-plan.md | Implementation planning and completion tracking documents |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| at: DynamicOptic, | ||
| transform: SchemaExpr[?, ?] | ||
| ) extends MigrationAction { | ||
| override def reverse: MigrationAction = TransformValue(at, transform) // Note: true reverse needs inverse function |
There was a problem hiding this comment.
The comment indicates "true reverse needs inverse function" but the implementation returns the same transform. This creates a structural reverse that may not be semantically correct. Consider adding a TODO or making this limitation more explicit in the documentation.
| override def reverse: MigrationAction = TransformValue(at, transform) // Note: true reverse needs inverse function | |
| // NOTE: This is a structural reverse that re-applies the same transform. | |
| // TODO: Support specifying an inverse transform for true semantic reversal. | |
| // Until then, reverse may not be a real inverse for all transformations. | |
| override def reverse: MigrationAction = TransformValue(at, transform) |
483fa0b to
5502966
Compare
6b0a77a to
f085bea
Compare
| SchemaExpr.literal(value) | ||
|
|
||
| def spec: Spec[TestEnvironment, Any] = suite("MigrationSpec")( | ||
| suite("DynamicMigration")( |
There was a problem hiding this comment.
Pull DynamicMigration out into its own spec.
|
|
||
| val migration = Migration | ||
| .newBuilder[PersonV1, PersonV2] | ||
| .addField(_.age, literalExpr(0)) |
There was a problem hiding this comment.
I would hope we can just do literal or SchemaExpr.literal rather than literalExpr.
|
|
||
| val migration = Migration | ||
| .newBuilder[PersonV1, PersonV2] | ||
| .changeFieldType(_.score, _.score, literalExpr("42")) |
There was a problem hiding this comment.
Why would this take two selectors? To prove the same field exists in both?
|
|
||
| val migration = Migration | ||
| .newBuilder[PersonV1, PersonV2] | ||
| .addField(_.age, literal(DynamicValue.Primitive(PrimitiveValue.Int(0)))) |
There was a problem hiding this comment.
This should be literalInt and not literal(dynamicvalue).
| val migration = Migration | ||
| .newBuilder[PersonV1, PersonV2] | ||
| .transformNested(_.address, _.address) { builder => | ||
| builder.addField(_.zip, literal(DynamicValue.Primitive(PrimitiveValue.String("00000")))) |
There was a problem hiding this comment.
All of these literal(dynamicvalues) should be replaced by literal[T](t: T), which eagerly converts to dynamic value.
eda5a17 to
06782b6
Compare
af7ab9c to
efde36b
Compare
| // By wrapping in FieldName[N], the type argument is preserved when using appliedTo. | ||
| sealed trait FieldName[N <: String & Singleton] | ||
|
|
||
| final class MigrationBuilder[A, B, SourceHandled, TargetProvided]( |
There was a problem hiding this comment.
Instead of SourceHandled, TargetProvided keep one param, that tracks all the actions that did apply to A to become B encoded in the type system. So the type is the full change set.
There was a problem hiding this comment.
I would think of the third type parameter as Changeset, which contains at type-level encoding of all the changes that have been applied to A to bring it into comformance with B -- each individual operation being one of those methods on MigrationBuilder.
The macros need a structural encoding of both A and B, and then need a way to apply the changeset to A, so you can check conformance with B.
Implements a pure, serializable migration system for ZIO Schema 2. Key features: - DynamicMigration: fully serializable (no closures), all transforms as SchemaExpr - Migration[A, B]: typed wrapper with schemas - 14 MigrationAction types (AddField, DropField, Rename, Transform, etc.) - MigrationBuilder with selector macros for Scala 2 and 3 - Macro validation in .build to confirm old→new migration - Structural reverse and semantic inverse laws - Enum rename/transform support Additional changes required for serialization: - SchemaExpr: made fully serializable (NumericPrimitiveType, DynamicOptic) - IsIntegral: new type class for mixed-type bitwise operations - Optic: added bitwise DSL methods Tests: 84+ tests across MigrationSpec, StructuralMigrationSpec, MigrationBuildValidationSpec Closes #519
Complete migration of StringReplace, StringStartsWith, StringEndsWith, StringContains, and StringIndexOf classes to use the new eval[B1 >: CONCRETE_TYPE] signature. All classes now match the updated SchemaExpr trait interface and successfully compile with all tests passing.
- Add andThen composition test - Add size utility method test - Add rename field not found error test - Add rename field already exists error test - Add renameCase with non-matching case name test
- Add 20+ new tests in 'Error Branches' suite - Cover type mismatch errors (AddField, DropField, Rename on non-Record) - Cover variant errors (RenameCase, TransformCase on non-Variant) - Cover collection errors (TransformElements, TransformKeys, TransformValues) - Cover path navigation errors (Case mismatch, AtIndex bounds, AtMapKey missing) - Cover nested operation failures - Test isEmpty method on empty and non-empty migrations
- Add 13 more tests covering edge cases and error branches - Test Node.Wrapped path navigation - Test early failure in multi-action migrations - Test empty collection edge cases - Test deep nested record operations - Total: 112 migration tests
…AtPath node types - Add tests for all 14 action type reversals (AddField↔DropField, Rename, etc) - Add tests for DynamicMigration varargs constructor with 1-3 actions - Add tests for all modifyAtPath node types (Field, Case, Elements, MapKeys, MapValues, AtIndex, AtMapKey, Wrapped) - Add tests for Mandate/Optionalize edge cases and round-trips - Add tests for reverse composition and double-reverse preservation - Add tests for Join/Split reversal pairs - Increase branch coverage from 78.86% to 100% on Scala 3.3.x - All 142 tests pass on Scala 3.7.4, 3.3.7, and 2.13.18
Add tests for builder methods: - changeFieldType selector syntax - mandateField builder (creates Mandate action) - optionalizeField builder (creates Optionalize action) - transformElements builder (creates TransformElements action) - transformKeys builder (creates TransformKeys action) - transformValues builder (creates TransformValues action) - renameCase builder (creates RenameCase action) These tests verify the builder API creates correct actions without testing execution (which has existing coverage). This should help reach the 80% branch coverage target on Scala 3.3.x.
Cover the error paths in MigrationAction's fieldName and from methods: - AddField.fieldName success path - AddField.fieldName failure path (root only, non-Field node) - DropField.fieldName success path - DropField.fieldName failure path (root only, non-Field node) - Rename.from success path - Rename.from failure path (root only, non-Field node) These tests exercise branches previously not covered to help reach the 80% branch coverage target.
…ieldName wrapper - Remove AddField type alias (no longer needed) - Add documentation explaining why FieldName wrapper is necessary - FieldName preserves literal types through macro expansion - Direct ConstantType with .asType causes literal types to widen to String - All tests pass on Scala 3.7.4 and 3.3.7
…in Scala 2 - Add FieldName[N] phantom type wrapper for literal type preservation - Update MigrationBuilder signature to [A, B, SourceHandled, TargetProvided] - All macro methods now return refined types with accumulated field names - Add MigrationComplete trait with implicit macro derivation - build method requires MigrationComplete evidence - buildPartial unchanged (no validation) - All tests pass on Scala 2.13.18
In Scala 2, the macro keyword must be followed immediately by the implementation reference on the same line. Scalafmt was breaking this by putting line breaks between '= macro' and the implementation. Added format:off/on directives to preserve correct macro syntax.
- Fully serializable expression ADT that operates on DynamicValue - No type parameters, no Schema references, no function closures - Implements all 21 case classes from design document: - Leaf expressions: Literal, Select, PrimitiveConversion - Relational operators: LessThan, GreaterThan, Equal, NotEqual, etc. - Logical operators: And, Or, Not - Arithmetic operators with NumericTypeTag (replaces NumericPrimitiveType) - Bitwise operators: And, Or, Xor, LeftShift, RightShift, etc. - String operations: Concat, Length, Substring, Trim, ToUpperCase, etc. - Select implements path walking logic adapted from SchemaExpr.Optic - NumericTypeTag sealed trait provides serializable arithmetic operations - All operators implemented as sealed traits of case objects - Compiles on both Scala 3.7.4 and Scala 2.13.18
…aExpr - Convert SchemaExpr from sealed trait hierarchy to single case class wrapper - Add DynamicSchemaExpr as serializable expression ADT (no Schema[A] embedded) - Implement typed eval/evalDynamic methods that convert via DynamicValue - Fix type shadowing (Validation.String vs Predef.String) throughout codebase - Add PrimitiveType.toTag implementations for all primitive types - Update test literal helpers to use new typed factory methods - Remove structural test assertions (wrapper pattern hides internal structure) Main code compiles on both Scala 3.7.4 and 2.13.15. 4 expected test failures in OpticSpec related to error-checking (future task). This refactoring enables: - Full serialization of schema expressions (DynamicSchemaExpr) - Type-safe expression evaluation with compile-time checking - Clean separation of serializable ADT from typed wrapper - Backward compatibility via dual eval interfaces
…maExpr - Add proper error handling in DynamicSchemaExpr.Select.walkPath for: - UnexpectedCase errors for wrong enum variants - EmptySequence errors for empty collections - SequenceIndexOutOfBounds for invalid indices - MissingKey and EmptyMap for map operations - Convert SchemaError to OpticCheck in SchemaExpr.eval/evalDynamic - Fix StringRegexMatch to use string's input schema instead of regex's
- Add TransformNested action for nested record migrations - Add transformNested method to MigrationBuilder (Scala 2 & 3) - Track nested fields with dot notation (e.g., FieldName["address.street"]) - Update validation macros to check nested field completeness - Add tests for nested type migrations
Add migrateField method to MigrationBuilder that allows composing existing migrations for nested types. This enables reusing migrations when migrating complex nested structures. Features: - .migrateField(src, tgt, migration) with explicit migration parameter - .migrateField(src, tgt) summons implicit/given Migration from scope - All nested fields from composed migration are expanded into type tracking - New MigrationAction.ApplyMigration for runtime execution - Works with both Scala 2 and Scala 3
Add 9 new migration tests covering: - 2-4 level nested case class migrations with field operations - Collection-wrapped nested types (Option, List, Map) - Recursive type migrations with self-referential fields - Multiple operations on nested types in single migration Also document migration test patterns, limitations, and best practices in docs/learnings.md. Key learnings: - DynamicMigration cannot traverse Variant types (Option wrappers) - Collection migrations work via identity + automatic type conversion - Recursive schemas require 'implicit lazy val' to prevent forward refs - Deeply nested paths (4+ levels) work correctly with dot notation All 191 tests passing on Scala 3.7.4 (including 9 new tests).
… syntax in tests - Replace verbose DynamicValue.Primitive(PrimitiveValue.X(...)) with simple literal(value) - Use MigrationBuilder selector syntax instead of Migration.fromActions - Keep one Migration.fromActions test as proof it works
…plit tests - Remove transformNested API entirely (deep selectors already work) - Simplify migrateField to ONE selector + migration (not two selectors) - Fix literal naming (literalExpr -> literal) in tests - Fix MigrationBuildValidationSpec to use ergonomic literal(0) - Split DynamicMigration tests into DynamicMigrationSpec - Remove TransformNested from MigrationAction - Remove executeTransformNested from DynamicMigration - All 203 tests pass, Scala 2.13 + 3.7.4 compile
Enable chained field selectors like _.address.street in all builder methods (addField, dropField, renameField, transformField, etc.). The macro now extracts full dot-separated paths instead of only the leaf field name, and the validation macros support cross-type auto- mapping of matching nested fields when the user manipulates individual nested fields directly.
Add 3-level deep nesting tests (Person→Address→Street), combined rename+drop at depth 3, fromActions with deep DynamicOptic paths, and phantom type inference verification via typeCheck. Fix validation macros to recurse cross-type auto-mapping and use fixpoint iteration for parent coverage inference at arbitrary nesting depths.
SchemaExpr was refactored from a sealed ADT to a case class wrapping DynamicSchemaExpr, but schema-examples and docs were not updated. This caused 115 compilation errors on Scala 3.x CI and 48 mdoc errors on Build Docs CI. - Update querydslsql, querydslextended, querydslbuilder examples to pattern match on expr.dynamic (DynamicSchemaExpr) instead of the old SchemaExpr ADT cases - Update Column to hold DynamicOptic instead of typed Optic - Update Lit to hold DynamicValue instead of typed value - Add sqlLiteralDV helper for rendering DynamicValue to SQL - Fix SchemaErrorExample.scala import (JsonFormat -> JsonBinaryCodecDeriver) - Update all 4 query DSL guide docs and schema-expr reference docs to match new API structure
Add missing algebraic law tests for both typed Migration and DynamicMigration: - Identity element under composition (left and right) for both layers - Typed Migration associativity, double-reverse involution, semantic inverse - MaxPathDepth=64 guard in ActionExecutor.modifyAtPath to prevent stack overflow
…tinel for non-invertible reverse Convert all 18 throw sites in DynamicSchemaExpr eval methods to proper Left(SchemaError.conversionFailed(...)) returns with shared extractors. Add Irreversible sentinel MigrationAction so reversing non-invertible actions (TransformValue, ChangeType, TransformElements, TransformKeys, TransformValues, Join, Split) now fails with a clear error instead of silently re-applying the forward transform. Clarify TransformElements/Keys/Values Scaladoc to document replace-all semantics.
… Changeset type param Replace MigrationBuilder[A, B, SourceHandled, TargetProvided] with MigrationBuilder[A, B, Changeset] where the Changeset encodes all operations (Added, Dropped, Renamed, Transformed, etc.) as phantom types in an intersection. Both Scala 2 and 3 updated.
Verify each builder operation accumulates the correct phantom type in the Changeset parameter (Added, Dropped, Renamed, Transformed) using typeCheck with forced type mismatch to reveal inferred types.
efde36b to
cacb259
Compare
Add missing match cases for new DynamicOptic.Node variants added in main (SchemaSearch, TypeSearch). These are not supported in schema expressions and return appropriate errors.
| override def reverse: MigrationAction = Mandate( | ||
| at, | ||
| DynamicSchemaExpr.Literal(DynamicValue.Record.empty) | ||
| ) |
| /** | ||
| * A typed migration that transforms values from type `A` to type `B`. | ||
| * | ||
| * `Migration[A, B]` provides a type-safe wrapper around `DynamicMigration`, | ||
| * handling the conversion between typed values and `DynamicValue` | ||
| * automatically. | ||
| * | ||
| * The migration process: | ||
| * 1. Convert input `A` to `DynamicValue` using `sourceSchema` | ||
| * 2. Apply the `dynamicMigration` to transform the dynamic value | ||
| * 3. Convert the result back to `B` using `targetSchema` | ||
| * | ||
| * The schemas can be any `Schema[A]` and `Schema[B]`, including: | ||
| * - Regular schemas derived from case classes/enums | ||
| * - Structural schemas (JVM only) for compile-time-only types | ||
| * | ||
| * This allows migrations between current runtime types and past structural | ||
| * versions without requiring old case classes to exist at runtime. | ||
| * | ||
| * @tparam A | ||
| * The source type | ||
| * @tparam B | ||
| * The target type | ||
| * @param sourceSchema | ||
| * Schema for the source type (can be structural or regular) | ||
| * @param targetSchema | ||
| * Schema for the target type (can be structural or regular) | ||
| * @param dynamicMigration | ||
| * The underlying untyped migration | ||
| */ | ||
| final case class Migration[A, B]( | ||
| sourceSchema: Schema[A], | ||
| targetSchema: Schema[B], | ||
| dynamicMigration: DynamicMigration | ||
| ) { | ||
|
|
||
| /** | ||
| * Apply this migration to transform a value from type `A` to type `B`. | ||
| * | ||
| * @param value | ||
| * The input value to migrate | ||
| * @return | ||
| * Either a `SchemaError` or the migrated value | ||
| */ | ||
| def apply(value: A): Either[SchemaError, B] = { | ||
| val dynamicValue = sourceSchema.toDynamicValue(value) | ||
| dynamicMigration(dynamicValue).flatMap { result => | ||
| targetSchema.fromDynamicValue(result) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Compose this migration with another, applying this migration first, then | ||
| * the other. | ||
| * | ||
| * @param that | ||
| * The migration to apply after this one | ||
| * @return | ||
| * A new migration that applies both in sequence | ||
| */ | ||
| def ++[C](that: Migration[B, C]): Migration[A, C] = | ||
| new Migration(sourceSchema, that.targetSchema, dynamicMigration ++ that.dynamicMigration) | ||
|
|
||
| /** | ||
| * Alias for `++`. | ||
| */ | ||
| def andThen[C](that: Migration[B, C]): Migration[A, C] = this ++ that | ||
|
|
||
| /** | ||
| * Returns the structural reverse of this migration. | ||
| * | ||
| * Note: Runtime execution of the reverse migration is best-effort. It may | ||
| * fail if information was lost during the forward migration. | ||
| */ | ||
| def reverse: Migration[B, A] = | ||
| new Migration(targetSchema, sourceSchema, dynamicMigration.reverse) | ||
|
|
||
| /** | ||
| * Returns true if this migration has no actions (identity migration). | ||
| */ | ||
| def isEmpty: Boolean = dynamicMigration.isEmpty | ||
|
|
||
| /** | ||
| * Returns the number of actions in this migration. | ||
| */ | ||
| def size: Int = dynamicMigration.size | ||
|
|
||
| /** | ||
| * Get the list of actions in this migration. | ||
| */ | ||
| def actions: Vector[MigrationAction] = dynamicMigration.actions | ||
| } | ||
|
|
||
| object Migration extends MigrationSelectorSyntax with MigrationCompanionVersionSpecific { | ||
|
|
| suite("Relational")( | ||
| test("LessThan operator") { | ||
| val _ = SchemaExpr.relational(intLit(5), intLit(10), SchemaExpr.RelationalOperator.LessThan) | ||
| assertTrue(true) | ||
| }, | ||
| test("GreaterThan operator") { | ||
| val _ = SchemaExpr.relational(intLit(10), intLit(5), SchemaExpr.RelationalOperator.GreaterThan) | ||
| assertTrue(true) | ||
| }, | ||
| test("LessThanOrEqual operator") { | ||
| val _ = SchemaExpr.relational(intLit(5), intLit(5), SchemaExpr.RelationalOperator.LessThanOrEqual) | ||
| assertTrue(true) | ||
| }, | ||
| test("GreaterThanOrEqual operator") { | ||
| val _ = SchemaExpr.relational(intLit(10), intLit(10), SchemaExpr.RelationalOperator.GreaterThanOrEqual) | ||
| assertTrue(true) | ||
| }, | ||
| test("Equal operator") { | ||
| val _ = SchemaExpr.relational(intLit(42), intLit(42), SchemaExpr.RelationalOperator.Equal) | ||
| assertTrue(true) | ||
| }, | ||
| test("NotEqual operator") { | ||
| val _ = SchemaExpr.relational(intLit(42), intLit(99), SchemaExpr.RelationalOperator.NotEqual) | ||
| assertTrue(true) | ||
| } |
| transparent inline def migrateField[F1, F2]( | ||
| inline selector: A => F1, | ||
| migration: Migration[F1, F2] | ||
| ) = ${ | ||
| MigrationBuilderMacros.migrateFieldExplicitImpl[A, B, F1, F2, Changeset]( | ||
| 'this, | ||
| 'selector, | ||
| 'migration | ||
| ) | ||
| } | ||
|
|
||
| @scala.annotation.targetName("migrateFieldImplicit") | ||
| transparent inline def migrateField[F1, F2]( | ||
| inline selector: A => F1 | ||
| )(using migration: Migration[F1, F2]) = ${ | ||
| MigrationBuilderMacros.migrateFieldImplicitImpl[A, B, F1, F2, Changeset]( | ||
| 'this, | ||
| 'selector, | ||
| 'migration | ||
| ) | ||
| } |
| suite("Not")( | ||
| test("negates boolean value") { | ||
| val _ = SchemaExpr.not(boolLit(true)) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("Not")( | ||
| test("negates boolean expression") { | ||
| val _ = SchemaExpr.not(boolLit(true)) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("Arithmetic")( | ||
| test("Add operator") { | ||
| val _ = | ||
| SchemaExpr.arithmetic(intLit(10), intLit(5), SchemaExpr.ArithmeticOperator.Add, PrimitiveType.Int(None)) | ||
| assertTrue(true) | ||
| }, | ||
| test("Subtract operator") { | ||
| val _ = | ||
| SchemaExpr.arithmetic(intLit(10), intLit(5), SchemaExpr.ArithmeticOperator.Subtract, PrimitiveType.Int(None)) | ||
| assertTrue(true) | ||
| }, | ||
| test("Multiply operator") { | ||
| val _ = | ||
| SchemaExpr.arithmetic(intLit(10), intLit(5), SchemaExpr.ArithmeticOperator.Multiply, PrimitiveType.Int(None)) | ||
| assertTrue(true) | ||
| }, | ||
| test("Divide operator") { | ||
| val _ = | ||
| SchemaExpr.arithmetic(intLit(10), intLit(2), SchemaExpr.ArithmeticOperator.Divide, PrimitiveType.Int(None)) | ||
| assertTrue(true) | ||
| }, | ||
| test("Pow operator") { | ||
| val _ = | ||
| SchemaExpr.arithmetic(intLit(2), intLit(3), SchemaExpr.ArithmeticOperator.Pow, PrimitiveType.Int(None)) | ||
| assertTrue(true) | ||
| }, | ||
| test("Modulo operator") { | ||
| val _ = | ||
| SchemaExpr.arithmetic(intLit(10), intLit(3), SchemaExpr.ArithmeticOperator.Modulo, PrimitiveType.Int(None)) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("Bitwise")( | ||
| test("And operator (Int)") { | ||
| val _ = SchemaExpr.bitwise(intLit(12), intLit(10), SchemaExpr.BitwiseOperator.And) | ||
| assertTrue(true) | ||
| }, | ||
| test("Or operator (Int)") { | ||
| val _ = SchemaExpr.bitwise(intLit(12), intLit(10), SchemaExpr.BitwiseOperator.Or) | ||
| assertTrue(true) | ||
| }, | ||
| test("Xor operator (Int)") { | ||
| val _ = SchemaExpr.bitwise(intLit(12), intLit(10), SchemaExpr.BitwiseOperator.Xor) | ||
| assertTrue(true) | ||
| }, | ||
| test("LeftShift operator (Int)") { | ||
| val _ = SchemaExpr.bitwise(intLit(5), intLit(2), SchemaExpr.BitwiseOperator.LeftShift) | ||
| assertTrue(true) | ||
| }, | ||
| test("RightShift operator (Int)") { | ||
| val _ = SchemaExpr.bitwise(intLit(20), intLit(2), SchemaExpr.BitwiseOperator.RightShift) | ||
| assertTrue(true) | ||
| }, | ||
| test("UnsignedRightShift operator (Int)") { | ||
| val _ = SchemaExpr.bitwise(intLit(20), intLit(2), SchemaExpr.BitwiseOperator.UnsignedRightShift) | ||
| assertTrue(true) | ||
| }, | ||
| test("And operator (Byte)") { | ||
| val expr = SchemaExpr.bitwise(byteLit(12), byteLit(10), SchemaExpr.BitwiseOperator.And) | ||
| val result = expr.evalDynamic(()) | ||
| assertTrue(result.isRight && result.exists(seq => seq.nonEmpty)) | ||
| }, | ||
| test("evalDynamic with byte and byte (And)") { | ||
| val expr = SchemaExpr.bitwise(byteLit(12), byteLit(10), SchemaExpr.BitwiseOperator.And) | ||
| val result = expr.evalDynamic(()) | ||
| assertTrue(result.isRight && result.exists(seq => seq.nonEmpty)) | ||
| }, | ||
| test("evalDynamic with byte and byte (Or)") { | ||
| val expr = SchemaExpr.bitwise(byteLit(12), byteLit(10), SchemaExpr.BitwiseOperator.Or) | ||
| val result = expr.evalDynamic(()) | ||
| assertTrue(result.isRight && result.exists(seq => seq.nonEmpty)) | ||
| }, | ||
| test("evalDynamic with byte and byte (Xor)") { | ||
| val expr = SchemaExpr.bitwise(byteLit(12), byteLit(10), SchemaExpr.BitwiseOperator.Xor) | ||
| val result = expr.evalDynamic(()) | ||
| assertTrue(result.isRight && result.exists(seq => seq.nonEmpty)) | ||
| }, | ||
| test("evalDynamic with short and short (And)") { | ||
| val expr = SchemaExpr.bitwise(shortLit(12), shortLit(10), SchemaExpr.BitwiseOperator.And) | ||
| val result = expr.evalDynamic(()) | ||
| assertTrue(result.isRight && result.exists(seq => seq.nonEmpty)) | ||
| }, | ||
| test("evalDynamic with short and short (Or)") { | ||
| val expr = SchemaExpr.bitwise(shortLit(12), shortLit(10), SchemaExpr.BitwiseOperator.Or) | ||
| val result = expr.evalDynamic(()) | ||
| assertTrue(result.isRight && result.exists(seq => seq.nonEmpty)) | ||
| }, | ||
| test("evalDynamic with int and int (Xor)") { | ||
| val expr = SchemaExpr.bitwise(intLit(12), intLit(10), SchemaExpr.BitwiseOperator.Xor) | ||
| val result = expr.evalDynamic(()) | ||
| assertTrue(result.isRight && result.exists(seq => seq.nonEmpty)) | ||
| }, | ||
| test("evalDynamic with byte and byte (And)") { | ||
| val _ = SchemaExpr.bitwise(byteLit(12), byteLit(10), SchemaExpr.BitwiseOperator.And) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with byte and byte (Or)") { | ||
| val _ = SchemaExpr.bitwise(byteLit(12), byteLit(10), SchemaExpr.BitwiseOperator.Or) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with byte and byte (Xor)") { | ||
| val _ = SchemaExpr.bitwise(byteLit(12), byteLit(10), SchemaExpr.BitwiseOperator.Xor) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with short and short (And)") { | ||
| val _ = SchemaExpr.bitwise(shortLit(12), shortLit(10), SchemaExpr.BitwiseOperator.And) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with short and short (Or)") { | ||
| val _ = SchemaExpr.bitwise(shortLit(12), shortLit(10), SchemaExpr.BitwiseOperator.Or) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with int and int (Xor)") { | ||
| val _ = SchemaExpr.bitwise(intLit(12), intLit(10), SchemaExpr.BitwiseOperator.Xor) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with long and long (LeftShift)") { | ||
| val _ = SchemaExpr.bitwise(longLit(12), longLit(2), SchemaExpr.BitwiseOperator.LeftShift) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with long and long (RightShift)") { | ||
| val _ = SchemaExpr.bitwise(longLit(12), longLit(2), SchemaExpr.BitwiseOperator.RightShift) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic with long and long (UnsignedRightShift)") { | ||
| val _ = SchemaExpr.bitwise(longLit(12), longLit(2), SchemaExpr.BitwiseOperator.UnsignedRightShift) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("BitwiseNot")( | ||
| test("negates int value") { | ||
| val _ = SchemaExpr.bitwiseNot(intLit(42)) | ||
| assertTrue(true) | ||
| }, | ||
| test("evalDynamic returns negated value") { | ||
| val _ = SchemaExpr.bitwiseNot(byteLit(5)) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringConcat")( | ||
| test("concatenates two strings") { | ||
| val _ = SchemaExpr.stringConcat(stringLit("hello"), stringLit("world")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringRegexMatch")( | ||
| test("matches regex pattern") { | ||
| val _ = SchemaExpr.stringRegexMatch(stringLit("[a-z]+"), stringLit("hello")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringLength")( | ||
| test("gets string length") { | ||
| val _ = SchemaExpr.stringLength(stringLit("hello")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringSubstring")( | ||
| test("extracts substring") { | ||
| val _ = SchemaExpr.stringSubstring(stringLit("hello"), intLit(0), intLit(3)) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringTrim")( | ||
| test("trims whitespace") { | ||
| val _ = SchemaExpr.stringTrim(stringLit(" hello ")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringToUpperCase")( | ||
| test("converts to uppercase") { | ||
| val _ = SchemaExpr.stringToUpperCase(stringLit("hello")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringToLowerCase")( | ||
| test("converts to lowercase") { | ||
| val _ = SchemaExpr.stringToLowerCase(stringLit("HELLO")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringReplace")( | ||
| test("replaces target with replacement") { | ||
| val _ = SchemaExpr.stringReplace(stringLit("hello world"), stringLit("world"), stringLit("scala")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringStartsWith")( | ||
| test("checks if string starts with prefix") { | ||
| val _ = SchemaExpr.stringStartsWith(stringLit("hello"), stringLit("he")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringEndsWith")( | ||
| test("checks if string ends with suffix") { | ||
| val _ = SchemaExpr.stringEndsWith(stringLit("hello"), stringLit("lo")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringContains")( | ||
| test("checks if string contains substring") { | ||
| val _ = SchemaExpr.stringContains(stringLit("hello world"), stringLit("world")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringConcat")( | ||
| test("concatenates two strings") { | ||
| val _ = SchemaExpr.stringConcat(stringLit("hello"), stringLit("world")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringRegexMatch")( | ||
| test("matches regex pattern") { | ||
| val _ = SchemaExpr.stringRegexMatch(stringLit("[a-z]+"), stringLit("hello")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringLength")( | ||
| test("gets string length") { | ||
| val _ = SchemaExpr.stringLength(stringLit("hello")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringSubstring")( | ||
| test("extracts substring") { | ||
| val _ = SchemaExpr.stringSubstring(stringLit("hello"), intLit(0), intLit(3)) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringTrim")( | ||
| test("trims whitespace") { | ||
| val _ = SchemaExpr.stringTrim(stringLit(" hello ")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringToUpperCase")( | ||
| test("converts to uppercase") { | ||
| val _ = SchemaExpr.stringToUpperCase(stringLit("hello")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringToLowerCase")( | ||
| test("converts to lowercase") { | ||
| val _ = SchemaExpr.stringToLowerCase(stringLit("HELLO")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringReplace")( | ||
| test("replaces target with replacement") { | ||
| val _ = SchemaExpr.stringReplace(stringLit("hello world"), stringLit("world"), stringLit("scala")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringStartsWith")( | ||
| test("checks if string starts with prefix") { | ||
| val _ = SchemaExpr.stringStartsWith(stringLit("hello"), stringLit("he")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringEndsWith")( | ||
| test("checks if string ends with suffix") { | ||
| val _ = SchemaExpr.stringEndsWith(stringLit("hello"), stringLit("lo")) | ||
| assertTrue(true) | ||
| } | ||
| ), | ||
| suite("StringContains")( | ||
| test("checks if string contains substring") { | ||
| val _ = SchemaExpr.stringContains(stringLit("hello world"), stringLit("world")) | ||
| assertTrue(true) | ||
| } |
| assertTrue( | ||
| result.isLeft, | ||
| result.swap.toOption.get.contains("Added[(\"age\" : String)]") | ||
| ) | ||
| } |
| assertTrue( | ||
| result.isLeft, | ||
| result.swap.toOption.get.contains("Renamed[(\"name\" : String), (\"fullName\" : String)]") | ||
| ) | ||
| } |
| }.map { result => | ||
| assertTrue( | ||
| result.isLeft, | ||
| result.swap.toOption.get.contains("Transformed[(\"age\" : String), (\"age\" : String)]") | ||
| ) |
| assertTrue( | ||
| result.isLeft, | ||
| result.swap.toOption.get.contains("Renamed[(\"name\" : String), (\"fullName\" : String)]"), | ||
| result.swap.toOption.get.contains("Added[(\"email\" : String)]") | ||
| ) |
| def fieldName: String = at.nodes.lastOption match { | ||
| case Some(DynamicOptic.Node.Field(name)) => name | ||
| case _ => throw new IllegalStateException("AddField path must end with a Field node") | ||
| } |
…rsible, test cleanup - Optionalize.reverse now returns Irreversible (empty Record default was broken) - fieldName/from methods return Option[String] instead of throwing - Replace unsafe .swap.toOption.get with .left.exists in type inference tests - Remove 56 no-op assertTrue(true) tests and deduplicate suites in SchemaExprSpec
Summary
Complete migration system with fully serializable expressions and ergonomic builder APIs.
Key Features
SchemaExpr[A, B](dynamic, inputSchema, outputSchema)for type safetyFieldName["address.street"]) via intersection types.migrateField(src, tgt, migration)with explicit/implicit resolutionliteral(1000)with implicit Schema, not verbose DynamicValue constructionAPI Examples
Ergonomic literals:
MigrationBuilder with selectors:
Nested field migration:
Migration composition:
Success Criteria Checklist
DynamicMigrationfully serializable (no closures, all data)Migration[A, B]wraps schemas and actionsDynamicOpticS => A) for "optics" on old and new types.buildto confirm "old" has been migrated to "new".buildPartialsupportedTest Coverage
Migration.fromActions(as API proof)Key Components
DynamicMigrationMigrationActions toDynamicValueMigration[A, B]apply/applyDynamic/reverse/++MigrationActionDynamicSchemaExprSchemaExpr[A, B]MigrationBuilderbuild(validates) /buildPartial(skips validation)Selector+ macros_.address.street) toDynamicOpticpathsFiles Changed
DynamicSchemaExpr.scalaSchemaExpr.scalaMigrationAction.scalaDynamicMigration.scalaMigrationBuilder.scala(Scala 2 & 3)MigrationSpec.scalaCloses #519