diff --git a/.scalafmt.conf b/.scalafmt.conf index bd27dd51c9..0354a85cef 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -24,6 +24,9 @@ rewriteTokens = { } fileOverride { + "glob:**/scala-2/**" { + runner.dialect = scala213 + } "glob:**/scala-3/**" { runner.dialect = scala3 } diff --git a/docs/reference/migration.md b/docs/reference/migration.md new file mode 100644 index 0000000000..7273bd2456 --- /dev/null +++ b/docs/reference/migration.md @@ -0,0 +1,319 @@ +# 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 +- **Build-time validation**: Structural correctness is verified when you call `build` +- **Compile-time field tracking** (Scala 3): Verify all fields are handled at compile time +- **Path-aware errors**: Detailed error messages with exact location information + +## Core Types + +### Migration[A, B] + +A typed migration from schema `A` to schema `B`. Use `MigrationBuilder` to construct one: + +```scala +import zio.blocks.schema._ +import zio.blocks.schema.migration._ + +// Import serialization schemas when (de)serializing migrations +import zio.blocks.schema.migration.MigrationSchemas._ + +@schema case class PersonV1(name: String, age: Int) +@schema case class PersonV2(fullName: String, age: Int, country: String) + +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 // skips structural validation +``` + +`build` validates that the migration actions produce a structurally correct target schema. +`buildPartial` skips validation and is useful during development or when validation is too strict. + +### DynamicMigration + +An untyped, serializable migration that operates directly on `DynamicValue`. Every `Migration[A, B]` +contains a `DynamicMigration` accessible via `.dynamicMigration`: + +```scala +val dynamicMigration: DynamicMigration = migration.dynamicMigration + +import zio.blocks.chunk.Chunk + +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) +// Right(Record(Chunk("fullName" -> Primitive(String("John")), "age" -> Primitive(Int(30)), "country" -> Primitive(String("US"))))) +``` + +### MigrationAction + +Individual migration steps are represented as an algebraic data type. Each action is reversible: + +| Action | Description | +|--------|-------------| +| `AddField` | Add a new field with a default value expression | +| `DropField` | Remove a field (stores a reverse default) | +| `RenameField` | Rename a field | +| `TransformValue` | Transform a field's value using a `DynamicSchemaExpr` | +| `Mandate` | Make an optional field mandatory (unwrap `Option`) | +| `Optionalize` | Make a mandatory field optional (wrap in `Option`) | +| `ChangeType` | Convert between primitive types (e.g., `Int` → `Long`) | +| `Join` | Combine multiple source fields into one target field | +| `Split` | Split one source field into multiple target fields | +| `RenameCase` | Rename a case in a variant/enum | +| `TransformCase` | Apply nested actions within a specific variant case | +| `TransformElements` | Transform every element in a sequence | +| `TransformKeys` | Transform every key in a map | +| `TransformValues` | Transform every value in a map | +| `Identity` | No-op action (useful as a placeholder) | + +### DynamicSchemaExpr + +A serializable expression language for computing values during migration. Expressions are evaluated +against `DynamicValue` at runtime: + +```scala +// Literal value +val lit = DynamicSchemaExpr.Literal( + DynamicValue.Primitive(PrimitiveValue.Int(42)) +) + +// Extract a value by path +val nameExpr = DynamicSchemaExpr.Path(DynamicOptic.root.field("name")) + +// Arithmetic on numeric fields +val doubled = DynamicSchemaExpr.Arithmetic( + nameExpr, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.ArithmeticOperator.Multiply +) + +// String operations +val concat = DynamicSchemaExpr.StringConcat(expr1, expr2) +val length = DynamicSchemaExpr.StringLength(stringExpr) + +// Primitive type coercion (e.g., Int → String) +val coerced = DynamicSchemaExpr.CoercePrimitive(intExpr, "String") +``` + +## MigrationBuilder API + +The builder provides a fluent API for constructing migrations. All path arguments are `DynamicOptic` +values — use `MigrationBuilder.paths` helpers or the type-safe selector syntax below: + +```scala +Migration + .newBuilder[OldType, NewType] + // Record operations + .addField(path, defaultExpr) // add a field with a default + .dropField(path, defaultForReverse) // remove a field + .renameField(fromPath, toPath) // rename a field + .transformField(path, transform, reverseTransform) // transform a field value + .mandateField(path, default) // Option[T] → T + .optionalizeField(path) // T → Option[T] + .changeFieldType(path, converter, reverseConverter) // change primitive type + .joinFields(targetPath, sourcePaths, combiner, splitter) + .splitField(sourcePath, targetPaths, splitter, combiner) + // Enum/variant operations + .renameCaseAt(path, from, to) + .transformCaseAt(path, caseName, nestedActions) + // Collection operations + .transformElements(path, transform, reverseTransform) + .transformKeys(path, transform, reverseTransform) + .transformValues(path, transform, reverseTransform) + // Build + .build // validates structural correctness, then builds + .buildPartial // builds without validation +``` + +## Type-Safe Selector Syntax (Scala 2 & 3) + +For ergonomic, type-checked paths, import the selector syntax. The macro inspects selector +lambdas like `_.fieldName.nested` and converts them to `DynamicOptic` paths at compile time: + +```scala +import zio.blocks.schema.migration.MigrationBuilderSyntax._ + +val migration: Migration[PersonV1, PersonV2] = + Migration + .newBuilder[PersonV1, PersonV2] + .renameField(_.name, _.fullName) + .addField(_.country, "US") + .buildPartial +``` + +Selector lambdas support optic-like projections for nested structures: + +| Projection | Meaning | +|------------|---------| +| `_.field` | Select a record field | +| `_.field.nested` | Select a nested field | +| `_.each` | Traverse into sequence elements | +| `_.eachKey` | Traverse into map keys | +| `_.eachValue` | Traverse into map values | + +## Path Helpers + +When you don't need compile-time type checking, use the `paths` object: + +```scala +import MigrationBuilder.paths + +paths.field("name") // Single field +paths.field("address", "street") // Nested field (address.street) +paths.elements // Sequence elements +paths.mapKeys // Map keys +paths.mapValues // Map values +``` + +## Reversibility + +Every migration action stores enough information to be reversed. Call `.reverse` to get +a `Migration[B, A]`: + +```scala +val forward: Migration[PersonV1, PersonV2] = ... +val backward: Migration[PersonV2, PersonV1] = forward.reverse + +// Reverse of addField is dropField, reverse of rename is rename back, etc. +``` + +> **Note:** Reverse transforms are resolved best-effort at build time. For `TransformValue` +> and `ChangeType`, provide explicit reverse expressions for reliable round-tripping. + +## Composition + +Migrations compose sequentially with `++` or `.andThen`: + +```scala +val v1ToV2: Migration[V1, V2] = ... +val v2ToV3: Migration[V2, V3] = ... + +val v1ToV3: Migration[V1, V3] = v1ToV2 ++ v2ToV3 +// or equivalently: +val v1ToV3: Migration[V1, V3] = v1ToV2.andThen(v2ToV3) +``` + +## Error Handling + +All migration operations return `Either[MigrationError, DynamicValue]`. Errors are accumulated +(not short-circuiting) and carry path information: + +```scala +migration.applyDynamic(value) match { + case Right(newValue) => // success + case Left(migrationError) => + migrationError.errors.foreach { error => + println(s"At ${error.path}: ${error.message}") + } +} +``` + +Error types include: + +| Error | Description | +|-------|-------------| +| `FieldNotFound` | Required field missing from source | +| `FieldAlreadyExists` | Field already exists when adding | +| `NotARecord` | Expected a record, found something else | +| `NotAVariant` | Expected a variant, found something else | +| `NotASequence` | Expected a sequence, found something else | +| `NotAMap` | Expected a map, found something else | +| `CaseNotFound` | Variant case not found | +| `TypeConversionFailed` | Primitive type conversion failed | +| `ExprEvalFailed` | Expression evaluation failed | +| `PathNavigationFailed` | Cannot navigate the specified path | +| `DefaultValueMissing` | Default value not resolved for a required field | +| `IndexOutOfBounds` | Sequence index out of range | +| `KeyNotFound` | Map key not found | +| `NumericOverflow` | Arithmetic overflow | +| `ActionFailed` | General action failure | + +## Best Practices + +1. **Use `build` in production** to catch structural mismatches early; use `buildPartial` during prototyping +2. **Provide explicit reverse expressions** for `transformField` and `changeFieldType` to ensure reliable round-tripping +3. **Compose small migrations** rather than writing one large migration — this improves readability and testability +4. **Test both directions** — apply forward, then reverse, and verify the round-trip +5. **Serialize migrations** alongside schema versions for audit trails and reproducibility +6. **Use `checkedBuilder` (Scala 3)** to catch missing fields at compile time instead of runtime + +## Compile-Time Field Tracking (Scala 3 Only) + +For Scala 3 projects, `TrackedMigrationBuilder` adds **type-level field name tracking** that validates +migration completeness at compile time. If you forget to handle a source field or provide a target +field, you get a compile error — not a runtime failure. + +### Quick Start + +```scala +import zio.blocks.schema._ +import zio.blocks.schema.migration._ +import zio.blocks.schema.migration.MigrationBuilderSyntax._ + +case class PersonV1(name: String, age: Int, ssn: String) derives Schema +case class PersonV2(fullName: String, age: Int, email: String) derives Schema + +val migration = MigrationBuilderSyntax.checkedBuilder[PersonV1, PersonV2] + .renameField(_.name, _.fullName) // handles source "name", provides target "fullName" + .dropField(_.ssn) // handles source "ssn" + .addField(_.email, "unknown") // provides target "email" + .build // ✅ compiles — all fields accounted for +``` + +If you forget `.dropField(_.ssn)`, the compiler produces: + +``` +Migration is incomplete. + +Unhandled source fields: ssn + +Hints: Use .dropField or .renameField for: ssn + +Source fields: name, age, ssn +Target fields: fullName, age, email +Auto-mapped: age +``` + +### Entry Points + +| Entry point | Description | +|-------------|-------------| +| `MigrationBuilderSyntax.checkedBuilder[A, B]` | Create a tracked builder directly | +| `Migration.newBuilder[A, B].tracked` | Convert an existing builder to tracked | + +### Build Modes + +| Method | Compile-time check | Runtime validation | +|--------|-------------------|-------------------| +| `.build` | ✅ `MigrationComplete` required | ✅ `MigrationValidator` runs | +| `.buildChecked` | ✅ `MigrationComplete` required | ❌ Skipped | +| `.buildPartial` | ❌ Not required | ❌ Skipped | + +### How It Works + +The builder carries two Tuple type parameters: +- **`SH` (SourceHandled)**: Accumulates source field names handled via `dropField` / `renameField` +- **`TP` (TargetProvided)**: Accumulates target field names provided via `addField` / `renameField` + +Fields with the same name in source and target are **auto-mapped** and don't need explicit handling. + +At `.build` time, the compiler summons `MigrationComplete[A, B, SH, TP]` evidence, which uses a +macro to verify: `sourceFields - autoMapped ⊆ SH` and `targetFields - autoMapped ⊆ TP`. + +> **Note:** This feature requires Scala 3 (transparent inline macros + Tuple types). Scala 2 +> projects should use the standard `MigrationBuilder` with runtime validation via `.build`. diff --git a/docs/reference/schema.md b/docs/reference/schema.md index 7d9bd401a9..794c8e96c9 100644 --- a/docs/reference/schema.md +++ b/docs/reference/schema.md @@ -221,9 +221,10 @@ Having the schema for `DynamicValue` allows seamless encoding/decoding between ` ```scala import zio.blocks.schema._ +import zio.blocks.chunk.Chunk // Records have unquoted keys -val record = DynamicValue.Record(Vector( +val record = DynamicValue.Record(Chunk( "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) )) @@ -234,7 +235,7 @@ println(record) // } // Maps have quoted string keys -val map = DynamicValue.Map(Vector( +val map = DynamicValue.Map(Chunk( DynamicValue.Primitive(PrimitiveValue.String("key")) -> DynamicValue.Primitive(PrimitiveValue.String("value")) )) @@ -244,7 +245,7 @@ println(map) // } // Variants use @ metadata -val variant = DynamicValue.Variant("Some", DynamicValue.Record(Vector( +val variant = DynamicValue.Variant("Some", DynamicValue.Record(Chunk( "value" -> DynamicValue.Primitive(PrimitiveValue.Int(42)) ))) println(variant) diff --git a/docs/reference/validation.md b/docs/reference/validation.md index e3810e527a..4b78ff259b 100644 --- a/docs/reference/validation.md +++ b/docs/reference/validation.md @@ -320,8 +320,10 @@ object Person { // Create a DynamicSchema for validation val dynamicSchema: DynamicSchema = Schema[Person].toDynamicSchema +import zio.blocks.chunk.Chunk + // Create a DynamicValue to validate -val value = DynamicValue.Record(Vector( +val value = DynamicValue.Record(Chunk( "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) )) @@ -345,7 +347,7 @@ val dynamicSchema: DynamicSchema = Schema[Person].toDynamicSchema val validatingSchema: Schema[DynamicValue] = dynamicSchema.toSchema // Now any decoding through this schema will validate structure -val invalidValue = DynamicValue.Record(Vector( +val invalidValue = DynamicValue.Record(Chunk( "name" -> DynamicValue.Primitive(PrimitiveValue.Int(42)) // wrong type! )) diff --git a/schema-toon/src/test/scala/zio/blocks/schema/toon/ToonTestUtils.scala b/schema-toon/src/test/scala/zio/blocks/schema/toon/ToonTestUtils.scala index 363cc6e339..94b2331fe6 100644 --- a/schema-toon/src/test/scala/zio/blocks/schema/toon/ToonTestUtils.scala +++ b/schema-toon/src/test/scala/zio/blocks/schema/toon/ToonTestUtils.scala @@ -12,6 +12,8 @@ import scala.collection.immutable.ArraySeq import scala.util.Try object ToonTestUtils { + private def normalizeNewlines(s: String): String = + s.replace("\r\n", "\n").replace("\r", "\n") def roundTrip[A](value: A, expectedToon: String)(implicit schema: Schema[A]): TestResult = roundTrip(value, expectedToon, getOrDeriveCodec(schema)) @@ -47,7 +49,7 @@ object ToonTestUtils { val encodedBySchema3 = output.toByteArray val encodedBySchema4 = codec.encode(value, writerConfig) val encodedBySchema5 = codec.encodeToString(value, writerConfig).getBytes(UTF_8) - assert(new String(encodedBySchema1, UTF_8))(equalTo(expectedToon)) && + assert(normalizeNewlines(new String(encodedBySchema1, UTF_8)))(equalTo(normalizeNewlines(expectedToon))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema2))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema3))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema4))) && @@ -110,7 +112,7 @@ object ToonTestUtils { ): TestResult = { val codec = ToonBinaryCodec.dynamicValueCodec val result = codec.encodeToString(value, writerConfig) - assert(result)(equalTo(expectedToon)) + assert(normalizeNewlines(result))(equalTo(normalizeNewlines(expectedToon))) } def decodeError[A](invalidToon: String, error: String)(implicit schema: Schema[A]): TestResult = @@ -181,7 +183,7 @@ object ToonTestUtils { val encodedBySchema3 = output.toByteArray val encodedBySchema4 = codec.encode(value, writerConfig) val encodedBySchema5 = codec.encodeToString(value, writerConfig).getBytes(UTF_8) - assert(new String(encodedBySchema1, UTF_8))(equalTo(expectedToon)) && + assert(normalizeNewlines(new String(encodedBySchema1, UTF_8)))(equalTo(normalizeNewlines(expectedToon))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema2))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema3))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema4))) && diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala index d7f71670ee..ec25309592 100644 --- a/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala @@ -11,6 +11,17 @@ import scala.reflect.NameTransformer trait SchemaCompanionVersionSpecific { def derived[A]: Schema[A] = macro SchemaCompanionVersionSpecific.derived[A] + + /** + * Derive a schema for a structural type. This is only supported in Scala 3 + * due to the requirement for: + * - Refinement types (structural records): + * `{ val name: String; val age: Int }` + * - Union types (structural enums): `Type1 | Type2` + * + * In Scala 2, use regular case classes with `Schema.derived[A]` instead. + */ + def structural[A]: Schema[A] = macro SchemaCompanionVersionSpecific.structural[A] } private object SchemaCompanionVersionSpecific { @@ -936,4 +947,11 @@ private object SchemaCompanionVersionSpecific { // c.info(c.enclosingPosition, s"Generated schema:\n${showCode(schemaBlock)}", force = true) c.Expr[Schema[A]](schemaBlock) } + + def structural[A: c.WeakTypeTag](c: blackbox.Context): c.Expr[Schema[A]] = + CommonMacroOps.fail(c)( + "Schema.structural is only supported in Scala 3. " + + "Structural types (refinement types and union types) are a Scala 3 feature. " + + "In Scala 2, please use regular case classes with Schema.derived[A] instead." + ) } diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/migration/MigrationBuilderSyntax.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/migration/MigrationBuilderSyntax.scala new file mode 100644 index 0000000000..81d40513cb --- /dev/null +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/migration/MigrationBuilderSyntax.scala @@ -0,0 +1,292 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import scala.annotation.compileTimeOnly +import scala.language.experimental.macros +import scala.reflect.macros.blackbox + +/** + * Scala 2 implicit class extension for [[MigrationBuilder]] that provides + * type-safe selector syntax. + * + * These extensions allow using lambda expressions like `_.fieldName` instead of + * constructing [[DynamicOptic]] paths manually. + * + * Usage: + * {{{ + * import MigrationBuilderSyntax._ + * + * MigrationBuilder[PersonV1, PersonV2] + * .addField[Int](_.age, 0) + * .renameField(_.firstName, _.givenName) + * .dropField(_.middleName) + * .build + * }}} + */ +object MigrationBuilderSyntax { + + // ==================== Selector-only Syntax ==================== + // + // These implicit classes exist solely to support the selector syntax + // consumed by `SelectorMacros`. They intentionally fail if evaluated outside + // of selector macros. + + implicit class ValueSelectorOps[A](private val a: A) extends AnyVal { + @compileTimeOnly("Can only be used inside migration selector macros") + def when[B <: A]: B = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def wrapped[B]: B = ??? + } + + implicit class SequenceSelectorOps[C[_], A](private val c: C[A]) extends AnyVal { + @compileTimeOnly("Can only be used inside migration selector macros") + def at(index: Int): A = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def atIndices(indices: Int*): A = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def each: A = ??? + } + + implicit class MapSelectorOps[M[_, _], K, V](private val m: M[K, V]) extends AnyVal { + @compileTimeOnly("Can only be used inside migration selector macros") + def atKey(key: K): V = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def atKeys(keys: K*): V = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def eachKey: K = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def eachValue: V = ??? + } + + // ==================== Builder Syntax ==================== + + implicit class MigrationBuilderOps[A, B](private val builder: MigrationBuilder[A, B]) extends AnyVal { + + /** + * Add a field with a type-safe selector and literal default. + */ + def addField[T](selector: B => T, default: T)(implicit schema: Schema[T]): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.addFieldImpl[A, B, T] + + /** + * Add a field with a type-safe selector and expression default. + */ + def addFieldExpr[T](selector: B => T, default: DynamicSchemaExpr): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.addFieldExprImpl[A, B, T] + + /** + * Drop a field using a type-safe selector. + */ + def dropField[T](selector: A => T): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.dropFieldImpl[A, B, T] + + /** + * Rename a field using type-safe selectors. + */ + def renameField[T, U](from: A => T, to: B => U): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.renameFieldImpl[A, B, T, U] + + /** + * Transform a field using a type-safe selector. + */ + def transformField[T](selector: A => T, transform: DynamicSchemaExpr): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.transformFieldImpl[A, B, T] + + /** + * Make an optional field mandatory with type-safe selector. + */ + def mandateField[T](selector: B => T, default: T)(implicit schema: Schema[T]): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.mandateFieldImpl[A, B, T] + + /** + * Make a mandatory field optional using a type-safe selector. + */ + def optionalizeField[T](selector: A => T): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.optionalizeFieldImpl[A, B, T] + + /** + * Make a mandatory field optional using a type-safe selector with a reverse + * default. + */ + def optionalizeFieldExpr[T]( + selector: A => T, + defaultForReverse: DynamicSchemaExpr + ): MigrationBuilder[A, B] = macro MigrationBuilderSyntaxImpl.optionalizeFieldExprImpl[A, B, T] + + /** + * Transform all elements in a sequence field. + */ + def transformElements[T](selector: A => Seq[T], transform: DynamicSchemaExpr): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.transformElementsImpl[A, B, T] + + /** + * Transform map keys using type-safe selector. + */ + def transformKeys[K, V](selector: A => Map[K, V], transform: DynamicSchemaExpr): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.transformKeysImpl[A, B, K, V] + + /** + * Transform map values using type-safe selector. + */ + def transformValues[K, V](selector: A => Map[K, V], transform: DynamicSchemaExpr): MigrationBuilder[A, B] = + macro MigrationBuilderSyntaxImpl.transformValuesImpl[A, B, K, V] + } + + /** + * Convenient syntax for creating paths from selectors, e.g.: + * `MigrationBuilder.paths.from[User, String](_.name)`. + */ + implicit class PathsOps(private val paths: MigrationBuilder.paths.type) extends AnyVal { + def from[A, B](selector: A => B): DynamicOptic = macro MigrationBuilderSyntaxImpl.fromImpl[A, B] + } +} + +class MigrationBuilderSyntaxImpl(val c: blackbox.Context) { + import c.universe._ + + private def unwrapBuilder(prefix: Tree): Tree = prefix match { + case Apply(_, List(b)) => b + case TypeApply(Apply(_, List(b)), _) => b + case Apply(TypeApply(_, _), List(b)) => b + case Apply(Select(_, _), List(b)) => b + case other => + c.abort(c.enclosingPosition, s"Unexpected macro prefix: ${showRaw(other)}") + } + + private def toOpticTree[A: WeakTypeTag, B: WeakTypeTag](selector: c.Expr[A => B]): Tree = { + val aTpe = weakTypeOf[A] + val bTpe = weakTypeOf[B] + q"_root_.zio.blocks.schema.migration.SelectorMacros.toOptic[$aTpe, $bTpe]($selector)" + } + + def fromImpl[A: WeakTypeTag, B: WeakTypeTag](selector: c.Expr[A => B]): c.Expr[DynamicOptic] = + c.Expr[DynamicOptic](toOpticTree[A, B](selector)) + + def addFieldImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[B => T], + default: c.Expr[T] + )(schema: c.Expr[Schema[T]]): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[B, T](selector) + val builder = unwrapBuilder(c.prefix.tree) + val tpe = weakTypeOf[T] + c.Expr[MigrationBuilder[A, B]]( + q"$builder.addField[$tpe]($optic, $default)($schema)" + ) + } + + def addFieldExprImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[B => T], + default: c.Expr[DynamicSchemaExpr] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[B, T](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.addField($optic, $default)" + ) + } + + def dropFieldImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[A => T] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[A, T](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.dropField($optic)" + ) + } + + def renameFieldImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag, U: WeakTypeTag]( + from: c.Expr[A => T], + to: c.Expr[B => U] + ): c.Expr[MigrationBuilder[A, B]] = { + val fromOptic = toOpticTree[A, T](from) + val toOptic = toOpticTree[B, U](to) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.renameField($fromOptic, $toOptic)" + ) + } + + def transformFieldImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[A => T], + transform: c.Expr[DynamicSchemaExpr] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[A, T](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.transformField($optic, $transform)" + ) + } + + def mandateFieldImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[B => T], + default: c.Expr[T] + )(schema: c.Expr[Schema[T]]): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[B, T](selector) + val builder = unwrapBuilder(c.prefix.tree) + val tpe = weakTypeOf[T] + c.Expr[MigrationBuilder[A, B]]( + q"$builder.mandateField[$tpe]($optic, $default)($schema)" + ) + } + + def optionalizeFieldImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[A => T] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[A, T](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.optionalizeField($optic)" + ) + } + + def optionalizeFieldExprImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[A => T], + defaultForReverse: c.Expr[DynamicSchemaExpr] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[A, T](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.optionalizeField($optic, $defaultForReverse)" + ) + } + + def transformElementsImpl[A: WeakTypeTag, B: WeakTypeTag, T: WeakTypeTag]( + selector: c.Expr[A => Seq[T]], + transform: c.Expr[DynamicSchemaExpr] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[A, Seq[T]](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.transformElements($optic, $transform)" + ) + } + + def transformKeysImpl[A: WeakTypeTag, B: WeakTypeTag, K: WeakTypeTag, V: WeakTypeTag]( + selector: c.Expr[A => Map[K, V]], + transform: c.Expr[DynamicSchemaExpr] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[A, Map[K, V]](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.transformKeys($optic, $transform)" + ) + } + + def transformValuesImpl[A: WeakTypeTag, B: WeakTypeTag, K: WeakTypeTag, V: WeakTypeTag]( + selector: c.Expr[A => Map[K, V]], + transform: c.Expr[DynamicSchemaExpr] + ): c.Expr[MigrationBuilder[A, B]] = { + val optic = toOpticTree[A, Map[K, V]](selector) + val builder = unwrapBuilder(c.prefix.tree) + c.Expr[MigrationBuilder[A, B]]( + q"$builder.transformValues($optic, $transform)" + ) + } +} diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/migration/SelectorMacros.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/migration/SelectorMacros.scala new file mode 100644 index 0000000000..5929d358c1 --- /dev/null +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/migration/SelectorMacros.scala @@ -0,0 +1,189 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import scala.language.experimental.macros +import scala.reflect.macros.blackbox + +/** + * Scala 2 macros for type-safe selector expressions. + * + * These macros convert lambda expressions like `_.fieldName.nested` into + * [[DynamicOptic]] paths at compile time. + */ +object SelectorMacros { + + /** + * Convert a selector lambda to a DynamicOptic. + */ + def toOptic[A, B](selector: A => B): DynamicOptic = macro SelectorMacrosImpl.toOpticImpl[A, B] + + /** + * Convert a selector lambda to a Selector. + */ + def toSelector[A, B](selector: A => B): Selector[A, B] = macro SelectorMacrosImpl.toSelectorImpl[A, B] +} + +class SelectorMacrosImpl(val c: blackbox.Context) { + import c.universe._ + + def toOpticImpl[A: WeakTypeTag, B: WeakTypeTag](selector: c.Expr[A => B]): c.Expr[DynamicOptic] = { + // Use tags to avoid -Xfatal-warnings unused-parameter errors in Scala 2. + val _ = weakTypeOf[A] + val _ = weakTypeOf[B] + val segments = extractPathSegments(selector.tree) + buildDynamicOptic(segments) + } + + def toSelectorImpl[A: WeakTypeTag, B: WeakTypeTag](selector: c.Expr[A => B]): c.Expr[Selector[A, B]] = { + val segments = extractPathSegments(selector.tree) + buildSelector[A, B](segments) + } + + private sealed trait PathSegment + private case class FieldAccess(name: String) extends PathSegment + private case class CaseAccess(name: String) extends PathSegment + private case object ElementsAccess extends PathSegment + private case object MapKeysAccess extends PathSegment + private case object MapValuesAccess extends PathSegment + private case object WrappedAccess extends PathSegment + private case class AtIndexAccess(index: Tree) extends PathSegment + private case class AtIndicesAccess(indices: List[Tree]) extends PathSegment + private case class AtMapKeyAccess(key: Tree) extends PathSegment + private case class AtMapKeysAccess(keys: List[Tree]) extends PathSegment + private case object OptionGet extends PathSegment + + private def extractPathSegments(tree: Tree): List[PathSegment] = { + def extract(t: Tree): List[PathSegment] = t match { + // Lambda: (x: A) => expr + case Function(_, body) => + extract(body) + + // Block with just the body + case Block(Nil, body) => + extract(body) + + // .each / .eachKey / .eachValue + case q"$_[..$_]($parent).each" => + extract(parent) :+ ElementsAccess + case q"$_[..$_]($parent).eachKey" => + extract(parent) :+ MapKeysAccess + case q"$_[..$_]($parent).eachValue" => + extract(parent) :+ MapValuesAccess + + // .wrapped[T] + case q"$_[..$_]($parent).wrapped[$_]" => + extract(parent) :+ WrappedAccess + + // .when[T] + case q"$_[..$_]($parent).when[$caseTree]" => + val name = caseTree.tpe.typeSymbol.name.decodedName.toString.stripSuffix("$") + extract(parent) :+ CaseAccess(name) + + // .at(index) + case q"$_[..$_]($parent).at(..$args)" if args.size == 1 => + extract(parent) :+ AtIndexAccess(args.head) + + // .atIndices(i1, i2, ...) + case q"$_[..$_]($parent).atIndices(..$args)" => + extract(parent) :+ AtIndicesAccess(args) + + // .atKey(key) + case q"$_[..$_]($parent).atKey(..$args)" if args.size == 1 => + extract(parent) :+ AtMapKeyAccess(args.head) + + // .atKeys(k1, k2, ...) + case q"$_[..$_]($parent).atKeys(..$args)" => + extract(parent) :+ AtMapKeysAccess(args) + + // Method selection: _.field + case Select(qualifier, name) if name.toString != "apply" => + val segments = extract(qualifier) + name.toString match { + case "get" => segments :+ OptionGet + case "head" => segments :+ AtIndexAccess(Literal(Constant(0))) + case "keys" => segments :+ MapKeysAccess + case "values" => segments :+ MapValuesAccess + case fieldName => segments :+ FieldAccess(fieldName) + } + + // Apply for indexed access: _.seq(0) + case Apply(Select(qualifier, TermName("apply")), List(idxTree)) + if idxTree.tpe.widen.dealias <:< definitions.IntTpe => + extract(qualifier) :+ AtIndexAccess(idxTree) + + // Typed expression + case Typed(expr, _) => + extract(expr) + + // Identifier (the parameter reference) + case Ident(_) => + Nil + + case other => + c.abort( + c.enclosingPosition, + s"Unsupported selector expression: ${showRaw(other)}. " + + "Expected path elements: ., .when[], .at(), .atIndices(), .atKey(), " + + ".atKeys(), .each, .eachKey, .eachValue, .wrapped[], or optional `.get`." + ) + } + + extract(tree) + } + + private def buildDynamicOptic(segments: List[PathSegment]): c.Expr[DynamicOptic] = + if (segments.isEmpty) { + c.Expr[DynamicOptic](q"_root_.zio.blocks.schema.DynamicOptic.root") + } else { + val initialOptic = q"_root_.zio.blocks.schema.DynamicOptic.root" + + val resultTree = segments.foldLeft(initialOptic: Tree) { (optic, segment) => + segment match { + case FieldAccess(name) => + q"$optic.field($name)" + case CaseAccess(name) => + q"$optic.caseOf($name)" + case ElementsAccess => + q"$optic.elements" + case MapKeysAccess => + q"$optic.mapKeys" + case MapValuesAccess => + q"$optic.mapValues" + case WrappedAccess => + q"$optic.wrapped" + case AtIndexAccess(index) => + q"$optic.at($index)" + case AtIndicesAccess(indices) => + q"$optic.atIndices(..$indices)" + case AtMapKeyAccess(key) => + q"$optic.atKey($key)" + case AtMapKeysAccess(keys) => + q"$optic.atKeys(..$keys)" + case OptionGet => + // Optional access doesn't add to path + optic + } + } + c.Expr[DynamicOptic](resultTree) + } + + private def buildSelector[A: WeakTypeTag, B: WeakTypeTag](segments: List[PathSegment]): c.Expr[Selector[A, B]] = { + val aType = weakTypeOf[A] + val bType = weakTypeOf[B] + + if (segments.isEmpty) { + c.Expr[Selector[A, B]]( + q"_root_.zio.blocks.schema.migration.Selector.root[$aType].asInstanceOf[_root_.zio.blocks.schema.migration.Selector[$aType, $bType]]" + ) + } else { + val opticExpr = buildDynamicOptic(segments) + c.Expr[Selector[A, B]]( + q""" + new _root_.zio.blocks.schema.migration.Selector[$aType, $bType] { + def toOptic: _root_.zio.blocks.schema.DynamicOptic = $opticExpr + } + """ + ) + } + } +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala index 0bed19fcab..5c91b78408 100644 --- a/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala @@ -10,11 +10,26 @@ import zio.blocks.schema.binding.RegisterOffset._ trait SchemaCompanionVersionSpecific { inline def derived[A]: Schema[A] = ${ SchemaCompanionVersionSpecificImpl.derived } + + /** + * Derive a schema for a structural type. Structural types are + * compile-time-only types that do not require runtime representations: + * - Records: `{ val name: String; val age: Int }` + * - Enums: `Type1 | Type2` where each type has a `type Tag = "CaseName"` + * member + * + * These schemas work with DynamicValue at runtime but provide compile-time + * type safety. Used primarily for schema migration where old versions exist + * only as structural types. + */ + inline def structural[A]: Schema[A] = ${ SchemaCompanionVersionSpecificImpl.structural } } private object SchemaCompanionVersionSpecificImpl { def derived[A: Type](using Quotes): Expr[Schema[A]] = new SchemaCompanionVersionSpecificImpl().derived[A] + def structural[A: Type](using Quotes): Expr[Schema[A]] = new SchemaCompanionVersionSpecificImpl().structural[A] + private implicit val fullTermNameOrdering: Ordering[Array[String]] = new Ordering[Array[String]] { override def compare(x: Array[String], y: Array[String]): Int = { val minLen = Math.min(x.length, y.length) @@ -1390,4 +1405,196 @@ private class SchemaCompanionVersionSpecificImpl(using Quotes) { // report.info(s"Generated schema:\n${schemaBlock.show}", Position.ofMacroExpansion) schemaBlock } + + /** + * Derive a schema for a structural type. Handles: + * - Refinement types (structural records): + * `{ val name: String; val age: Int }` + * - Union types (structural enums): `Type1 | Type2` where types have + * `type Tag = "CaseName"` + */ + def structural[A: Type]: Expr[Schema[A]] = { + val aTpe = TypeRepr.of[A].dealias + + if (isUnion(aTpe)) { + // Structural enum: union of structural record types with Tag members + deriveStructuralVariant[A](aTpe) + } else if (isRefinementType(aTpe)) { + // Structural record + deriveStructuralRecord[A](aTpe) + } else { + // Fall back to regular derivation for compatible types + derived[A] + } + } + + private def isRefinementType(tpe: TypeRepr): Boolean = tpe match { + case Refinement(_, _, _) => true + case _ => false + } + + private case class StructuralField(name: String, tpe: TypeRepr) + + private def extractRefinementFields(tpe: TypeRepr): List[StructuralField] = { + def collectFields(t: TypeRepr): List[StructuralField] = t match { + case Refinement(parent, name, info) => + val parentFields = collectFields(parent) + info match { + case TypeBounds(_, _) => parentFields // Skip type members like `type Tag = "..."` + case _ => + // It's a val/def member - extract as a field + parentFields :+ StructuralField(name, info.widen) + } + case _ => Nil + } + collectFields(tpe).sortBy(_.name) + } + + private def extractTagFromRefinement(tpe: TypeRepr): Option[String] = { + def findTag(t: TypeRepr): Option[String] = t match { + case Refinement(_, "Tag", TypeBounds(ConstantType(StringConstant(tagName)), ConstantType(StringConstant(_)))) => + Some(tagName) + case Refinement(_, "Tag", ConstantType(StringConstant(tagName))) => + Some(tagName) + case Refinement(inner, _, _) => + findTag(inner) + case _ => None + } + findTag(tpe) + } + + private def deriveStructuralRecord[A: Type](tpe: TypeRepr): Expr[Schema[A]] = { + val fields = extractRefinementFields(tpe) + if (fields.isEmpty) { + fail(s"Structural type '${tpe.show}' has no val/def members. Expected `{ val field: Type; ... }`.") + } + + val fieldExprs: List[Expr[SchemaTerm[Binding, A, ?]]] = fields.map { field => + field.tpe.asType match { + case '[ft] => + val fieldSchema = findImplicitOrDeriveSchema[ft](field.tpe) + val fieldName = Expr(field.name) + '{ $fieldSchema.reflect.asTerm[A]($fieldName) } + } + } + + val typeName = Expr(tpe.show.replaceAll("\\s+", " ").take(100)) + val fieldsVec = Expr.ofSeq(fieldExprs) + val usedRegs = Expr(fields.length.toLong) + + '{ + new Schema( + reflect = new Reflect.Record[Binding, A]( + fields = Vector($fieldsVec*), + typeId = + zio.blocks.typeid.TypeId.nominal[A]($typeName, zio.blocks.typeid.Owner.fromPackagePath("structural")), + recordBinding = new Binding.Record( + constructor = new StructuralConstructor[A] { + def usedRegisters: RegisterOffset = $usedRegs + + def construct(in: Registers, offset: RegisterOffset): A = + // Structural types don't have runtime representations + // This constructor exists for type safety but throws if actually invoked + throw new UnsupportedOperationException( + "Structural types cannot be constructed at runtime. Use DynamicValue instead." + ) + }, + deconstructor = new StructuralDeconstructor[A] { + def usedRegisters: RegisterOffset = $usedRegs + + def deconstruct(out: Registers, offset: RegisterOffset, in: A): Unit = + // Structural types don't have runtime representations + throw new UnsupportedOperationException( + "Structural types cannot be deconstructed at runtime. Use DynamicValue instead." + ) + } + ) + ) + ) + } + } + + private def deriveStructuralVariant[A: Type](tpe: TypeRepr): Expr[Schema[A]] = { + val unionTypes = allUnionTypes(tpe) + + // Extract cases: each union member must be a refinement type with a Tag member + val cases: List[(String, TypeRepr, List[StructuralField])] = unionTypes.map { caseTpe => + val tag = extractTagFromRefinement(caseTpe).getOrElse { + fail(s"Structural enum case '${caseTpe.show}' must have a `type Tag = \"CaseName\"` member.") + } + val fields = extractRefinementFields(caseTpe) + (tag, caseTpe, fields) + } + + if (cases.isEmpty) { + fail(s"Structural enum '${tpe.show}' has no union members.") + } + + val typeName = Expr(tpe.show.replaceAll("\\s+", " ").take(100)) + + // Build case Terms using the same pattern as deriveSchemaForSealedTraitOrAbstractClassOrUnion + val caseTermExprs = Varargs(cases.map { case (tag, caseTpe, fields) => + caseTpe.asType match { + case '[ct] => + val fieldSchemas: List[Expr[(String, Schema[?])]] = fields.map { field => + field.tpe.asType match { + case '[ft] => + val fieldSchema = findImplicitOrDeriveSchema[ft](field.tpe) + val fieldName = Expr(field.name) + '{ ($fieldName, $fieldSchema) } + } + } + val caseTpeName = Expr(tag) + val usedRegs = Expr(fields.length.toLong) + val fieldsSeq = Expr.ofSeq(fieldSchemas) + + ('{ + val flds = $fieldsSeq + val fieldTerms = flds.map { case (n, s) => s.reflect.asTerm[ct](n) } + new Reflect.Record[Binding, ct]( + fields = fieldTerms.toVector, + typeId = zio.blocks.typeid.TypeId + .nominal[ct]($caseTpeName, zio.blocks.typeid.Owner.fromPackagePath("structural")), + recordBinding = new Binding.Record( + constructor = new StructuralConstructor[ct] { + def usedRegisters: RegisterOffset = $usedRegs + def construct(in: Registers, offset: RegisterOffset): ct = + throw new UnsupportedOperationException("Structural types cannot be constructed at runtime.") + }, + deconstructor = new StructuralDeconstructor[ct] { + def usedRegisters: RegisterOffset = $usedRegs + def deconstruct(out: Registers, offset: RegisterOffset, in: ct): Unit = + throw new UnsupportedOperationException("Structural types cannot be deconstructed at runtime.") + } + ) + ).asTerm[A]($caseTpeName) + }).asInstanceOf[Expr[SchemaTerm[Binding, A, ? <: A]]] + } + }) + + val matcherExprs = Varargs(cases.map { case (_, caseTpe, _) => + caseTpe.asType match { + case '[ct] => + '{ new StructuralMatcher[ct] }.asInstanceOf[Expr[Matcher[? <: A]]] + } + }) + + '{ + new Schema( + reflect = new Reflect.Variant[Binding, A]( + cases = Vector($caseTermExprs*), + typeId = + zio.blocks.typeid.TypeId.nominal[A]($typeName, zio.blocks.typeid.Owner.fromPackagePath("structural")), + variantBinding = new Binding.Variant( + discriminator = new Discriminator[A] { + def discriminate(a: A): Int = + // Runtime discrimination not supported for structural types + throw new UnsupportedOperationException("Structural types cannot be discriminated at runtime.") + }, + matchers = Matchers($matcherExprs*) + ) + ) + ) + } + } } diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationBuilderSyntax.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationBuilderSyntax.scala new file mode 100644 index 0000000000..f8499b1a0a --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationBuilderSyntax.scala @@ -0,0 +1,287 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import scala.annotation.compileTimeOnly + +/** + * Scala 3 extension methods for [[MigrationBuilder]] that provide type-safe + * selector syntax. + * + * These extensions allow using lambda expressions like `_.fieldName` instead of + * constructing [[DynamicOptic]] paths manually. + * + * Usage: + * {{{ + * import MigrationBuilderSyntax._ + * + * MigrationBuilder[PersonV1, PersonV2] + * .addField(_.age, 0) + * .renameField(_.firstName, _.givenName) + * .dropField(_.middleName) + * .build + * }}} + */ +object MigrationBuilderSyntax { + + // ==================== Selector-only Syntax ==================== + // + // These extension methods exist solely to support the selector syntax + // consumed by `SelectorMacros`. They intentionally fail if evaluated outside + // of selector macros. + + extension [A](a: A) { + @compileTimeOnly("Can only be used inside migration selector macros") + def when[B <: A]: B = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def wrapped[B]: B = ??? + } + + extension [C[_], A](c: C[A]) { + @compileTimeOnly("Can only be used inside migration selector macros") + def at(index: Int): A = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def atIndices(indices: Int*): A = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def each: A = ??? + } + + extension [M[_, _], K, V](m: M[K, V]) { + @compileTimeOnly("Can only be used inside migration selector macros") + def atKey(key: K): V = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def atKeys(keys: K*): V = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def eachKey: K = ??? + + @compileTimeOnly("Can only be used inside migration selector macros") + def eachValue: V = ??? + } + + // ==================== Builder Syntax ==================== + + extension [A, B](builder: MigrationBuilder[A, B]) { + + /** + * Add a field with a type-safe selector and literal default. + */ + inline def addField[T]( + inline selector: B => T, + default: T + )(using Schema[T]): MigrationBuilder[A, B] = + builder.addField(SelectorMacros.toOptic(selector), default) + + /** + * Add a field with a type-safe selector and expression default. + */ + inline def addFieldExpr[T]( + inline selector: B => T, + default: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.addField(SelectorMacros.toOptic(selector), default) + + /** + * Drop a field using a type-safe selector. + */ + inline def dropField[T]( + inline selector: A => T + ): MigrationBuilder[A, B] = + builder.dropField(SelectorMacros.toOptic(selector)) + + /** + * Drop a field with a default for reverse migration. + */ + inline def dropField[T]( + inline selector: A => T, + defaultForReverse: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.dropField(SelectorMacros.toOptic(selector), defaultForReverse) + + /** + * Rename a field using type-safe selectors. + */ + inline def renameField[T, U]( + inline from: A => T, + inline to: B => U + ): MigrationBuilder[A, B] = + builder.renameField( + SelectorMacros.toOptic(from), + SelectorMacros.toOptic(to) + ) + + /** + * Transform a field using a type-safe selector. + */ + inline def transformField[T]( + inline selector: A => T, + transform: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.transformField(SelectorMacros.toOptic(selector), transform) + + /** + * Transform a field with reverse expression. + */ + inline def transformField[T]( + inline selector: A => T, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.transformField( + SelectorMacros.toOptic(selector), + transform, + reverseTransform + ) + + /** + * Make an optional field mandatory with type-safe selector. + */ + inline def mandateField[T]( + inline selector: B => T, + default: T + )(using Schema[T]): MigrationBuilder[A, B] = + builder.mandateField(SelectorMacros.toOptic(selector), default) + + /** + * Make an optional field mandatory with expression default. + */ + inline def mandateFieldExpr[T]( + inline selector: B => T, + default: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.mandateField(SelectorMacros.toOptic(selector), default) + + /** + * Make a mandatory field optional using a type-safe selector. + */ + inline def optionalizeField[T]( + inline selector: A => T + ): MigrationBuilder[A, B] = + builder.optionalizeField(SelectorMacros.toOptic(selector)) + + /** + * Make a mandatory field optional using a type-safe selector with a reverse + * default. + */ + inline def optionalizeFieldExpr[T]( + inline selector: A => T, + defaultForReverse: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.optionalizeField(SelectorMacros.toOptic(selector), defaultForReverse) + + /** + * Change field type using type-safe selector. + */ + inline def changeFieldType[T]( + inline selector: A => T, + converter: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.changeFieldType( + SelectorMacros.toOptic(selector), + converter + ) + + /** + * Change field type with converter and reverse converter. + */ + inline def changeFieldType[T]( + inline selector: A => T, + converter: DynamicSchemaExpr, + reverseConverter: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.changeFieldType( + SelectorMacros.toOptic(selector), + converter, + reverseConverter + ) + + /** + * Transform all elements in a sequence field. + */ + inline def transformElements[T]( + inline selector: A => Seq[T], + transform: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.transformElements(SelectorMacros.toOptic(selector), transform) + + /** + * Transform all elements with reverse transform. + */ + inline def transformElements[T]( + inline selector: A => Seq[T], + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.transformElements( + SelectorMacros.toOptic(selector), + transform, + reverseTransform + ) + + /** + * Transform map keys using type-safe selector. + */ + inline def transformKeys[K, V]( + inline selector: A => Map[K, V], + transform: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.transformKeys(SelectorMacros.toOptic(selector), transform) + + /** + * Transform map values using type-safe selector. + */ + inline def transformValues[K, V]( + inline selector: A => Map[K, V], + transform: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + builder.transformValues(SelectorMacros.toOptic(selector), transform) + } + + /** + * Convenient extension for accessing paths object. + */ + extension (paths: MigrationBuilder.paths.type) { + + /** + * Create a path from a type-safe selector. + */ + inline def from[A, B](inline selector: A => B): DynamicOptic = + SelectorMacros.toOptic(selector) + } + + // ==================== Tracked Builder ==================== + + /** + * Convert an untracked builder to a compile-time-tracked builder. + * + * The tracked builder accumulates field names at the type level and validates + * migration completeness at compile time via [[MigrationComplete]]. + */ + extension [A, B](builder: MigrationBuilder[A, B]) { + def tracked: TrackedMigrationBuilder[A, B, EmptyTuple, EmptyTuple] = + new TrackedMigrationBuilder(builder) + } + + /** + * Create a compile-time-tracked migration builder. + * + * This is the recommended entry point for migrations that should be validated + * at compile time. Equivalent to `Migration.newBuilder[A, B].tracked`. + * + * Usage: + * {{{ + * val migration = MigrationBuilderSyntax.checkedBuilder[PersonV1, PersonV2] + * .addField(_.email, "unknown@example.com") + * .renameField(_.name, _.fullName) + * .build // compile error if incomplete + * }}} + */ + def checkedBuilder[A, B](using + sourceSchema: Schema[A], + targetSchema: Schema[B] + ): TrackedMigrationBuilder[A, B, EmptyTuple, EmptyTuple] = + new TrackedMigrationBuilder(new MigrationBuilder(sourceSchema, targetSchema, Vector.empty)) +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationComplete.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationComplete.scala new file mode 100644 index 0000000000..dd4ffd0105 --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationComplete.scala @@ -0,0 +1,43 @@ +package zio.blocks.schema.migration + +/** + * Type class witnessing that a migration is complete. + * + * A migration is complete when: + * - All source fields that don't exist in the target are explicitly handled + * (via dropField or renameField) + * - All target fields that don't exist in the source are explicitly provided + * (via addField or renameField) + * + * Fields with the same name in both source and target are auto-mapped and don't + * need explicit handling. + * + * @tparam A + * The source type + * @tparam B + * The target type + * @tparam SourceHandled + * Tuple of source field name literal types that have been explicitly handled + * @tparam TargetProvided + * Tuple of target field name literal types that have been explicitly provided + */ +sealed trait MigrationComplete[A, B, SourceHandled <: Tuple, TargetProvided <: Tuple] + +object MigrationComplete { + + private val instance: MigrationComplete[Any, Any, EmptyTuple, EmptyTuple] = + new MigrationComplete[Any, Any, EmptyTuple, EmptyTuple] {} + + /** + * Derive a [[MigrationComplete]] instance if the migration is valid. + * + * This macro validates at compile time that all fields are accounted for, + * producing a compile error with helpful hints if any fields are missing. + */ + inline given derived[A, B, SH <: Tuple, TP <: Tuple]: MigrationComplete[A, B, SH, TP] = + ${ MigrationValidationMacros.validateMigration[A, B, SH, TP] } + + /** Unsafe instance that skips validation. Used by `buildPartial`. */ + def unsafePartial[A, B, SH <: Tuple, TP <: Tuple]: MigrationComplete[A, B, SH, TP] = + instance.asInstanceOf[MigrationComplete[A, B, SH, TP]] +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationValidationMacros.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationValidationMacros.scala new file mode 100644 index 0000000000..0ca2a4da05 --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/MigrationValidationMacros.scala @@ -0,0 +1,130 @@ +package zio.blocks.schema.migration + +import scala.quoted._ + +/** + * Compile-time macros for validating schema migrations. + * + * These macros extract field names from case class types and validate that + * migrations are complete — all source fields are handled and all target fields + * are provided. + */ +object MigrationValidationMacros { + + /** + * Validates that a migration from A to B is complete given the tracked + * fields. + * + * Auto-mapping: fields with the same name in both source and target are + * automatically mapped and don't need explicit handling. + */ + def validateMigration[A: Type, B: Type, SH <: Tuple: Type, TP <: Tuple: Type](using + q: Quotes + ): Expr[MigrationComplete[A, B, SH, TP]] = { + import q.reflect._ + + val sourceFields = extractFieldNames[A] + val targetFields = extractFieldNames[B] + val handledFields = extractTupleElements[SH] + val providedFields = extractTupleElements[TP] + + val autoMapped = sourceFields.intersect(targetFields) + val sourceNeedingHandling = sourceFields.diff(autoMapped) + val targetNeedingProviding = targetFields.diff(autoMapped) + + val unhandledSource = sourceNeedingHandling.diff(handledFields) + val unprovidedTarget = targetNeedingProviding.diff(providedFields) + + if (unhandledSource.nonEmpty || unprovidedTarget.nonEmpty) { + val errors = List( + if (unhandledSource.nonEmpty) + Some(s"Unhandled source fields: ${unhandledSource.mkString(", ")}") + else None, + if (unprovidedTarget.nonEmpty) + Some(s"Unprovided target fields: ${unprovidedTarget.mkString(", ")}") + else None + ).flatten.mkString("; ") + + val hints = List( + if (unhandledSource.nonEmpty) + Some(s"Use .dropField or .renameField for: ${unhandledSource.mkString(", ")}") + else None, + if (unprovidedTarget.nonEmpty) + Some(s"Use .addField or .renameField for: ${unprovidedTarget.mkString(", ")}") + else None + ).flatten.mkString("; ") + + report.errorAndAbort( + s"""Migration is incomplete. + | + |$errors + | + |Hints: $hints + | + |Source fields: ${sourceFields.mkString(", ")} + |Target fields: ${targetFields.mkString(", ")} + |Auto-mapped: ${autoMapped.mkString(", ")} + |Handled: ${handledFields.mkString(", ")} + |Provided: ${providedFields.mkString(", ")}""".stripMargin + ) + } + + '{ MigrationComplete.unsafePartial[A, B, SH, TP] } + } + + /** Extract field names from a case class type's primary constructor. */ + private def extractFieldNames[T: Type](using q: Quotes): Set[String] = { + import q.reflect._ + + val tpe = TypeRepr.of[T] + val symbol = tpe.typeSymbol + + if (!symbol.flags.is(Flags.Case)) Set.empty + else + symbol.primaryConstructor.paramSymss.flatten + .filter(_.isValDef) + .map(_.name) + .toSet + } + + /** Extract string literal elements from a Tuple type. */ + private def extractTupleElements[T <: Tuple: Type](using q: Quotes): Set[String] = { + import q.reflect._ + + def extract(tpe: TypeRepr): Set[String] = tpe.dealias match { + case ConstantType(StringConstant(s)) => Set(s) + case AppliedType(tycon, List(head, tail)) if tycon.typeSymbol.name == "*:" => + extract(head) ++ extract(tail) + case AppliedType(tycon, args) if tycon.typeSymbol.name.startsWith("Tuple") => + args.flatMap(extract).toSet + case tpe if tpe =:= TypeRepr.of[EmptyTuple] => Set.empty + case _ => Set.empty + } + + extract(TypeRepr.of[T]) + } + + /** + * Extract the last field name from a selector lambda (e.g. `_.name` → + * "name"). + */ + def extractFieldNameFromSelector[S: Type, A: Type](selector: Expr[S => A])(using q: Quotes): String = { + import q.reflect._ + + def extractLastField(term: Term): String = term match { + case Select(_, fieldName) => fieldName + case Ident(_) => report.errorAndAbort("Selector must access at least one field") + case _ => report.errorAndAbort(s"Cannot extract field name from: ${term.show}") + } + + @scala.annotation.tailrec + def getBody(term: Term): Term = term match { + case Inlined(_, _, inner) => getBody(inner) + case Block(List(DefDef(_, _, _, Some(body))), _) => body + case Lambda(_, body) => body + case _ => report.errorAndAbort(s"Expected lambda, got: ${term.show}") + } + + extractLastField(getBody(selector.asTerm)) + } +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/migration/SelectorMacros.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/SelectorMacros.scala new file mode 100644 index 0000000000..75a193d757 --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/SelectorMacros.scala @@ -0,0 +1,207 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import scala.quoted._ + +/** + * Scala 3 macros for type-safe selector expressions. + * + * These macros convert lambda expressions like `_.fieldName.nested` into + * [[DynamicOptic]] paths at compile time, ensuring type safety. + */ +object SelectorMacros { + + /** + * Convert a selector lambda to a DynamicOptic. + * + * Usage: + * {{{ + * import SelectorMacros.toOptic + * + * val path: DynamicOptic = toOptic[Person, String](_.name) + * val nested: DynamicOptic = toOptic[Person, String](_.address.street) + * }}} + */ + inline def toOptic[A, B](inline selector: A => B): DynamicOptic = + ${ toOpticImpl[A, B]('selector) } + + /** + * Convert a selector lambda to a Selector. + */ + inline def toSelector[A, B](inline selector: A => B): Selector[A, B] = + ${ toSelectorImpl[A, B]('selector) } + + private def toOpticImpl[A: Type, B: Type]( + selector: Expr[A => B] + )(using Quotes): Expr[DynamicOptic] = { + import quotes.reflect._ + buildDynamicOptic(selector.asTerm) + } + + private def toSelectorImpl[A: Type, B: Type]( + selector: Expr[A => B] + )(using Quotes): Expr[Selector[A, B]] = { + val opticExpr = toOpticImpl[A, B](selector) + '{ + val optic = $opticExpr + new Selector[A, B] { + def toOptic: DynamicOptic = optic + } + } + } + + private def hasName(using Quotes)(term: quotes.reflect.Term, name: String): Boolean = { + import quotes.reflect._ + term match { + case Ident(s) => s == name + case Select(_, s) => s == name + case _ => false + } + } + + private def extractTagFromRefinement(using Quotes)(tpe: quotes.reflect.TypeRepr): Option[String] = { + import quotes.reflect._ + + def findTag(t: TypeRepr): Option[String] = t.dealias match { + case Refinement(_, "Tag", TypeBounds(ConstantType(StringConstant(tagName)), ConstantType(StringConstant(_)))) => + Some(tagName) + case Refinement(_, "Tag", ConstantType(StringConstant(tagName))) => + Some(tagName) + case Refinement(inner, _, _) => + findTag(inner) + case _ => None + } + + findTag(tpe) + } + + private def caseNameFromType(using Quotes)(tpe: quotes.reflect.TypeRepr): String = { + val direct = extractTagFromRefinement(tpe) + direct.getOrElse { + val n = tpe.typeSymbol.name + if (n.endsWith("$")) n.stripSuffix("$") else n + } + } + + private def buildDynamicOptic(using Quotes)(term: quotes.reflect.Term): Expr[DynamicOptic] = { + import quotes.reflect._ + + def recurse(t: Term): Expr[DynamicOptic] = t match { + case Inlined(_, _, body) => + recurse(body) + + case Block(Nil, body) => + recurse(body) + + case Block(List(DefDef(_, _, _, Some(body))), _) => + recurse(body) + + case Lambda(_, body) => + recurse(body) + + case Typed(expr, _) => + recurse(expr) + + // parameter reference + case Ident(_) => + '{ DynamicOptic.root } + + // Field selection: _.fieldName + case Select(qualifier, name) if name != "apply" => + name match { + case "get" => + // Optional access doesn't add to the DynamicOptic path. + recurse(qualifier) + case "head" => + '{ ${ recurse(qualifier) }.at(0) } + case "keys" => + '{ ${ recurse(qualifier) }.mapKeys } + case "values" => + '{ ${ recurse(qualifier) }.mapValues } + case fieldName => + '{ ${ recurse(qualifier) }.field(${ Expr(fieldName) }) } + } + + // Indexed access: _.seq(0) + case Apply(Select(qualifier, "apply"), List(index)) if index.tpe.widen.dealias <:< TypeRepr.of[Int] => + '{ ${ recurse(qualifier) }.at(${ index.asExprOf[Int] }) } + + // .each + case Apply(TypeApply(eachTerm, _), List(parent)) if hasName(eachTerm, "each") => + '{ ${ recurse(parent) }.elements } + + // .eachKey + case Apply(TypeApply(keyTerm, _), List(parent)) if hasName(keyTerm, "eachKey") => + '{ ${ recurse(parent) }.mapKeys } + + // .eachValue + case Apply(TypeApply(valueTerm, _), List(parent)) if hasName(valueTerm, "eachValue") => + '{ ${ recurse(parent) }.mapValues } + + // .when[Case] + case TypeApply(Apply(TypeApply(caseTerm, _), List(parent)), List(typeTree)) if hasName(caseTerm, "when") => + val caseName = caseNameFromType(typeTree.tpe) + '{ ${ recurse(parent) }.caseOf(${ Expr(caseName) }) } + + // .wrapped[Wrapped] + case TypeApply(Apply(TypeApply(wrapperTerm, _), List(parent)), List(_)) if hasName(wrapperTerm, "wrapped") => + '{ ${ recurse(parent) }.wrapped } + + // .at(index) + case Apply(Apply(TypeApply(atTerm, _), List(parent)), List(index)) if hasName(atTerm, "at") => + '{ ${ recurse(parent) }.at(${ index.asExprOf[Int] }) } + + // .atIndices(i1, i2, ...) + case Apply(Apply(TypeApply(atIndicesTerm, _), List(parent)), List(Typed(Repeated(indices, _), _))) + if hasName(atIndicesTerm, "atIndices") => + val indicesExprs = indices.map(_.asExprOf[Int]) + '{ ${ recurse(parent) }.atIndices(${ Varargs(indicesExprs) }*) } + + // .atKey(key) + case Apply(Apply(TypeApply(atKeyTerm, _), List(parent)), List(key)) if hasName(atKeyTerm, "atKey") => + key.tpe.widen.dealias.asType match { + case '[k] => + Expr.summon[Schema[k]] match { + case Some(schemaExpr) => + '{ + given Schema[k] = $schemaExpr + ${ recurse(parent) }.atKey(${ key.asExprOf[k] }) + } + case None => + report.errorAndAbort(s"Missing Schema for key type: ${key.tpe.widen.show}") + } + } + + // .atKeys(k1, k2, ...) + case Apply(Apply(TypeApply(atKeysTerm, _), List(parent)), List(Typed(Repeated(keys, _), _))) + if hasName(atKeysTerm, "atKeys") => + keys match { + case Nil => + report.errorAndAbort("`.atKeys` requires at least one key.") + case head :: _ => + head.tpe.widen.dealias.asType match { + case '[k] => + val keyExprs = keys.map(_.asExprOf[k]) + Expr.summon[Schema[k]] match { + case Some(schemaExpr) => + '{ + given Schema[k] = $schemaExpr + ${ recurse(parent) }.atKeys(${ Varargs(keyExprs) }*) + } + case None => + report.errorAndAbort(s"Missing Schema for key type: ${head.tpe.widen.show}") + } + } + } + + case other => + report.errorAndAbort( + s"Unsupported selector expression: ${other.show}. " + + "Expected path elements: ., .when[], .at(), .atIndices(), .atKey(), " + + ".atKeys(), .each, .eachKey, .eachValue, .wrapped[], or optional `.get`." + ) + } + + recurse(term) + } +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/migration/TrackedMigrationBuilder.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/TrackedMigrationBuilder.scala new file mode 100644 index 0000000000..0d0e1c6daf --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/TrackedMigrationBuilder.scala @@ -0,0 +1,187 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ + +/** + * A compile-time-tracked migration builder for Scala 3. + * + * This wraps a [[MigrationBuilder]] and adds type-level field name tracking + * using Tuple types. Each call to `addField`, `dropField`, or `renameField` + * appends the field name to the appropriate tuple type parameter. + * + * At `.build` time, the compiler requires [[MigrationComplete]] evidence, which + * uses a macro to verify that all source fields are handled and all target + * fields are provided. If the migration is incomplete, a compile-time error is + * produced with hints about which fields need attention. + * + * For methods that don't change field names (transformField, mandateField, + * optionalizeField, etc.), the field is assumed auto-mapped by same name. + * + * Usage: + * {{{ + * import MigrationBuilderSyntax._ + * + * Migration.checkedBuilder[PersonV1, PersonV2] + * .addField(_.email, "unknown@example.com") + * .dropField(_.ssn) + * .renameField(_.firstName, _.givenName) + * .build // compile error if incomplete + * }}} + * + * @tparam A + * source type + * @tparam B + * target type + * @tparam SH + * Tuple of source field name literal types that have been explicitly handled + * @tparam TP + * Tuple of target field name literal types that have been explicitly provided + */ +final class TrackedMigrationBuilder[A, B, SH <: Tuple, TP <: Tuple]( + private[migration] val inner: MigrationBuilder[A, B] +) { + + // ==================== Tracked Operations (transparent inline) ==================== + + /** Add a field with a literal default. Tracks the target field name. */ + transparent inline def addField[T]( + inline selector: B => T, + default: T + )(using schema: Schema[T]): TrackedMigrationBuilder[A, B, SH, ?] = + ${ TrackedMigrationBuilderMacros.addFieldImpl[A, B, SH, TP, T]('this, 'selector, 'default, 'schema) } + + /** Add a field with an expression default. Tracks the target field name. */ + transparent inline def addFieldExpr[T]( + inline selector: B => T, + default: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, SH, ?] = + ${ TrackedMigrationBuilderMacros.addFieldExprImpl[A, B, SH, TP, T]('this, 'selector, 'default) } + + /** Drop a field from the source. Tracks the source field name. */ + transparent inline def dropField[T]( + inline selector: A => T + ): TrackedMigrationBuilder[A, B, ?, TP] = + ${ TrackedMigrationBuilderMacros.dropFieldImpl[A, B, SH, TP, T]('this, 'selector) } + + /** Drop a field with a reverse default. Tracks the source field name. */ + transparent inline def dropField[T]( + inline selector: A => T, + defaultForReverse: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, ?, TP] = + ${ TrackedMigrationBuilderMacros.dropFieldWithReverseImpl[A, B, SH, TP, T]('this, 'selector, 'defaultForReverse) } + + /** Rename a field. Tracks both source (handled) and target (provided). */ + transparent inline def renameField[T, U]( + inline from: A => T, + inline to: B => U + ): TrackedMigrationBuilder[A, B, ?, ?] = + ${ TrackedMigrationBuilderMacros.renameFieldImpl[A, B, SH, TP]('this, 'from, 'to) } + + // ==================== Non-tracked Operations (auto-mapped by same name) ==================== + + /** Transform a field value (field name unchanged, auto-mapped). */ + inline def transformField[T]( + inline selector: A => T, + transform: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.transformField(optic, transform)) + } + + /** Transform a field with reverse expression. */ + inline def transformField[T]( + inline selector: A => T, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.transformField(optic, transform, reverseTransform)) + } + + /** Make an optional field mandatory (field name unchanged). */ + inline def mandateField[T]( + inline selector: B => T, + default: T + )(using schema: Schema[T]): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.mandateField(optic, default)(schema)) + } + + /** Make a mandatory field optional (field name unchanged). */ + inline def optionalizeField[T]( + inline selector: A => T + ): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.optionalizeField(optic)) + } + + /** Change field type (field name unchanged). */ + inline def changeFieldType[T]( + inline selector: A => T, + converter: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.changeFieldType(optic, converter)) + } + + /** Rename an enum case. */ + def renameCase(from: String, to: String): TrackedMigrationBuilder[A, B, SH, TP] = + new TrackedMigrationBuilder(inner.renameCase(from, to)) + + /** Transform elements in a collection field. */ + inline def transformElements[T]( + inline selector: A => Seq[T], + transform: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.transformElements(optic, transform)) + } + + /** Transform map keys. */ + inline def transformKeys[K, V]( + inline selector: A => Map[K, V], + transform: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.transformKeys(optic, transform)) + } + + /** Transform map values. */ + inline def transformValues[K, V]( + inline selector: A => Map[K, V], + transform: DynamicSchemaExpr + ): TrackedMigrationBuilder[A, B, SH, TP] = { + val optic = SelectorMacros.toOptic(selector) + new TrackedMigrationBuilder(inner.transformValues(optic, transform)) + } + + // ==================== Build Methods ==================== + + /** + * Build with compile-time field validation AND runtime structural validation. + * + * Requires [[MigrationComplete]] evidence, which the compiler generates via + * macro. If any source fields are unhandled or target fields unprovided, a + * compile error is produced. + * + * Also runs [[MigrationValidator]] at runtime for structural schema checks. + */ + inline def build(using MigrationComplete[A, B, SH, TP]): Migration[A, B] = + inner.build + + /** + * Build with compile-time field validation only (skip runtime validation). + * + * Useful when runtime validation is too restrictive or for testing. + */ + inline def buildChecked(using MigrationComplete[A, B, SH, TP]): Migration[A, B] = + inner.buildPartial + + /** + * Build without any compile-time validation. + * + * Also skips runtime validation. Use for partial migrations. + */ + def buildPartial: Migration[A, B] = + inner.buildPartial +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/migration/TrackedMigrationBuilderMacros.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/TrackedMigrationBuilderMacros.scala new file mode 100644 index 0000000000..b83e9a5c26 --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/migration/TrackedMigrationBuilderMacros.scala @@ -0,0 +1,128 @@ +package zio.blocks.schema.migration + +import scala.quoted._ +import zio.blocks.schema.Schema + +/** + * Macros for [[TrackedMigrationBuilder]] that track field names at the type + * level using Tuple types. + * + * Each method extracts the field name from the selector lambda at compile time, + * then returns a builder with the field name appended to the appropriate tuple + * type parameter. + */ +object TrackedMigrationBuilderMacros { + + def addFieldImpl[A: Type, B: Type, SH <: Tuple: Type, TP <: Tuple: Type, T: Type]( + builder: Expr[TrackedMigrationBuilder[A, B, SH, TP]], + selector: Expr[B => T], + default: Expr[T], + schema: Expr[Schema[T]] + )(using q: Quotes): Expr[TrackedMigrationBuilder[A, B, SH, ?]] = { + import q.reflect._ + + val fieldName = MigrationValidationMacros.extractFieldNameFromSelector(selector) + val fieldNameType = ConstantType(StringConstant(fieldName)) + + fieldNameType.asType match { + case '[fn] => + '{ + val optic = SelectorMacros.toOptic($selector) + new TrackedMigrationBuilder[A, B, SH, Tuple.Concat[TP, fn *: EmptyTuple]]( + $builder.inner.addField(optic, $default)(using $schema) + ) + } + } + } + + def addFieldExprImpl[A: Type, B: Type, SH <: Tuple: Type, TP <: Tuple: Type, T: Type]( + builder: Expr[TrackedMigrationBuilder[A, B, SH, TP]], + selector: Expr[B => T], + default: Expr[DynamicSchemaExpr] + )(using q: Quotes): Expr[TrackedMigrationBuilder[A, B, SH, ?]] = { + import q.reflect._ + + val fieldName = MigrationValidationMacros.extractFieldNameFromSelector(selector) + val fieldNameType = ConstantType(StringConstant(fieldName)) + + fieldNameType.asType match { + case '[fn] => + '{ + val optic = SelectorMacros.toOptic($selector) + new TrackedMigrationBuilder[A, B, SH, Tuple.Concat[TP, fn *: EmptyTuple]]( + $builder.inner.addField(optic, $default) + ) + } + } + } + + def dropFieldImpl[A: Type, B: Type, SH <: Tuple: Type, TP <: Tuple: Type, T: Type]( + builder: Expr[TrackedMigrationBuilder[A, B, SH, TP]], + selector: Expr[A => T] + )(using q: Quotes): Expr[TrackedMigrationBuilder[A, B, ?, TP]] = { + import q.reflect._ + + val fieldName = MigrationValidationMacros.extractFieldNameFromSelector(selector) + val fieldNameType = ConstantType(StringConstant(fieldName)) + + fieldNameType.asType match { + case '[fn] => + '{ + val optic = SelectorMacros.toOptic($selector) + new TrackedMigrationBuilder[A, B, Tuple.Concat[SH, fn *: EmptyTuple], TP]( + $builder.inner.dropField(optic) + ) + } + } + } + + def dropFieldWithReverseImpl[A: Type, B: Type, SH <: Tuple: Type, TP <: Tuple: Type, T: Type]( + builder: Expr[TrackedMigrationBuilder[A, B, SH, TP]], + selector: Expr[A => T], + defaultForReverse: Expr[DynamicSchemaExpr] + )(using q: Quotes): Expr[TrackedMigrationBuilder[A, B, ?, TP]] = { + import q.reflect._ + + val fieldName = MigrationValidationMacros.extractFieldNameFromSelector(selector) + val fieldNameType = ConstantType(StringConstant(fieldName)) + + fieldNameType.asType match { + case '[fn] => + '{ + val optic = SelectorMacros.toOptic($selector) + new TrackedMigrationBuilder[A, B, Tuple.Concat[SH, fn *: EmptyTuple], TP]( + $builder.inner.dropField(optic, $defaultForReverse) + ) + } + } + } + + def renameFieldImpl[A: Type, B: Type, SH <: Tuple: Type, TP <: Tuple: Type]( + builder: Expr[TrackedMigrationBuilder[A, B, SH, TP]], + from: Expr[A => Any], + to: Expr[B => Any] + )(using q: Quotes): Expr[TrackedMigrationBuilder[A, B, ?, ?]] = { + import q.reflect._ + + val fromFieldName = MigrationValidationMacros.extractFieldNameFromSelector(from) + val toFieldName = MigrationValidationMacros.extractFieldNameFromSelector(to) + val fromFieldNameType = ConstantType(StringConstant(fromFieldName)) + val toFieldNameType = ConstantType(StringConstant(toFieldName)) + + (fromFieldNameType.asType, toFieldNameType.asType) match { + case ('[fnFrom], '[fnTo]) => + '{ + val fromOptic = SelectorMacros.toOptic($from) + val toOptic = SelectorMacros.toOptic($to) + new TrackedMigrationBuilder[ + A, + B, + Tuple.Concat[SH, fnFrom *: EmptyTuple], + Tuple.Concat[TP, fnTo *: EmptyTuple] + ]( + $builder.inner.renameField(fromOptic, toOptic) + ) + } + } + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/binding/Constructor.scala b/schema/shared/src/main/scala/zio/blocks/schema/binding/Constructor.scala index 70c41446d5..1ff625500f 100644 --- a/schema/shared/src/main/scala/zio/blocks/schema/binding/Constructor.scala +++ b/schema/shared/src/main/scala/zio/blocks/schema/binding/Constructor.scala @@ -24,3 +24,10 @@ class ConstantConstructor[A](constant: A) extends Constructor[A] { def construct(in: Registers, offset: RegisterOffset): A = constant } + +/** + * A constructor for structural types that exist only at compile-time. + * Structural types cannot be instantiated at runtime; they work only with + * DynamicValue. + */ +abstract class StructuralConstructor[+A] extends Constructor[A] diff --git a/schema/shared/src/main/scala/zio/blocks/schema/binding/Deconstructor.scala b/schema/shared/src/main/scala/zio/blocks/schema/binding/Deconstructor.scala index f9ab71f699..4b3c884bd0 100644 --- a/schema/shared/src/main/scala/zio/blocks/schema/binding/Deconstructor.scala +++ b/schema/shared/src/main/scala/zio/blocks/schema/binding/Deconstructor.scala @@ -24,3 +24,10 @@ class ConstantDeconstructor[A] extends Deconstructor[A] { override def deconstruct(out: Registers, offset: RegisterOffset, in: A): Unit = {} } + +/** + * A deconstructor for structural types that exist only at compile-time. + * Structural types cannot be deconstructed at runtime; they work only with + * DynamicValue. + */ +abstract class StructuralDeconstructor[-A] extends Deconstructor[A] diff --git a/schema/shared/src/main/scala/zio/blocks/schema/binding/Matcher.scala b/schema/shared/src/main/scala/zio/blocks/schema/binding/Matcher.scala index e7dded3681..8892d3bf5b 100644 --- a/schema/shared/src/main/scala/zio/blocks/schema/binding/Matcher.scala +++ b/schema/shared/src/main/scala/zio/blocks/schema/binding/Matcher.scala @@ -11,3 +11,11 @@ trait Matcher[+A] { */ def downcastOrNull(any: Any): A } + +/** + * A matcher for structural types that exist only at compile-time. Structural + * types cannot be matched at runtime; they work only with DynamicValue. + */ +class StructuralMatcher[+A] extends Matcher[A] { + def downcastOrNull(any: Any): A = null.asInstanceOf[A] +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/json/JsonBinaryCodecDeriver.scala b/schema/shared/src/main/scala/zio/blocks/schema/json/JsonBinaryCodecDeriver.scala index e86dcb26f4..cf18c5ccd1 100644 --- a/schema/shared/src/main/scala/zio/blocks/schema/json/JsonBinaryCodecDeriver.scala +++ b/schema/shared/src/main/scala/zio/blocks/schema/json/JsonBinaryCodecDeriver.scala @@ -1929,11 +1929,14 @@ class JsonBinaryCodecDeriver private[json] ( override def encodeValue(x: A, out: JsonWriter): Unit = { out.writeObjectStart() if (discriminatorField ne null) discriminatorField.writeKeyAndValue(out) + val len = fieldInfos.length + if (len > 0 && usedRegisters == 0) { + usedRegisters = fieldInfos(len - 1).usedRegisters // delayed initialization for recursive records + } val top = out.push(usedRegisters) try { val regs = out.registers deconstructor.deconstruct(regs, top, x) - val len = fieldInfos.length var idx = 0 while (idx < len) { val fieldInfo = fieldInfos(idx) diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicMigration.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicMigration.scala new file mode 100644 index 0000000000..a481292b68 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicMigration.scala @@ -0,0 +1,774 @@ +package zio.blocks.schema.migration + +import zio.blocks.chunk.Chunk +import zio.blocks.schema._ + +/** + * An untyped, pure data migration that operates on [[DynamicValue]]. + * + * [[DynamicMigration]] is fully serializable and contains: + * - No user functions + * - No closures + * - No reflection + * - No runtime code generation + * + * This enables migrations to be: + * - Stored in registries + * - Applied dynamically + * - Inspected and transformed + * - Used to generate DDL, SQL, offline data transforms, etc. + */ +final case class DynamicMigration(actions: Vector[MigrationAction]) { + + /** + * Apply this migration to transform a [[DynamicValue]]. + */ + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = + actions.foldLeft[Either[MigrationError, DynamicValue]](Right(value)) { + case (Right(v), action) => DynamicMigration.applyAction(v, action) + case (left, _) => left + } + + /** + * Compose this migration with another, applying actions sequentially. + */ + def ++(that: DynamicMigration): DynamicMigration = + new DynamicMigration(actions ++ that.actions) + + /** + * Alias for `++`. + */ + def andThen(that: DynamicMigration): DynamicMigration = this ++ that + + /** + * Get the structural reverse of this migration. + * + * The reversed migration applies the reverse of each action in reverse order. + */ + def reverse: DynamicMigration = + new DynamicMigration(actions.map(_.reverse).reverse) + + /** + * Check if this migration is empty (no actions). + */ + def isEmpty: Boolean = actions.isEmpty + + /** + * Check if this migration is non-empty. + */ + def nonEmpty: Boolean = actions.nonEmpty +} + +object DynamicMigration { + + private def wrapExprError(at: DynamicOptic, action: String)(error: MigrationError): MigrationError = + MigrationError.single(MigrationError.ActionFailed(at, action, error.message)) + + /** + * An empty migration that performs no changes. + */ + val empty: DynamicMigration = new DynamicMigration(Vector.empty) + + /** + * Create a migration from a single action. + */ + def apply(action: MigrationAction): DynamicMigration = + new DynamicMigration(Vector(action)) + + /** + * Apply a single action to a DynamicValue. + */ + private[migration] def applyAction( + value: DynamicValue, + action: MigrationAction + ): Either[MigrationError, DynamicValue] = action match { + case MigrationAction.Identity => Right(value) + + case MigrationAction.AddField(at, name, default) => + modifyAtPath(value, at) { + case record @ DynamicValue.Record(fields) => + if (fields.exists(_._1 == name)) { + Left(MigrationError.single(MigrationError.FieldAlreadyExists(at.field(name), name))) + } else { + default.eval(record).left.map(wrapExprError(at.field(name), "AddField")).map { defaultValue => + DynamicValue.Record(fields :+ (name -> defaultValue)) + } + } + case other => + Left(MigrationError.single(MigrationError.NotARecord(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.DropField(at, name, _) => + modifyAtPath(value, at) { + case DynamicValue.Record(fields) => + if (!fields.exists(_._1 == name)) { + Left(MigrationError.single(MigrationError.FieldNotFound(at.field(name), name))) + } else { + Right(DynamicValue.Record(fields.filterNot(_._1 == name))) + } + case other => + Left(MigrationError.single(MigrationError.NotARecord(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.RenameField(at, from, to) => + modifyAtPath(value, at) { + case DynamicValue.Record(fields) => + if (!fields.exists(_._1 == from)) { + Left(MigrationError.single(MigrationError.FieldNotFound(at.field(from), from))) + } else if (fields.exists(_._1 == to)) { + Left(MigrationError.single(MigrationError.FieldAlreadyExists(at.field(to), to))) + } else { + Right(DynamicValue.Record(fields.map { + case (n, v) if n == from => (to, v) + case other => other + })) + } + case other => + Left(MigrationError.single(MigrationError.NotARecord(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.TransformValue(at, transform, _) => + modifyAtPathWithParentContext(value, at) { (_, targetValue) => + transform.eval(targetValue).left.map(wrapExprError(at, "TransformValue")) + } + + case MigrationAction.Mandate(at, default) => + modifyAtPathWithParentContext(value, at) { (context, targetValue) => + targetValue match { + case DynamicValue.Variant("None", _) => + default.eval(context).left.map(wrapExprError(at, "Mandate")) + case DynamicValue.Variant("Some", inner) => + Right(inner) + case other => + // Already a non-optional value, return as-is + Right(other) + } + } + + case MigrationAction.Optionalize(at, _) => + modifyAtPath(value, at) { + case already @ DynamicValue.Variant("Some", _) => Right(already) + case DynamicValue.Variant("None", _) => Right(DynamicValue.Variant("None", DynamicValue.Record(Chunk.empty))) + case targetValue => Right(DynamicValue.Variant("Some", targetValue)) + } + + case MigrationAction.ChangeType(at, converter, _) => + modifyAtPathWithParentContext(value, at) { (_, targetValue) => + converter.eval(targetValue).left.map(wrapExprError(at, "ChangeType")) + } + + case MigrationAction.Join(at, sourcePaths, combiner, _) => + // For join, we need special handling - gather values from source paths and combine + val parentPath = DynamicOptic(at.nodes.dropRight(1)) + modifyAtPath(value, parentPath) { + case record @ DynamicValue.Record(fields) => + // Extract values from source paths + val sourceResults = sourcePaths.map { path => + DynamicSchemaExpr.navigateDynamicValue(record, path).toRight(path) + } + val missingPaths = sourceResults.collect { case Left(path) => path.toString } + if (missingPaths.nonEmpty) { + Left( + MigrationError.single( + MigrationError.PathNavigationFailed(at, s"Source paths not found: ${missingPaths.mkString(", ")}") + ) + ) + } else { + val sourceValues = sourceResults.collect { case Right(v) => v } + // Create a temporary record with the source values for the combiner + val tempRecord = DynamicValue.Record( + Chunk.from( + sourceValues.zipWithIndex.map { case (v, i) => (s"_$i", v) } + ) + ) + combiner.eval(tempRecord).left.map(wrapExprError(at, "Join")).flatMap { combined => + // Remove source fields and add combined field + val sourceFieldNames = sourcePaths + .flatMap(_.nodes.lastOption) + .collect { case DynamicOptic.Node.Field(name) => + name + } + if (sourceFieldNames.size != sourcePaths.size) { + Left( + MigrationError.single( + MigrationError.ActionFailed( + at, + "Join", + s"All source paths must end with a Field node, but ${sourcePaths.size - sourceFieldNames.size} do not" + ) + ) + ) + } else { + val newFields = fields.filterNot { case (name, _) => sourceFieldNames.toSet.contains(name) } + val targetFieldName = at.nodes.lastOption match { + case Some(DynamicOptic.Node.Field(name)) => name + case _ => "combined" + } + Right(DynamicValue.Record(newFields :+ (targetFieldName -> combined))) + } + } + } + case other => + Left(MigrationError.single(MigrationError.NotARecord(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.Split(at, targetPaths, splitter, _) => + val parentPath = DynamicOptic(at.nodes.dropRight(1)) + modifyAtPath(value, parentPath) { + case DynamicValue.Record(fields) => + at.nodes.lastOption match { + case Some(DynamicOptic.Node.Field(sourceFieldName)) => + fields.find(_._1 == sourceFieldName) match { + case Some((_, sourceValue)) => + splitter.eval(sourceValue).left.map(wrapExprError(at, "Split")).flatMap { + case DynamicValue.Sequence(splitValues) => + if (splitValues.length != targetPaths.length) { + Left( + MigrationError.single( + MigrationError.ActionFailed( + at, + "Split", + s"Expected ${targetPaths.length} values but got ${splitValues.length}" + ) + ) + ) + } else { + val targetFieldNames = targetPaths.flatMap(_.nodes.lastOption).collect { + case DynamicOptic.Node.Field(name) => name + } + if (targetFieldNames.length != targetPaths.length) { + Left( + MigrationError.single( + MigrationError.ActionFailed( + at, + "Split", + s"All target paths must end with a Field node, but ${targetPaths.length - targetFieldNames.length} do not" + ) + ) + ) + } else { + val newFields = fields.filterNot(_._1 == sourceFieldName) ++ + targetFieldNames.zip(splitValues) + Right(DynamicValue.Record(newFields)) + } + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(at, getDynamicValueTypeName(other)))) + } + case None => + Left(MigrationError.single(MigrationError.FieldNotFound(at, sourceFieldName))) + } + case _ => + Left(MigrationError.single(MigrationError.PathNavigationFailed(at, "Invalid path"))) + } + case other => + Left(MigrationError.single(MigrationError.NotARecord(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.RenameCase(at, from, to) => + modifyAtPath(value, at) { + case DynamicValue.Variant(caseName, inner) if caseName == from => + Right(DynamicValue.Variant(to, inner)) + case variant: DynamicValue.Variant => + // If the case name doesn't match, pass through unchanged + Right(variant) + case other => + Left(MigrationError.single(MigrationError.NotAVariant(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.TransformCase(at, caseName, nestedActions) => + modifyAtPath(value, at) { + case DynamicValue.Variant(cn, inner) if cn == caseName => + val nestedMigration = new DynamicMigration(nestedActions) + nestedMigration.apply(inner).map(transformed => DynamicValue.Variant(cn, transformed)) + case variant: DynamicValue.Variant => + // If the case name doesn't match, pass through unchanged + Right(variant) + case other => + Left(MigrationError.single(MigrationError.NotAVariant(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.TransformElements(at, transform, _) => + modifyAtPath(value, at) { + case DynamicValue.Sequence(elements) => + val transformedElements = + elements.map(e => transform.eval(e).left.map(wrapExprError(at, "TransformElements"))) + val errors = transformedElements.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Sequence(transformedElements.collect { case Right(v) => v })) + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.TransformKeys(at, transform, _) => + modifyAtPath(value, at) { + case DynamicValue.Map(entries) => + val transformedEntries = entries.map { case (k, v) => + transform.eval(k).left.map(wrapExprError(at, "TransformKeys")).map(tk => (tk, v)) + } + val errors = transformedEntries.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Map(transformedEntries.collect { case Right(entry) => entry })) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(at, getDynamicValueTypeName(other)))) + } + + case MigrationAction.TransformValues(at, transform, _) => + modifyAtPath(value, at) { + case DynamicValue.Map(entries) => + val transformedEntries = entries.map { case (k, v) => + transform.eval(v).left.map(wrapExprError(at, "TransformValues")).map(tv => (k, tv)) + } + val errors = transformedEntries.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Map(transformedEntries.collect { case Right(entry) => entry })) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(at, getDynamicValueTypeName(other)))) + } + } + + /** + * Navigate to a path in the DynamicValue and apply a modification function. + */ + private def modifyAtPath( + value: DynamicValue, + path: DynamicOptic + )(modify: DynamicValue => Either[MigrationError, DynamicValue]): Either[MigrationError, DynamicValue] = + if (path.nodes.isEmpty) { + modify(value) + } else { + modifyAtPathRec(value, path, 0)(modify) + } + + private def modifyAtPathWithParentContext( + value: DynamicValue, + path: DynamicOptic + )( + modify: (DynamicValue, DynamicValue) => Either[MigrationError, DynamicValue] + ): Either[MigrationError, DynamicValue] = + if (path.nodes.isEmpty) { + modify(value, value) + } else { + modifyAtPathWithParentContextRec(value, path, 0)(modify) + } + + private def modifyAtPathWithParentContextRec( + value: DynamicValue, + path: DynamicOptic, + idx: Int + )( + modify: (DynamicValue, DynamicValue) => Either[MigrationError, DynamicValue] + ): Either[MigrationError, DynamicValue] = { + if (idx >= path.nodes.length) { + // This should be unreachable for non-empty paths. + modify(value, value) + } else { + val isLast = idx == path.nodes.length - 1 + path.nodes(idx) match { + case DynamicOptic.Node.Field(name) => + value match { + case DynamicValue.Record(fields) => + val fieldIdx = fields.indexWhere(_._1 == name) + if (fieldIdx < 0) { + Left(MigrationError.single(MigrationError.FieldNotFound(path, name))) + } else if (isLast) { + val targetValue = fields(fieldIdx)._2 + modify(value, targetValue).map { newValue => + DynamicValue.Record(fields.updated(fieldIdx, (name, newValue))) + } + } else { + modifyAtPathWithParentContextRec(fields(fieldIdx)._2, path, idx + 1)(modify).map { newValue => + DynamicValue.Record(fields.updated(fieldIdx, (name, newValue))) + } + } + case other => + Left(MigrationError.single(MigrationError.NotARecord(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.Case(caseName) => + value match { + case DynamicValue.Variant(cn, inner) if cn == caseName => + if (isLast) { + modify(value, inner).map { newInner => + DynamicValue.Variant(cn, newInner) + } + } else { + modifyAtPathWithParentContextRec(inner, path, idx + 1)(modify).map { newInner => + DynamicValue.Variant(cn, newInner) + } + } + case variant: DynamicValue.Variant => + // Case doesn't match, return unchanged + Right(variant) + case other => + Left(MigrationError.single(MigrationError.NotAVariant(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.AtIndex(i) => + value match { + case DynamicValue.Sequence(elements) => + if (i < 0 || i >= elements.length) { + Left(MigrationError.single(MigrationError.IndexOutOfBounds(path, i, elements.length))) + } else if (isLast) { + val targetValue = elements(i) + modify(value, targetValue).map { newValue => + DynamicValue.Sequence(elements.updated(i, newValue)) + } + } else { + modifyAtPathWithParentContextRec(elements(i), path, idx + 1)(modify).map { newValue => + DynamicValue.Sequence(elements.updated(i, newValue)) + } + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.AtMapKey(key) => + value match { + case DynamicValue.Map(entries) => + val entryIdx = entries.indexWhere(_._1 == key) + if (entryIdx < 0) { + Left(MigrationError.single(MigrationError.KeyNotFound(path, key.toString))) + } else if (isLast) { + val targetValue = entries(entryIdx)._2 + modify(value, targetValue).map { newValue => + DynamicValue.Map(entries.updated(entryIdx, (key, newValue))) + } + } else { + modifyAtPathWithParentContextRec(entries(entryIdx)._2, path, idx + 1)(modify).map { newValue => + DynamicValue.Map(entries.updated(entryIdx, (key, newValue))) + } + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.AtIndices(indices) => + value match { + case DynamicValue.Sequence(elements) => + val unique = indices.distinct.sorted + val results = unique.map { i => + if (i < 0 || i >= elements.length) { + Left(MigrationError.single(MigrationError.IndexOutOfBounds(path, i, elements.length))) + } else if (isLast) { + modify(value, elements(i)).map(v => (i, v)) + } else { + modifyAtPathWithParentContextRec(elements(i), path, idx + 1)(modify).map(v => (i, v)) + } + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + val updates = results.collect { case Right(pair) => pair }.toMap + Right( + DynamicValue.Sequence( + elements.zipWithIndex.map { case (v, i) => updates.getOrElse(i, v) } + ) + ) + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.AtMapKeys(keys) => + value match { + case DynamicValue.Map(entries) => + val uniqueKeys = keys.distinct + val results = uniqueKeys.map { key => + val entryIdx = entries.indexWhere(_._1 == key) + if (entryIdx < 0) { + Left(MigrationError.single(MigrationError.KeyNotFound(path, key.toString))) + } else if (isLast) { + modify(value, entries(entryIdx)._2).map(v => (entryIdx, key, v)) + } else { + modifyAtPathWithParentContextRec(entries(entryIdx)._2, path, idx + 1)(modify).map(v => + (entryIdx, key, v) + ) + } + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + val updates = results.collect { case Right((idx, k, v)) => (idx, (k, v)) }.toMap + val newEntries = + entries.zipWithIndex.map { case (entry, i) => updates.getOrElse(i, entry) } + Right(DynamicValue.Map(newEntries)) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.Elements => + value match { + case DynamicValue.Sequence(elements) => + val results = + elements.map { e => + if (isLast) modify(value, e) else modifyAtPathWithParentContextRec(e, path, idx + 1)(modify) + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Sequence(results.collect { case Right(v) => v })) + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.MapKeys => + value match { + case DynamicValue.Map(entries) => + val results = entries.map { case (k, v) => + if (isLast) modify(value, k).map(nk => (nk, v)) + else modifyAtPathWithParentContextRec(k, path, idx + 1)(modify).map(nk => (nk, v)) + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Map(results.collect { case Right(entry) => entry })) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.MapValues => + value match { + case DynamicValue.Map(entries) => + val results = entries.map { case (k, v) => + if (isLast) modify(value, v).map(nv => (k, nv)) + else modifyAtPathWithParentContextRec(v, path, idx + 1)(modify).map(nv => (k, nv)) + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Map(results.collect { case Right(entry) => entry })) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.Wrapped => + value match { + case DynamicValue.Record(fields) if fields.length == 1 => + if (isLast) { + modify(value, fields.head._2).map { newValue => + DynamicValue.Record(fields.head._1 -> newValue) + } + } else { + modifyAtPathWithParentContextRec(fields.head._2, path, idx + 1)(modify).map { newValue => + DynamicValue.Record(fields.head._1 -> newValue) + } + } + case other => + Left( + MigrationError.single( + MigrationError.PathNavigationFailed(path, s"Cannot unwrap ${getDynamicValueTypeName(other)}") + ) + ) + } + } + } + } + + private def modifyAtPathRec( + value: DynamicValue, + path: DynamicOptic, + idx: Int + )(modify: DynamicValue => Either[MigrationError, DynamicValue]): Either[MigrationError, DynamicValue] = { + if (idx >= path.nodes.length) { + modify(value) + } else { + path.nodes(idx) match { + case DynamicOptic.Node.Field(name) => + value match { + case DynamicValue.Record(fields) => + val fieldIdx = fields.indexWhere(_._1 == name) + if (fieldIdx < 0) { + Left(MigrationError.single(MigrationError.FieldNotFound(path, name))) + } else { + modifyAtPathRec(fields(fieldIdx)._2, path, idx + 1)(modify).map { newValue => + DynamicValue.Record(fields.updated(fieldIdx, (name, newValue))) + } + } + case other => + Left(MigrationError.single(MigrationError.NotARecord(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.Case(caseName) => + value match { + case DynamicValue.Variant(cn, inner) if cn == caseName => + modifyAtPathRec(inner, path, idx + 1)(modify).map { newInner => + DynamicValue.Variant(cn, newInner) + } + case variant: DynamicValue.Variant => + // Case doesn't match, return unchanged + Right(variant) + case other => + Left(MigrationError.single(MigrationError.NotAVariant(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.AtIndex(i) => + value match { + case DynamicValue.Sequence(elements) => + if (i < 0 || i >= elements.length) { + Left(MigrationError.single(MigrationError.IndexOutOfBounds(path, i, elements.length))) + } else { + modifyAtPathRec(elements(i), path, idx + 1)(modify).map { newElement => + DynamicValue.Sequence(elements.updated(i, newElement)) + } + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.AtMapKey(key) => + value match { + case DynamicValue.Map(entries) => + val entryIdx = entries.indexWhere(_._1 == key) + if (entryIdx < 0) { + Left(MigrationError.single(MigrationError.KeyNotFound(path, key.toString))) + } else { + modifyAtPathRec(entries(entryIdx)._2, path, idx + 1)(modify).map { newValue => + DynamicValue.Map(entries.updated(entryIdx, (key, newValue))) + } + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.Elements => + value match { + case DynamicValue.Sequence(elements) => + val results = elements.map(e => modifyAtPathRec(e, path, idx + 1)(modify)) + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Sequence(results.collect { case Right(v) => v })) + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.MapKeys => + value match { + case DynamicValue.Map(entries) => + val results = entries.map { case (k, v) => + modifyAtPathRec(k, path, idx + 1)(modify).map(nk => (nk, v)) + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Map(results.collect { case Right(entry) => entry })) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.MapValues => + value match { + case DynamicValue.Map(entries) => + val results = entries.map { case (k, v) => + modifyAtPathRec(v, path, idx + 1)(modify).map(nv => (k, nv)) + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + Right(DynamicValue.Map(results.collect { case Right(entry) => entry })) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.Wrapped => + value match { + case DynamicValue.Record(fields) if fields.length == 1 => + modifyAtPathRec(fields.head._2, path, idx + 1)(modify).map { newValue => + DynamicValue.Record(fields.head._1 -> newValue) + } + case other => + Left( + MigrationError.single( + MigrationError.PathNavigationFailed(path, s"Cannot unwrap ${getDynamicValueTypeName(other)}") + ) + ) + } + + case DynamicOptic.Node.AtIndices(indices) => + value match { + case DynamicValue.Sequence(elements) => + val unique = indices.distinct.sorted + val results = unique.map { i => + if (i < 0 || i >= elements.length) { + Left(MigrationError.single(MigrationError.IndexOutOfBounds(path, i, elements.length))) + } else { + modifyAtPathRec(elements(i), path, idx + 1)(modify).map(v => (i, v)) + } + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + val updates = results.collect { case Right(pair) => pair }.toMap + Right( + DynamicValue.Sequence( + elements.zipWithIndex.map { case (v, i) => updates.getOrElse(i, v) } + ) + ) + } + case other => + Left(MigrationError.single(MigrationError.NotASequence(path, getDynamicValueTypeName(other)))) + } + + case DynamicOptic.Node.AtMapKeys(keys) => + value match { + case DynamicValue.Map(entries) => + val uniqueKeys = keys.distinct + val results = uniqueKeys.map { key => + val entryIdx = entries.indexWhere(_._1 == key) + if (entryIdx < 0) { + Left(MigrationError.single(MigrationError.KeyNotFound(path, key.toString))) + } else { + modifyAtPathRec(entries(entryIdx)._2, path, idx + 1)(modify).map(v => (entryIdx, key, v)) + } + } + val errors = results.collect { case Left(e) => e } + if (errors.nonEmpty) { + Left(errors.reduce(_ ++ _)) + } else { + val updates = results.collect { case Right((idx, k, v)) => (idx, (k, v)) }.toMap + val newEntries = + entries.zipWithIndex.map { case (entry, i) => updates.getOrElse(i, entry) } + Right(DynamicValue.Map(newEntries)) + } + case other => + Left(MigrationError.single(MigrationError.NotAMap(path, getDynamicValueTypeName(other)))) + } + } + } + } + + private def getDynamicValueTypeName(dv: DynamicValue): String = dv match { + case _: DynamicValue.Primitive => "Primitive" + case _: DynamicValue.Record => "Record" + case _: DynamicValue.Variant => "Variant" + case _: DynamicValue.Sequence => "Sequence" + case _: DynamicValue.Map => "Map" + case DynamicValue.Null => "Null" + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicSchemaExpr.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicSchemaExpr.scala new file mode 100644 index 0000000000..a10d71fd21 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicSchemaExpr.scala @@ -0,0 +1,610 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ + +/** + * A serializable expression that operates on [[DynamicValue]] values. + * + * Unlike [[SchemaExpr]], this is fully serializable and contains no runtime + * type references, functions, or closures. It supports primitive-only + * operations suitable for schema migrations. + */ +sealed trait DynamicSchemaExpr { self => + + /** + * Evaluate this expression on the given input value. + */ + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] + + /** + * Compose with another expression using logical AND. + */ + final def &&(that: DynamicSchemaExpr): DynamicSchemaExpr = + DynamicSchemaExpr.Logical(self, that, DynamicSchemaExpr.LogicalOperator.And) + + /** + * Compose with another expression using logical OR. + */ + final def ||(that: DynamicSchemaExpr): DynamicSchemaExpr = + DynamicSchemaExpr.Logical(self, that, DynamicSchemaExpr.LogicalOperator.Or) +} + +object DynamicSchemaExpr { + + /** + * Convert a typed [[SchemaExpr]] into a fully serializable + * [[DynamicSchemaExpr]]. + * + * Notes: + * - Only the subset of SchemaExpr operations supported by DynamicSchemaExpr + * can be converted. + * - [[SchemaExpr.StringRegexMatch]] is currently not supported. + */ + def fromSchemaExpr[A, B](expr: SchemaExpr[A, B]): Either[String, DynamicSchemaExpr] = expr match { + // Scala 2 needs the existential type captured via the case class instance + // to keep `value` and `schema` tied to the same type parameter. + case lit: SchemaExpr.Literal[_, _] => + Right(DynamicSchemaExpr.Literal(lit.schema.toDynamicValue(lit.value))) + + case SchemaExpr.Optic(optic) => + Right(DynamicSchemaExpr.Path(optic.toDynamic)) + + case SchemaExpr.Not(inner) => + fromSchemaExpr(inner).map(DynamicSchemaExpr.Not(_)) + + case SchemaExpr.Logical(left, right, op) => + for { + l <- fromSchemaExpr(left) + r <- fromSchemaExpr(right) + } yield { + val dynOp = op match { + case SchemaExpr.LogicalOperator.And => DynamicSchemaExpr.LogicalOperator.And + case SchemaExpr.LogicalOperator.Or => DynamicSchemaExpr.LogicalOperator.Or + } + DynamicSchemaExpr.Logical(l, r, dynOp) + } + + case SchemaExpr.Relational(left, right, op) => + for { + l <- fromSchemaExpr(left) + r <- fromSchemaExpr(right) + } yield { + val dynOp = op match { + case SchemaExpr.RelationalOperator.LessThan => DynamicSchemaExpr.RelationalOperator.LessThan + case SchemaExpr.RelationalOperator.LessThanOrEqual => DynamicSchemaExpr.RelationalOperator.LessThanOrEqual + case SchemaExpr.RelationalOperator.GreaterThan => DynamicSchemaExpr.RelationalOperator.GreaterThan + case SchemaExpr.RelationalOperator.GreaterThanOrEqual => + DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual + case SchemaExpr.RelationalOperator.Equal => DynamicSchemaExpr.RelationalOperator.Equal + case SchemaExpr.RelationalOperator.NotEqual => DynamicSchemaExpr.RelationalOperator.NotEqual + } + DynamicSchemaExpr.Relational(l, r, dynOp) + } + + case SchemaExpr.Arithmetic(left, right, op, _) => + for { + l <- fromSchemaExpr(left) + r <- fromSchemaExpr(right) + } yield { + val dynOp = op match { + case SchemaExpr.ArithmeticOperator.Add => DynamicSchemaExpr.ArithmeticOperator.Add + case SchemaExpr.ArithmeticOperator.Subtract => DynamicSchemaExpr.ArithmeticOperator.Subtract + case SchemaExpr.ArithmeticOperator.Multiply => DynamicSchemaExpr.ArithmeticOperator.Multiply + } + DynamicSchemaExpr.Arithmetic(l, r, dynOp) + } + + case SchemaExpr.StringConcat(left, right) => + for { + l <- fromSchemaExpr(left) + r <- fromSchemaExpr(right) + } yield DynamicSchemaExpr.StringConcat(l, r) + + case _: SchemaExpr.StringRegexMatch[_] => + Left("SchemaExpr.StringRegexMatch is not supported by DynamicSchemaExpr") + + case SchemaExpr.StringLength(inner) => + fromSchemaExpr(inner).map(DynamicSchemaExpr.StringLength(_)) + } + + /** + * A literal constant value. + */ + final case class Literal(value: DynamicValue) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = Right(value) + } + + /** + * Extract a value at a path from the input using [[DynamicOptic]]. + */ + final case class Path(optic: DynamicOptic) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + navigateDynamicValue(input, optic).toRight( + MigrationError.single(MigrationError.PathNavigationFailed(optic, "Path does not exist in value")) + ) + } + + /** + * Use the default value from the schema for the target field. This is a + * sentinel value that must be resolved at build time. + */ + case object DefaultValue extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + Left(MigrationError.single(MigrationError.DefaultValueMissing(DynamicOptic.root, "default"))) + } + + /** + * Resolved default value with the actual DynamicValue. + */ + final case class ResolvedDefault(value: DynamicValue) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = Right(value) + } + + /** + * Logical negation. + */ + final case class Not(expr: DynamicSchemaExpr) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + expr.eval(input).flatMap { + case DynamicValue.Primitive(PrimitiveValue.Boolean(b)) => + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(!b))) + case other => + Left( + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + getDynamicValueTypeName(other), + "Boolean", + "Expected a boolean for NOT operation" + ) + ) + ) + } + } + + /** + * Logical operators. + */ + sealed trait LogicalOperator + object LogicalOperator { + case object And extends LogicalOperator + case object Or extends LogicalOperator + } + + final case class Logical( + left: DynamicSchemaExpr, + right: DynamicSchemaExpr, + operator: LogicalOperator + ) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + for { + l <- left.eval(input) + r <- right.eval(input) + result <- (l, r) match { + case ( + DynamicValue.Primitive(PrimitiveValue.Boolean(lb)), + DynamicValue.Primitive(PrimitiveValue.Boolean(rb)) + ) => + val res = operator match { + case LogicalOperator.And => lb && rb + case LogicalOperator.Or => lb || rb + } + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(res))) + case _ => + Left( + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + "non-boolean", + "Boolean", + "Expected booleans for logical operation" + ) + ) + ) + } + } yield result + } + + /** + * Relational operators. + */ + sealed trait RelationalOperator + object RelationalOperator { + case object LessThan extends RelationalOperator + case object LessThanOrEqual extends RelationalOperator + case object GreaterThan extends RelationalOperator + case object GreaterThanOrEqual extends RelationalOperator + case object Equal extends RelationalOperator + case object NotEqual extends RelationalOperator + } + + final case class Relational( + left: DynamicSchemaExpr, + right: DynamicSchemaExpr, + operator: RelationalOperator + ) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + for { + l <- left.eval(input) + r <- right.eval(input) + } yield { + val cmp = l.compare(r) + val result = operator match { + case RelationalOperator.LessThan => cmp < 0 + case RelationalOperator.LessThanOrEqual => cmp <= 0 + case RelationalOperator.GreaterThan => cmp > 0 + case RelationalOperator.GreaterThanOrEqual => cmp >= 0 + case RelationalOperator.Equal => cmp == 0 + case RelationalOperator.NotEqual => cmp != 0 + } + DynamicValue.Primitive(PrimitiveValue.Boolean(result)) + } + } + + /** + * Arithmetic operators. + */ + sealed trait ArithmeticOperator + object ArithmeticOperator { + case object Add extends ArithmeticOperator + case object Subtract extends ArithmeticOperator + case object Multiply extends ArithmeticOperator + } + + final case class Arithmetic( + left: DynamicSchemaExpr, + right: DynamicSchemaExpr, + operator: ArithmeticOperator + ) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + for { + l <- left.eval(input) + r <- right.eval(input) + result <- (l, r) match { + case (DynamicValue.Primitive(lp), DynamicValue.Primitive(rp)) => + evalPrimitiveArithmetic(lp, rp, operator) + case _ => + Left( + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + "non-primitive", + "numeric", + "Expected primitives for arithmetic" + ) + ) + ) + } + } yield result + } + + /** + * String concatenation. + */ + final case class StringConcat( + left: DynamicSchemaExpr, + right: DynamicSchemaExpr + ) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + for { + l <- left.eval(input) + r <- right.eval(input) + result <- (l, r) match { + case ( + DynamicValue.Primitive(PrimitiveValue.String(ls)), + DynamicValue.Primitive(PrimitiveValue.String(rs)) + ) => + Right(DynamicValue.Primitive(PrimitiveValue.String(ls + rs))) + case _ => + Left( + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + "non-string", + "String", + "Expected strings for concatenation" + ) + ) + ) + } + } yield result + } + + /** + * String length. + */ + final case class StringLength(expr: DynamicSchemaExpr) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + expr.eval(input).flatMap { + case DynamicValue.Primitive(PrimitiveValue.String(s)) => + Right(DynamicValue.Primitive(PrimitiveValue.Int(s.length))) + case other => + Left( + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + getDynamicValueTypeName(other), + "String", + "Expected string for length" + ) + ) + ) + } + } + + /** + * Coerce a primitive value to a different primitive type. + */ + final case class CoercePrimitive( + expr: DynamicSchemaExpr, + targetType: String + ) extends DynamicSchemaExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + expr.eval(input).flatMap { + case DynamicValue.Primitive(pv) => coercePrimitive(pv, targetType) + case other => + Left( + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + getDynamicValueTypeName(other), + targetType, + "Expected primitive" + ) + ) + ) + } + } + + // Helper to get type name for error messages + private def getDynamicValueTypeName(dv: DynamicValue): String = dv match { + case _: DynamicValue.Primitive => "Primitive" + case _: DynamicValue.Record => "Record" + case _: DynamicValue.Variant => "Variant" + case _: DynamicValue.Sequence => "Sequence" + case _: DynamicValue.Map => "Map" + case DynamicValue.Null => "Null" + } + + // Navigate into a DynamicValue using a DynamicOptic path + private[migration] def navigateDynamicValue(value: DynamicValue, optic: DynamicOptic): Option[DynamicValue] = { + var current = value + val nodes = optic.nodes + var idx = 0 + while (idx < nodes.length) { + nodes(idx) match { + case DynamicOptic.Node.Field(name) => + current match { + case DynamicValue.Record(fields) => + fields.find(_._1 == name) match { + case Some((_, v)) => current = v + case None => return None + } + case _ => return None + } + case DynamicOptic.Node.Case(name) => + current match { + case DynamicValue.Variant(caseName, v) if caseName == name => + current = v + case _ => return None + } + case DynamicOptic.Node.AtIndex(index) => + current match { + case DynamicValue.Sequence(elements) if index >= 0 && index < elements.length => + current = elements(index) + case _ => return None + } + case DynamicOptic.Node.AtMapKey(key) => + current match { + case DynamicValue.Map(entries) => + entries.find(_._1 == key) match { + case Some((_, v)) => current = v + case None => return None + } + case _ => return None + } + case DynamicOptic.Node.Wrapped => + // For wrapped values, we expect a record with a single field + current match { + case DynamicValue.Record(fields) if fields.length == 1 => + current = fields.head._2 + case _ => return None + } + case _ => + // Elements, MapKeys, MapValues, AtIndices, AtMapKeys are traversals, not suitable for single-value extraction + return None + } + idx += 1 + } + Some(current) + } + + // Primitive arithmetic evaluation + private def evalPrimitiveArithmetic( + left: PrimitiveValue, + right: PrimitiveValue, + op: ArithmeticOperator + ): Either[MigrationError, DynamicValue] = + (left, right) match { + case (PrimitiveValue.Int(l), PrimitiveValue.Int(r)) => + val result = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + Right(DynamicValue.Primitive(PrimitiveValue.Int(result))) + + case (PrimitiveValue.Long(l), PrimitiveValue.Long(r)) => + val result = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + Right(DynamicValue.Primitive(PrimitiveValue.Long(result))) + + case (PrimitiveValue.Double(l), PrimitiveValue.Double(r)) => + val result = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + Right(DynamicValue.Primitive(PrimitiveValue.Double(result))) + + case (PrimitiveValue.Float(l), PrimitiveValue.Float(r)) => + val result = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + Right(DynamicValue.Primitive(PrimitiveValue.Float(result))) + + case (PrimitiveValue.Short(l), PrimitiveValue.Short(r)) => + val wide = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + if (wide < Short.MinValue || wide > Short.MaxValue) + Left(MigrationError.single(MigrationError.NumericOverflow(DynamicOptic.root, s"Short $l ${op} $r = $wide"))) + else Right(DynamicValue.Primitive(PrimitiveValue.Short(wide.toShort))) + + case (PrimitiveValue.Byte(l), PrimitiveValue.Byte(r)) => + val wide = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + if (wide < Byte.MinValue || wide > Byte.MaxValue) + Left(MigrationError.single(MigrationError.NumericOverflow(DynamicOptic.root, s"Byte $l ${op} $r = $wide"))) + else Right(DynamicValue.Primitive(PrimitiveValue.Byte(wide.toByte))) + + case (PrimitiveValue.BigInt(l), PrimitiveValue.BigInt(r)) => + val result = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + Right(DynamicValue.Primitive(PrimitiveValue.BigInt(result))) + + case (PrimitiveValue.BigDecimal(l), PrimitiveValue.BigDecimal(r)) => + val result = op match { + case ArithmeticOperator.Add => l + r + case ArithmeticOperator.Subtract => l - r + case ArithmeticOperator.Multiply => l * r + } + Right(DynamicValue.Primitive(PrimitiveValue.BigDecimal(result))) + + case _ => + Left( + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + s"${left.getClass.getSimpleName}, ${right.getClass.getSimpleName}", + "compatible numeric types", + "Arithmetic requires matching numeric types" + ) + ) + ) + } + + // Coerce a primitive value to a target type + private def coercePrimitive(value: PrimitiveValue, targetType: String): Either[MigrationError, DynamicValue] = { + def toInt: Either[MigrationError, Int] = value match { + case PrimitiveValue.Int(v) => Right(v) + case PrimitiveValue.Long(v) => + if (v < Int.MinValue || v > Int.MaxValue) Left(conversionError(value, targetType)) + else Right(v.toInt) + case PrimitiveValue.Short(v) => Right(v.toInt) + case PrimitiveValue.Byte(v) => Right(v.toInt) + case PrimitiveValue.Double(v) => + if (v < Int.MinValue.toDouble || v > Int.MaxValue.toDouble) Left(conversionError(value, targetType)) + else Right(v.toInt) + case PrimitiveValue.Float(v) => + if (v < Int.MinValue.toFloat || v > Int.MaxValue.toFloat) Left(conversionError(value, targetType)) + else Right(v.toInt) + case PrimitiveValue.String(v) => v.toIntOption.toRight(conversionError(value, targetType)) + case _ => Left(conversionError(value, targetType)) + } + + def toLong: Either[MigrationError, Long] = value match { + case PrimitiveValue.Long(v) => Right(v) + case PrimitiveValue.Int(v) => Right(v.toLong) + case PrimitiveValue.Short(v) => Right(v.toLong) + case PrimitiveValue.Byte(v) => Right(v.toLong) + case PrimitiveValue.Double(v) => + if (v < Long.MinValue.toDouble || v > Long.MaxValue.toDouble) Left(conversionError(value, targetType)) + else Right(v.toLong) + case PrimitiveValue.Float(v) => + Right(v.toLong) + case PrimitiveValue.String(v) => v.toLongOption.toRight(conversionError(value, targetType)) + case _ => Left(conversionError(value, targetType)) + } + + def toDouble: Either[MigrationError, Double] = value match { + case PrimitiveValue.Double(v) => Right(v) + case PrimitiveValue.Float(v) => Right(v.toDouble) + case PrimitiveValue.Int(v) => Right(v.toDouble) + case PrimitiveValue.Long(v) => Right(v.toDouble) + case PrimitiveValue.Short(v) => Right(v.toDouble) + case PrimitiveValue.Byte(v) => Right(v.toDouble) + case PrimitiveValue.String(v) => v.toDoubleOption.toRight(conversionError(value, targetType)) + case _ => Left(conversionError(value, targetType)) + } + + def toFloat: Either[MigrationError, Float] = value match { + case PrimitiveValue.Float(v) => Right(v) + case PrimitiveValue.Double(v) => + if (!v.isNaN && !v.isInfinite && v.abs > Float.MaxValue) + Left(conversionError(value, targetType)) + else Right(v.toFloat) + case PrimitiveValue.Int(v) => Right(v.toFloat) + case PrimitiveValue.Long(v) => Right(v.toFloat) + case PrimitiveValue.Short(v) => Right(v.toFloat) + case PrimitiveValue.Byte(v) => Right(v.toFloat) + case PrimitiveValue.String(v) => v.toFloatOption.toRight(conversionError(value, targetType)) + case _ => Left(conversionError(value, targetType)) + } + + def toString_ : Either[MigrationError, String] = value match { + case PrimitiveValue.String(v) => Right(v) + case PrimitiveValue.Int(v) => Right(v.toString) + case PrimitiveValue.Long(v) => Right(v.toString) + case PrimitiveValue.Double(v) => Right(v.toString) + case PrimitiveValue.Float(v) => Right(v.toString) + case PrimitiveValue.Boolean(v) => Right(v.toString) + case PrimitiveValue.Short(v) => Right(v.toString) + case PrimitiveValue.Byte(v) => Right(v.toString) + case PrimitiveValue.Char(v) => Right(v.toString) + case PrimitiveValue.BigInt(v) => Right(v.toString) + case PrimitiveValue.BigDecimal(v) => Right(v.toString) + case _ => Left(conversionError(value, targetType)) + } + + def toBoolean: Either[MigrationError, Boolean] = value match { + case PrimitiveValue.Boolean(v) => Right(v) + case PrimitiveValue.String(v) => v.toBooleanOption.toRight(conversionError(value, targetType)) + case PrimitiveValue.Int(v) => Right(v != 0) + case _ => Left(conversionError(value, targetType)) + } + + targetType match { + case "Int" => toInt.map(v => DynamicValue.Primitive(PrimitiveValue.Int(v))) + case "Long" => toLong.map(v => DynamicValue.Primitive(PrimitiveValue.Long(v))) + case "Double" => toDouble.map(v => DynamicValue.Primitive(PrimitiveValue.Double(v))) + case "Float" => toFloat.map(v => DynamicValue.Primitive(PrimitiveValue.Float(v))) + case "String" => toString_.map(v => DynamicValue.Primitive(PrimitiveValue.String(v))) + case "Boolean" => toBoolean.map(v => DynamicValue.Primitive(PrimitiveValue.Boolean(v))) + case _ => Left(conversionError(value, targetType)) + } + } + + private def conversionError(value: PrimitiveValue, targetType: String): MigrationError = + MigrationError.single( + MigrationError.TypeConversionFailed( + DynamicOptic.root, + value.getClass.getSimpleName, + targetType, + "Conversion not supported" + ) + ) +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/Migration.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/Migration.scala new file mode 100644 index 0000000000..418e899ed3 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/Migration.scala @@ -0,0 +1,200 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ + +import scala.util.control.NonFatal + +/** + * A typed migration from type `A` to type `B`. + * + * [[Migration]] wraps a [[DynamicMigration]] with source and target schemas, + * providing: + * - Type-safe application of migrations + * - Compile-time validation via macros + * - Composable migration chains + * - Reversibility (structural inverse) + * + * The schemas are "structural schemas" - for old versions, these may be derived + * from structural types that exist only at compile time, with no runtime + * representation. + * + * @tparam A + * the source type + * @tparam B + * the target type + * @param dynamicMigration + * the underlying untyped migration + * @param sourceSchema + * schema for the source type + * @param targetSchema + * schema for the target type + */ +final case class Migration[A, B]( + dynamicMigration: DynamicMigration, + sourceSchema: Schema[A], + targetSchema: Schema[B] +) { + + /** + * Apply this migration to transform a value of type A to type B. + */ + def apply(value: A): Either[MigrationError, B] = { + val dynamicValueEither = + try Right(sourceSchema.toDynamicValue(value)) + catch { + case _: UnsupportedOperationException => + Left( + MigrationError.single( + MigrationError.TransformFailed( + DynamicOptic.root, + "Cannot apply typed migration to a structural source schema at runtime. Use `applyDynamic` with `DynamicValue` instead." + ) + ) + ) + case NonFatal(e) => + Left( + MigrationError.single( + MigrationError.TransformFailed( + DynamicOptic.root, + s"Failed to convert source value to DynamicValue: ${e.getMessage}" + ) + ) + ) + } + + dynamicValueEither.flatMap { dynamicValue => + dynamicMigration(dynamicValue).flatMap { result => + try { + targetSchema.fromDynamicValue(result) match { + case Right(b) => Right(b) + case Left(schemaError) => + Left( + MigrationError.single( + MigrationError.TransformFailed( + DynamicOptic.root, + s"Failed to convert result to target type: ${schemaError.getMessage}" + ) + ) + ) + } + } catch { + case _: UnsupportedOperationException => + Left( + MigrationError.single( + MigrationError.TransformFailed( + DynamicOptic.root, + "Cannot materialize a structural target schema at runtime. Use `applyDynamic` with `DynamicValue` instead." + ) + ) + ) + case NonFatal(e) => + Left( + MigrationError.single( + MigrationError.TransformFailed( + DynamicOptic.root, + s"Failed to convert result to target type: ${e.getMessage}" + ) + ) + ) + } + } + } + } + + /** + * Apply this migration on a [[DynamicValue]] directly. + */ + def applyDynamic(value: DynamicValue): Either[MigrationError, DynamicValue] = + dynamicMigration(value) + + /** + * Compose this migration with another, creating a migration from A to C. + */ + def ++[C](that: Migration[B, C]): Migration[A, C] = + new Migration( + dynamicMigration ++ that.dynamicMigration, + sourceSchema, + that.targetSchema + ) + + /** + * Alias for `++`. + */ + def andThen[C](that: Migration[B, C]): Migration[A, C] = this ++ that + + /** + * Get the structural reverse of this migration. + * + * The reversed migration transforms from B back to A. Runtime correctness is + * best-effort - it depends on sufficient default values being available for + * reverse operations. + */ + def reverse: Migration[B, A] = + new Migration( + dynamicMigration.reverse, + targetSchema, + sourceSchema + ) + + /** + * Check if this migration is empty (no actions). + */ + def isEmpty: Boolean = dynamicMigration.isEmpty + + /** + * Check if this migration has actions. + */ + def nonEmpty: Boolean = dynamicMigration.nonEmpty + + /** + * Get the list of actions in this migration. + */ + def actions: Vector[MigrationAction] = dynamicMigration.actions +} + +object Migration { + + /** + * Create an identity migration that performs no changes. + */ + def identity[A](implicit schema: Schema[A]): Migration[A, A] = + new Migration(DynamicMigration.empty, schema, schema) + + /** + * Create a new migration builder for migrating from type A to type B. + */ + def newBuilder[A, B](implicit + sourceSchema: Schema[A], + targetSchema: Schema[B] + ): MigrationBuilder[A, B] = + new MigrationBuilder[A, B](sourceSchema, targetSchema, Vector.empty) + + /** + * Create a migration from a single action. + */ + def fromAction[A, B](action: MigrationAction)(implicit + sourceSchema: Schema[A], + targetSchema: Schema[B] + ): Migration[A, B] = + new Migration(DynamicMigration(action), sourceSchema, targetSchema) + + /** + * Create a migration from a sequence of actions. + */ + def fromActions[A, B](actions: MigrationAction*)(implicit + sourceSchema: Schema[A], + targetSchema: Schema[B] + ): Migration[A, B] = + new Migration(new DynamicMigration(actions.toVector), sourceSchema, targetSchema) + + /** + * Create a migration from a [[DynamicMigration]]. + */ + def fromDynamic[A, B]( + dynamicMigration: DynamicMigration + )(implicit + sourceSchema: Schema[A], + targetSchema: Schema[B] + ): Migration[A, B] = + new Migration(dynamicMigration, sourceSchema, targetSchema) +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationAction.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationAction.scala new file mode 100644 index 0000000000..63f406956e --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationAction.scala @@ -0,0 +1,305 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema.DynamicOptic + +/** + * A migration action represents a single transformation step in a + * [[DynamicMigration]]. + * + * All actions operate at a specific path (represented by [[DynamicOptic]]) and + * are: + * - Pure data (no user functions, closures, or runtime code generation) + * - Fully serializable + * - Reversible (structurally; runtime is best-effort) + * + * This enables migrations to be stored, inspected, and used to generate DDL, + * data transforms, etc. + */ +sealed trait MigrationAction { + + /** + * The path at which this action operates. + */ + def at: DynamicOptic + + /** + * Get the structural reverse of this action. + * + * The reverse of an action undoes the structural change. For semantic + * correctness, reverse actions may require additional information (e.g., + * default values for dropped fields). + */ + def reverse: MigrationAction +} + +object MigrationAction { + + // ==================== Record Actions ==================== + + /** + * Add a new field to a record at the specified path. + * + * @param at + * path to the record + * @param name + * name of the new field + * @param default + * expression producing the default value for the new field + */ + final case class AddField( + at: DynamicOptic, + name: String, + default: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = DropField(at, name, default) + } + + /** + * Drop a field from a record at the specified path. + * + * @param at + * path to the record + * @param name + * name of the field to drop + * @param defaultForReverse + * expression producing the default value when reversing (re-adding the + * field) + */ + final case class DropField( + at: DynamicOptic, + name: String, + defaultForReverse: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = AddField(at, name, defaultForReverse) + } + + /** + * Rename a field in a record. + * + * @param at + * path to the record + * @param from + * original field name + * @param to + * new field name + */ + final case class RenameField( + at: DynamicOptic, + from: String, + to: String + ) extends MigrationAction { + def reverse: MigrationAction = RenameField(at, to, from) + } + + /** + * Transform the value at a specific field path. + * + * @param at + * path to the value to transform + * @param transform + * expression that computes the new value + * @param reverseTransform + * expression that reverses the transform (best-effort) + */ + final case class TransformValue( + at: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = TransformValue(at, reverseTransform, transform) + } + + /** + * Make an optional field mandatory by providing a default for None values. + * + * @param at + * path to the optional field + * @param default + * expression producing value when the original is None + */ + final case class Mandate( + at: DynamicOptic, + default: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = Optionalize(at, default) + } + + /** + * Make a mandatory field optional (wrapping values in Some). + * + * @param at + * path to the field + */ + final case class Optionalize( + at: DynamicOptic, + defaultForReverse: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ) extends MigrationAction { + def reverse: MigrationAction = Mandate(at, defaultForReverse) + } + + /** + * Change the type of a field (primitive-to-primitive only). + * + * @param at + * path to the field + * @param converter + * expression that converts to the new type + * @param reverseConverter + * expression that converts back to the original type + */ + final case class ChangeType( + at: DynamicOptic, + converter: DynamicSchemaExpr, + reverseConverter: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = ChangeType(at, reverseConverter, converter) + } + + /** + * Join multiple fields into a single field. + * + * @param at + * path to the new combined field + * @param sourcePaths + * paths to the source fields (relative to the same record as `at`) + * @param combiner + * expression that combines the source values into one + * @param splitter + * expression that splits the combined value back (for reverse) + */ + final case class Join( + at: DynamicOptic, + sourcePaths: Vector[DynamicOptic], + combiner: DynamicSchemaExpr, + splitter: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = Split(at, sourcePaths, splitter, combiner) + } + + /** + * Split a single field into multiple fields. + * + * @param at + * path to the field to split + * @param targetPaths + * paths to the target fields (relative to the same record as `at`) + * @param splitter + * expression that produces values for each target field + * @param combiner + * expression that combines back (for reverse) + */ + final case class Split( + at: DynamicOptic, + targetPaths: Vector[DynamicOptic], + splitter: DynamicSchemaExpr, + combiner: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = Join(at, targetPaths, combiner, splitter) + } + + // ==================== Enum/Variant Actions ==================== + + /** + * Rename a case in a variant/enum. + * + * @param at + * path to the variant + * @param from + * original case name + * @param to + * new case name + */ + final case class RenameCase( + at: DynamicOptic, + from: String, + to: String + ) extends MigrationAction { + def reverse: MigrationAction = RenameCase(at, to, from) + } + + /** + * Transform the structure within a specific case of a variant. + * + * @param at + * path to the variant + * @param caseName + * name of the case to transform + * @param actions + * nested migration actions to apply within the case + */ + final case class TransformCase( + at: DynamicOptic, + caseName: String, + actions: Vector[MigrationAction] + ) extends MigrationAction { + def reverse: MigrationAction = TransformCase(at, caseName, actions.map(_.reverse).reverse) + } + + // ==================== Collection Actions ==================== + + /** + * Transform all elements in a sequence/collection. + * + * @param at + * path to the sequence + * @param transform + * expression applied to each element + * @param reverseTransform + * expression to reverse the transform + */ + final case class TransformElements( + at: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = TransformElements(at, reverseTransform, transform) + } + + // ==================== Map Actions ==================== + + /** + * Transform all keys in a map. + * + * @param at + * path to the map + * @param transform + * expression applied to each key + * @param reverseTransform + * expression to reverse the transform + */ + final case class TransformKeys( + at: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = TransformKeys(at, reverseTransform, transform) + } + + /** + * Transform all values in a map. + * + * @param at + * path to the map + * @param transform + * expression applied to each value + * @param reverseTransform + * expression to reverse the transform + */ + final case class TransformValues( + at: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr + ) extends MigrationAction { + def reverse: MigrationAction = TransformValues(at, reverseTransform, transform) + } + + // ==================== Composite/No-Op ==================== + + /** + * A no-op action that does nothing. Useful as identity. + */ + case object Identity extends MigrationAction { + val at: DynamicOptic = DynamicOptic.root + def reverse: MigrationAction = Identity + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationBuilder.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationBuilder.scala new file mode 100644 index 0000000000..4d2f76e36e --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationBuilder.scala @@ -0,0 +1,634 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ + +/** + * A builder for constructing [[Migration]] instances. + * + * The builder provides a fluent API for defining migration actions using + * selector expressions. In the full implementation, selector-accepting methods + * would be implemented via macros that: + * 1. Inspect the selector expression + * 2. Validate it is a supported projection + * 3. Convert it to a [[DynamicOptic]] + * 4. Store the optic in the migration action + * + * This implementation provides runtime versions of these methods that accept + * [[DynamicOptic]] paths directly. The macro layer can be added for + * compile-time selector validation. + * + * @tparam A + * the source type + * @tparam B + * the target type + */ +class MigrationBuilder[A, B]( + val sourceSchema: Schema[A], + val targetSchema: Schema[B], + val actions: Vector[MigrationAction] +) { + + // ==================== Record Operations ==================== + + /** + * Add a field to the target record. + * + * @param path + * path to the new field (typically _.fieldName) + * @param default + * expression producing the default value + */ + def addField(path: DynamicOptic, default: DynamicSchemaExpr): MigrationBuilder[A, B] = { + val (parentPath, fieldName) = splitPath(path) + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.AddField(parentPath, fieldName, default) + ) + } + + /** + * Add a field with a literal default value. + */ + def addField[T](path: DynamicOptic, default: T)(implicit schema: Schema[T]): MigrationBuilder[A, B] = { + val defaultValue = schema.toDynamicValue(default) + addField(path, DynamicSchemaExpr.Literal(defaultValue)) + } + + /** + * Drop a field from the source record. + * + * @param path + * path to the field to drop + * @param defaultForReverse + * expression producing value when reversing (optional) + */ + def dropField( + path: DynamicOptic, + defaultForReverse: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ): MigrationBuilder[A, B] = { + val (parentPath, fieldName) = splitPath(path) + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.DropField(parentPath, fieldName, defaultForReverse) + ) + } + + /** + * Rename a field. + * + * @param from + * path to the source field + * @param to + * path to the target field (must be at same level as source) + */ + def renameField(from: DynamicOptic, to: DynamicOptic): MigrationBuilder[A, B] = { + val (parentPath, fromName) = splitPath(from) + val (_, toName) = splitPath(to) + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.RenameField(parentPath, fromName, toName) + ) + } + + /** + * Rename a field using string names. + */ + def renameField(from: String, to: String): MigrationBuilder[A, B] = + renameField(DynamicOptic.root.field(from), DynamicOptic.root.field(to)) + + /** + * Transform a field value. + * + * @param path + * path to the field + * @param transform + * expression to transform the value + * @param reverseTransform + * expression to reverse the transform (for reverse migration) + */ + def transformField( + path: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformValue(path, transform, reverseTransform) + ) + + /** + * Make an optional field mandatory. + * + * @param path + * path to the optional field + * @param default + * expression producing value when None + */ + def mandateField( + path: DynamicOptic, + default: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.Mandate(path, default) + ) + + /** + * Make an optional field mandatory with a literal default. + */ + def mandateField[T](path: DynamicOptic, default: T)(implicit schema: Schema[T]): MigrationBuilder[A, B] = + mandateField(path, DynamicSchemaExpr.Literal(schema.toDynamicValue(default))) + + /** + * Make a mandatory field optional. + * + * @param path + * path to the field + */ + def optionalizeField( + path: DynamicOptic, + defaultForReverse: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.Optionalize(path, defaultForReverse) + ) + + /** + * Change the type of a field (primitive-to-primitive only). + * + * @param path + * path to the field + * @param converter + * expression to convert to the new type + * @param reverseConverter + * expression to convert back + */ + def changeFieldType( + path: DynamicOptic, + converter: DynamicSchemaExpr, + reverseConverter: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.ChangeType(path, converter, reverseConverter) + ) + + /** + * Change a field from one primitive type to another using type names. + */ + def changeFieldType(path: DynamicOptic, toType: String, fromType: String): MigrationBuilder[A, B] = + changeFieldType( + path, + DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Path( + if (path.nodes.isEmpty) DynamicOptic.root + else DynamicOptic(Vector(path.nodes.last)) + ), + toType + ), + DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Path( + if (path.nodes.isEmpty) DynamicOptic.root + else DynamicOptic(Vector(path.nodes.last)) + ), + fromType + ) + ) + + /** + * Join multiple fields into one. + * + * @param target + * path to the target combined field + * @param sourcePaths + * paths to source fields + * @param combiner + * expression to combine source values + * @param splitter + * expression to split back (for reverse) + */ + def joinFields( + target: DynamicOptic, + sourcePaths: Vector[DynamicOptic], + combiner: DynamicSchemaExpr, + splitter: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.Join(target, sourcePaths, combiner, splitter) + ) + + /** + * Split a field into multiple fields. + * + * @param source + * path to the source field + * @param targetPaths + * paths to target fields + * @param splitter + * expression to split the source value + * @param combiner + * expression to combine back (for reverse) + */ + def splitField( + source: DynamicOptic, + targetPaths: Vector[DynamicOptic], + splitter: DynamicSchemaExpr, + combiner: DynamicSchemaExpr + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.Split(source, targetPaths, splitter, combiner) + ) + + // ==================== Enum Operations ==================== + + /** + * Rename a case in a variant/enum. + * + * @param from + * original case name + * @param to + * new case name + */ + def renameCase(from: String, to: String): MigrationBuilder[A, B] = + renameCaseAt(DynamicOptic.root, from, to) + + /** + * Rename a case in a variant at a specific path. + */ + def renameCaseAt(path: DynamicOptic, from: String, to: String): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.RenameCase(path, from, to) + ) + + /** + * Transform the structure within a specific case. + * + * @param caseName + * name of the case to transform + * @param caseMigration + * function that configures the nested migration + */ + def transformCase( + caseName: String, + caseMigration: MigrationBuilder[Any, Any] => MigrationBuilder[Any, Any] + ): MigrationBuilder[A, B] = + transformCaseAt(DynamicOptic.root, caseName, caseMigration) + + /** + * Transform a case at a specific path. + */ + def transformCaseAt( + path: DynamicOptic, + caseName: String, + caseMigration: MigrationBuilder[Any, Any] => MigrationBuilder[Any, Any] + ): MigrationBuilder[A, B] = { + // Create an empty builder and let the user configure it + val emptyBuilder = new MigrationBuilder[Any, Any]( + Schema.dynamic.asInstanceOf[Schema[Any]], + Schema.dynamic.asInstanceOf[Schema[Any]], + Vector.empty + ) + val configuredBuilder = caseMigration(emptyBuilder) + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformCase(path, caseName, configuredBuilder.actions) + ) + } + + // ==================== Collection Operations ==================== + + /** + * Transform all elements in a sequence. + * + * @param path + * path to the sequence + * @param transform + * expression to apply to each element + * @param reverseTransform + * expression to reverse the transform + */ + def transformElements( + path: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformElements(path, transform, reverseTransform) + ) + + // ==================== Map Operations ==================== + + /** + * Transform all keys in a map. + * + * @param path + * path to the map + * @param transform + * expression to apply to each key + * @param reverseTransform + * expression to reverse the transform + */ + def transformKeys( + path: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformKeys(path, transform, reverseTransform) + ) + + /** + * Transform all values in a map. + * + * @param path + * path to the map + * @param transform + * expression to apply to each value + * @param reverseTransform + * expression to reverse the transform + */ + def transformValues( + path: DynamicOptic, + transform: DynamicSchemaExpr, + reverseTransform: DynamicSchemaExpr = DynamicSchemaExpr.DefaultValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformValues(path, transform, reverseTransform) + ) + + // ==================== Build Methods ==================== + + /** + * Build the migration with full validation. + * + * This validates that applying the migration actions to the source schema + * structure would produce a structure compatible with the target schema. + * + * @throws java.lang.IllegalArgumentException + * if validation fails + */ + def build: Migration[A, B] = { + val validation = MigrationValidator.validate(sourceSchema, targetSchema, actions) + if (!validation.isValid) { + throw new IllegalArgumentException( + s"Migration validation failed:\n${validation.errors.mkString(" - ", "\n - ", "")}" + ) + } + buildPartial + } + + /** + * Build the migration and return validation result. + * + * Unlike `build`, this does not throw on validation failure but returns the + * validation result along with the migration. + */ + def buildValidated: Either[List[String], Migration[A, B]] = { + val validation = MigrationValidator.validate(sourceSchema, targetSchema, actions) + if (validation.isValid) { + Right(buildPartial) + } else { + Left(validation.errors) + } + } + + /** + * Build the migration without full structural validation. + * + * Use this when you want to skip validation, such as for partial migrations + * or when validation is too restrictive. + */ + def buildPartial: Migration[A, B] = + new Migration(new DynamicMigration(resolveDefaults(actions)), sourceSchema, targetSchema) + + // ==================== Helper Methods ==================== + + private def resolveDefaults(actions: Vector[MigrationAction]): Vector[MigrationAction] = { + def resolveDefaultValue(schema: Schema[_], optic: DynamicOptic): Option[DynamicValue] = + schema.reflect.get(optic).flatMap { reflect => + val anyReflect = reflect.asInstanceOf[Reflect[zio.blocks.schema.binding.Binding, Any]] + anyReflect.getDefaultValue.map(anyReflect.toDynamicValue) + } + + def containsDefaultValue(expr: DynamicSchemaExpr): Boolean = expr match { + case DynamicSchemaExpr.DefaultValue => true + case DynamicSchemaExpr.ResolvedDefault(_) => false + case DynamicSchemaExpr.Literal(_) => false + case DynamicSchemaExpr.Path(_) => false + case DynamicSchemaExpr.Not(e) => containsDefaultValue(e) + case DynamicSchemaExpr.Logical(l, r, _) => containsDefaultValue(l) || containsDefaultValue(r) + case DynamicSchemaExpr.Relational(l, r, _) => containsDefaultValue(l) || containsDefaultValue(r) + case DynamicSchemaExpr.Arithmetic(l, r, _) => containsDefaultValue(l) || containsDefaultValue(r) + case DynamicSchemaExpr.StringConcat(l, r) => containsDefaultValue(l) || containsDefaultValue(r) + case DynamicSchemaExpr.StringLength(e) => containsDefaultValue(e) + case DynamicSchemaExpr.CoercePrimitive(e, _) => containsDefaultValue(e) + } + + def rewriteDefault(expr: DynamicSchemaExpr, replacement: DynamicSchemaExpr): DynamicSchemaExpr = expr match { + case DynamicSchemaExpr.DefaultValue => + replacement + case DynamicSchemaExpr.ResolvedDefault(_) => + expr + case DynamicSchemaExpr.Literal(_) => + expr + case DynamicSchemaExpr.Path(_) => + expr + case DynamicSchemaExpr.Not(e) => + DynamicSchemaExpr.Not(rewriteDefault(e, replacement)) + case DynamicSchemaExpr.Logical(l, r, op) => + DynamicSchemaExpr.Logical(rewriteDefault(l, replacement), rewriteDefault(r, replacement), op) + case DynamicSchemaExpr.Relational(l, r, op) => + DynamicSchemaExpr.Relational(rewriteDefault(l, replacement), rewriteDefault(r, replacement), op) + case DynamicSchemaExpr.Arithmetic(l, r, op) => + DynamicSchemaExpr.Arithmetic(rewriteDefault(l, replacement), rewriteDefault(r, replacement), op) + case DynamicSchemaExpr.StringConcat(l, r) => + DynamicSchemaExpr.StringConcat(rewriteDefault(l, replacement), rewriteDefault(r, replacement)) + case DynamicSchemaExpr.StringLength(e) => + DynamicSchemaExpr.StringLength(rewriteDefault(e, replacement)) + case DynamicSchemaExpr.CoercePrimitive(e, tpe) => + DynamicSchemaExpr.CoercePrimitive(rewriteDefault(e, replacement), tpe) + } + + def resolveExprStrict(schema: Schema[_], defaultAt: DynamicOptic, expr: DynamicSchemaExpr): DynamicSchemaExpr = + if (!containsDefaultValue(expr)) expr + else { + val defaultValue = resolveDefaultValue(schema, defaultAt).getOrElse { + throw new IllegalArgumentException(s"DefaultValue used but no default is available at path $defaultAt.") + } + rewriteDefault(expr, DynamicSchemaExpr.ResolvedDefault(defaultValue)) + } + + def resolveExprBestEffort(schema: Schema[_], defaultAt: DynamicOptic, expr: DynamicSchemaExpr): DynamicSchemaExpr = + if (!containsDefaultValue(expr)) expr + else + resolveDefaultValue(schema, defaultAt).fold(expr) { dv => + rewriteDefault(expr, DynamicSchemaExpr.ResolvedDefault(dv)) + } + + actions.map { + case MigrationAction.AddField(at, name, default) => + val fieldPath = at.field(name) + MigrationAction.AddField(at, name, resolveExprStrict(targetSchema, fieldPath, default)) + + case MigrationAction.DropField(at, name, defaultForReverse) => + val fieldPath = at.field(name) + MigrationAction.DropField(at, name, resolveExprBestEffort(sourceSchema, fieldPath, defaultForReverse)) + + case a: MigrationAction.RenameField => + a + + case MigrationAction.TransformValue(at, transform, reverseTransform) => + MigrationAction.TransformValue( + at, + resolveExprStrict(targetSchema, at, transform), + resolveExprBestEffort(sourceSchema, at, reverseTransform) + ) + + case MigrationAction.Mandate(at, default) => + MigrationAction.Mandate(at, resolveExprStrict(targetSchema, at, default)) + + case MigrationAction.Optionalize(at, defaultForReverse) => + MigrationAction.Optionalize(at, resolveExprBestEffort(sourceSchema, at, defaultForReverse)) + + case MigrationAction.ChangeType(at, converter, reverseConverter) => + MigrationAction.ChangeType( + at, + resolveExprStrict(targetSchema, at, converter), + resolveExprBestEffort(sourceSchema, at, reverseConverter) + ) + + case MigrationAction.Join(at, sourcePaths, combiner, splitter) => + MigrationAction.Join( + at, + sourcePaths, + resolveExprStrict(targetSchema, at, combiner), + resolveExprBestEffort(sourceSchema, at, splitter) + ) + + case MigrationAction.Split(at, targetPaths, splitter, combiner) => + MigrationAction.Split( + at, + targetPaths, + resolveExprStrict(targetSchema, at, splitter), + resolveExprBestEffort(sourceSchema, at, combiner) + ) + + case a: MigrationAction.RenameCase => + a + + case MigrationAction.TransformCase(at, caseName, nestedActions) => + MigrationAction.TransformCase( + at, + caseName, + resolveDefaults(nestedActions) + ) + + case MigrationAction.TransformElements(at, transform, reverseTransform) => + val elementPath = new DynamicOptic(at.nodes :+ DynamicOptic.Node.Elements) + MigrationAction.TransformElements( + at, + resolveExprStrict(targetSchema, elementPath, transform), + resolveExprBestEffort(sourceSchema, elementPath, reverseTransform) + ) + + case MigrationAction.TransformKeys(at, transform, reverseTransform) => + val keyPath = new DynamicOptic(at.nodes :+ DynamicOptic.Node.MapKeys) + MigrationAction.TransformKeys( + at, + resolveExprStrict(targetSchema, keyPath, transform), + resolveExprBestEffort(sourceSchema, keyPath, reverseTransform) + ) + + case MigrationAction.TransformValues(at, transform, reverseTransform) => + val valuePath = new DynamicOptic(at.nodes :+ DynamicOptic.Node.MapValues) + MigrationAction.TransformValues( + at, + resolveExprStrict(targetSchema, valuePath, transform), + resolveExprBestEffort(sourceSchema, valuePath, reverseTransform) + ) + + case MigrationAction.Identity => + MigrationAction.Identity + } + } + + /** + * Split a path into parent path and field name. + */ + private def splitPath(path: DynamicOptic): (DynamicOptic, String) = { + val nodes = path.nodes + nodes.lastOption match { + case Some(DynamicOptic.Node.Field(name)) => + (DynamicOptic(nodes.dropRight(1)), name) + case _ => + throw new IllegalArgumentException(s"Path must end in a field node, but was: $path") + } + } +} + +object MigrationBuilder { + + /** + * Create a new builder for migrating from A to B. + */ + def apply[A, B](implicit + sourceSchema: Schema[A], + targetSchema: Schema[B] + ): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, Vector.empty) + + /** + * Convenient syntax for creating paths. + */ + object paths { + def field(name: String): DynamicOptic = DynamicOptic.root.field(name) + def field(names: String*): DynamicOptic = names.foldLeft(DynamicOptic.root)(_.field(_)) + def elements: DynamicOptic = DynamicOptic.elements + def mapKeys: DynamicOptic = DynamicOptic.mapKeys + def mapValues: DynamicOptic = DynamicOptic.mapValues + } + + /** + * Convenient syntax for creating expressions. + */ + object exprs { + def literal[T](value: T)(implicit schema: Schema[T]): DynamicSchemaExpr = + DynamicSchemaExpr.Literal(schema.toDynamicValue(value)) + + def path(optic: DynamicOptic): DynamicSchemaExpr = + DynamicSchemaExpr.Path(optic) + + def path(fieldName: String): DynamicSchemaExpr = + DynamicSchemaExpr.Path(DynamicOptic.root.field(fieldName)) + + def concat(left: DynamicSchemaExpr, right: DynamicSchemaExpr): DynamicSchemaExpr = + DynamicSchemaExpr.StringConcat(left, right) + + def defaultValue: DynamicSchemaExpr = + DynamicSchemaExpr.DefaultValue + + def coerce(expr: DynamicSchemaExpr, targetType: String): DynamicSchemaExpr = + DynamicSchemaExpr.CoercePrimitive(expr, targetType) + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationError.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationError.scala new file mode 100644 index 0000000000..ad0aea75e0 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationError.scala @@ -0,0 +1,142 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema.DynamicOptic + +import scala.util.control.NoStackTrace + +/** + * An error that occurred during a migration operation. + * + * All errors capture path information via [[DynamicOptic]] to enable + * diagnostics such as "Failed to apply TransformValue at + * .addresses.each.streetNumber". + */ +final case class MigrationError(errors: ::[MigrationError.Single]) extends Exception with NoStackTrace { + def ++(other: MigrationError): MigrationError = + new MigrationError(new ::(errors.head, errors.tail ++ other.errors)) + + def message: String = errors.map(_.message).mkString("\n") + + override def getMessage: String = message +} + +object MigrationError { + + def single(error: Single): MigrationError = + new MigrationError(new ::(error, Nil)) + + sealed trait Single { + def path: DynamicOptic + def message: String + } + + /** + * A required field was not found in the source value. + */ + case class FieldNotFound(path: DynamicOptic, fieldName: String) extends Single { + def message: String = s"Field '$fieldName' not found at path $path" + } + + /** + * A field already exists when trying to add it. + */ + case class FieldAlreadyExists(path: DynamicOptic, fieldName: String) extends Single { + def message: String = s"Field '$fieldName' already exists at path $path" + } + + /** + * Expected a record but found a different kind of value. + */ + case class NotARecord(path: DynamicOptic, actual: String) extends Single { + def message: String = s"Expected a record at path $path, but found $actual" + } + + /** + * Expected a variant but found a different kind of value. + */ + case class NotAVariant(path: DynamicOptic, actual: String) extends Single { + def message: String = s"Expected a variant at path $path, but found $actual" + } + + /** + * Expected a sequence but found a different kind of value. + */ + case class NotASequence(path: DynamicOptic, actual: String) extends Single { + def message: String = s"Expected a sequence at path $path, but found $actual" + } + + /** + * Expected a map but found a different kind of value. + */ + case class NotAMap(path: DynamicOptic, actual: String) extends Single { + def message: String = s"Expected a map at path $path, but found $actual" + } + + /** + * Expected an optional value but found a non-optional one or vice versa. + */ + case class OptionalMismatch(path: DynamicOptic, message: String) extends Single + + /** + * A case name was not found in a variant. + */ + case class CaseNotFound(path: DynamicOptic, caseName: String) extends Single { + def message: String = s"Case '$caseName' not found in variant at path $path" + } + + /** + * Type conversion failed. + */ + case class TypeConversionFailed(path: DynamicOptic, from: String, to: String, reason: String) extends Single { + def message: String = s"Failed to convert from '$from' to '$to' at path $path: $reason" + } + + /** + * A transformation expression failed to evaluate. + */ + case class TransformFailed(path: DynamicOptic, reason: String) extends Single { + def message: String = s"Transform failed at path $path: $reason" + } + + /** + * Path navigation failed. + */ + case class PathNavigationFailed(path: DynamicOptic, reason: String) extends Single { + def message: String = s"Failed to navigate at path $path: $reason" + } + + /** + * Default value was required but not available. + */ + case class DefaultValueMissing(path: DynamicOptic, fieldName: String) extends Single { + def message: String = s"Default value required but not available for field '$fieldName' at path $path" + } + + /** + * Index out of bounds when accessing sequence elements. + */ + case class IndexOutOfBounds(path: DynamicOptic, index: Int, size: Int) extends Single { + def message: String = s"Index $index out of bounds (size: $size) at path $path" + } + + /** + * Key not found when accessing map entries. + */ + case class KeyNotFound(path: DynamicOptic, key: String) extends Single { + def message: String = s"Key '$key' not found in map at path $path" + } + + /** + * Numeric constraint violations during transforms. + */ + case class NumericOverflow(path: DynamicOptic, operation: String) extends Single { + def message: String = s"Numeric overflow during '$operation' at path $path" + } + + /** + * General migration action failure. + */ + case class ActionFailed(path: DynamicOptic, action: String, reason: String) extends Single { + def message: String = s"Action '$action' failed at path $path: $reason" + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationSchemas.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationSchemas.scala new file mode 100644 index 0000000000..1e28f388ce --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationSchemas.scala @@ -0,0 +1,1242 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.schema.binding._ +import zio.blocks.schema.binding.RegisterOffset._ +import zio.blocks.typeid.TypeId + +/** + * Schema instances for migration types, enabling serialization/deserialization + * of [[DynamicMigration]], [[MigrationAction]], and [[DynamicSchemaExpr]]. + * + * These schemas are hand-written for Scala 2 compatibility and to ensure + * optimal serialization behavior. + */ +object MigrationSchemas { + + // ==================== DynamicSchemaExpr Schemas ==================== + + implicit lazy val literalSchema: Schema[DynamicSchemaExpr.Literal] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.Literal]( + fields = Vector( + Schema[DynamicValue].reflect.asTerm("value") + ), + typeId = TypeId.of[DynamicSchemaExpr.Literal], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.Literal] { + def usedRegisters: RegisterOffset = 1 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.Literal = + DynamicSchemaExpr.Literal(in.getObject(offset + 0).asInstanceOf[DynamicValue]) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.Literal] { + def usedRegisters: RegisterOffset = 1 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.Literal): Unit = + out.setObject(offset + 0, in.value) + } + ) + ) + ) + + implicit lazy val pathExprSchema: Schema[DynamicSchemaExpr.Path] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.Path]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("optic") + ), + typeId = TypeId.of[DynamicSchemaExpr.Path], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.Path] { + def usedRegisters: RegisterOffset = 1 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.Path = + DynamicSchemaExpr.Path(in.getObject(offset + 0).asInstanceOf[DynamicOptic]) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.Path] { + def usedRegisters: RegisterOffset = 1 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.Path): Unit = + out.setObject(offset + 0, in.optic) + } + ) + ) + ) + + implicit lazy val defaultValueExprSchema: Schema[DynamicSchemaExpr.DefaultValue.type] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.DefaultValue.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.DefaultValue.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor[DynamicSchemaExpr.DefaultValue.type](DynamicSchemaExpr.DefaultValue), + deconstructor = new ConstantDeconstructor[DynamicSchemaExpr.DefaultValue.type] + ) + ) + ) + + implicit lazy val resolvedDefaultSchema: Schema[DynamicSchemaExpr.ResolvedDefault] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.ResolvedDefault]( + fields = Vector( + Schema[DynamicValue].reflect.asTerm("value") + ), + typeId = TypeId.of[DynamicSchemaExpr.ResolvedDefault], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.ResolvedDefault] { + def usedRegisters: RegisterOffset = 1 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.ResolvedDefault = + DynamicSchemaExpr.ResolvedDefault(in.getObject(offset + 0).asInstanceOf[DynamicValue]) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.ResolvedDefault] { + def usedRegisters: RegisterOffset = 1 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.ResolvedDefault): Unit = + out.setObject(offset + 0, in.value) + } + ) + ) + ) + + implicit lazy val notExprSchema: Schema[DynamicSchemaExpr.Not] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.Not]( + fields = Vector( + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("expr") + ), + typeId = TypeId.of[DynamicSchemaExpr.Not], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.Not] { + def usedRegisters: RegisterOffset = 1 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.Not = + DynamicSchemaExpr.Not(in.getObject(offset + 0).asInstanceOf[DynamicSchemaExpr]) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.Not] { + def usedRegisters: RegisterOffset = 1 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.Not): Unit = + out.setObject(offset + 0, in.expr) + } + ) + ) + ) + + implicit lazy val logicalOperatorAndSchema: Schema[DynamicSchemaExpr.LogicalOperator.And.type] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.LogicalOperator.And.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.LogicalOperator.And.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(DynamicSchemaExpr.LogicalOperator.And), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + implicit lazy val logicalOperatorOrSchema: Schema[DynamicSchemaExpr.LogicalOperator.Or.type] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.LogicalOperator.Or.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.LogicalOperator.Or.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(DynamicSchemaExpr.LogicalOperator.Or), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + implicit lazy val logicalOperatorSchema: Schema[DynamicSchemaExpr.LogicalOperator] = new Schema( + reflect = new Reflect.Variant[Binding, DynamicSchemaExpr.LogicalOperator]( + cases = Vector( + logicalOperatorAndSchema.reflect.asTerm("And"), + logicalOperatorOrSchema.reflect.asTerm("Or") + ), + typeId = TypeId.of[DynamicSchemaExpr.LogicalOperator], + variantBinding = new Binding.Variant( + discriminator = new Discriminator[DynamicSchemaExpr.LogicalOperator] { + def discriminate(a: DynamicSchemaExpr.LogicalOperator): Int = a match { + case DynamicSchemaExpr.LogicalOperator.And => 0 + case DynamicSchemaExpr.LogicalOperator.Or => 1 + } + }, + matchers = Matchers( + new Matcher[DynamicSchemaExpr.LogicalOperator.And.type] { + def downcastOrNull(a: Any): DynamicSchemaExpr.LogicalOperator.And.type = a match { + case x: DynamicSchemaExpr.LogicalOperator.And.type => x + case _ => null.asInstanceOf[DynamicSchemaExpr.LogicalOperator.And.type] + } + }, + new Matcher[DynamicSchemaExpr.LogicalOperator.Or.type] { + def downcastOrNull(a: Any): DynamicSchemaExpr.LogicalOperator.Or.type = a match { + case x: DynamicSchemaExpr.LogicalOperator.Or.type => x + case _ => null.asInstanceOf[DynamicSchemaExpr.LogicalOperator.Or.type] + } + } + ) + ) + ) + ) + + implicit lazy val logicalExprSchema: Schema[DynamicSchemaExpr.Logical] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.Logical]( + fields = Vector( + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("left"), + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("right"), + logicalOperatorSchema.reflect.asTerm("operator") + ), + typeId = TypeId.of[DynamicSchemaExpr.Logical], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.Logical] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.Logical = + DynamicSchemaExpr.Logical( + in.getObject(offset + 0).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr.LogicalOperator] + ) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.Logical] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.Logical): Unit = { + out.setObject(offset + 0, in.left) + out.setObject(offset + 1, in.right) + out.setObject(offset + 2, in.operator) + } + } + ) + ) + ) + + implicit lazy val relationalOperatorSchema: Schema[DynamicSchemaExpr.RelationalOperator] = { + import DynamicSchemaExpr.RelationalOperator._ + + val ltSchema: Schema[LessThan.type] = new Schema( + reflect = new Reflect.Record[Binding, LessThan.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.RelationalOperator.LessThan.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(LessThan), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + val lteSchema: Schema[LessThanOrEqual.type] = new Schema( + reflect = new Reflect.Record[Binding, LessThanOrEqual.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.RelationalOperator.LessThanOrEqual.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(LessThanOrEqual), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + val gtSchema: Schema[GreaterThan.type] = new Schema( + reflect = new Reflect.Record[Binding, GreaterThan.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.RelationalOperator.GreaterThan.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(GreaterThan), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + val gteSchema: Schema[GreaterThanOrEqual.type] = new Schema( + reflect = new Reflect.Record[Binding, GreaterThanOrEqual.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(GreaterThanOrEqual), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + val eqSchema: Schema[Equal.type] = new Schema( + reflect = new Reflect.Record[Binding, Equal.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.RelationalOperator.Equal.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(Equal), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + val neqSchema: Schema[NotEqual.type] = new Schema( + reflect = new Reflect.Record[Binding, NotEqual.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.RelationalOperator.NotEqual.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(NotEqual), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + new Schema( + reflect = new Reflect.Variant[Binding, DynamicSchemaExpr.RelationalOperator]( + cases = Vector( + ltSchema.reflect.asTerm("LessThan"), + lteSchema.reflect.asTerm("LessThanOrEqual"), + gtSchema.reflect.asTerm("GreaterThan"), + gteSchema.reflect.asTerm("GreaterThanOrEqual"), + eqSchema.reflect.asTerm("Equal"), + neqSchema.reflect.asTerm("NotEqual") + ), + typeId = TypeId.of[DynamicSchemaExpr.RelationalOperator], + variantBinding = new Binding.Variant( + discriminator = new Discriminator[DynamicSchemaExpr.RelationalOperator] { + def discriminate(a: DynamicSchemaExpr.RelationalOperator): Int = a match { + case LessThan => 0 + case LessThanOrEqual => 1 + case GreaterThan => 2 + case GreaterThanOrEqual => 3 + case Equal => 4 + case NotEqual => 5 + } + }, + matchers = Matchers( + new Matcher[LessThan.type] { + def downcastOrNull(a: Any): LessThan.type = a match { + case x: LessThan.type => x + case _ => null.asInstanceOf[LessThan.type] + } + }, + new Matcher[LessThanOrEqual.type] { + def downcastOrNull(a: Any): LessThanOrEqual.type = a match { + case x: LessThanOrEqual.type => x + case _ => null.asInstanceOf[LessThanOrEqual.type] + } + }, + new Matcher[GreaterThan.type] { + def downcastOrNull(a: Any): GreaterThan.type = a match { + case x: GreaterThan.type => x + case _ => null.asInstanceOf[GreaterThan.type] + } + }, + new Matcher[GreaterThanOrEqual.type] { + def downcastOrNull(a: Any): GreaterThanOrEqual.type = a match { + case x: GreaterThanOrEqual.type => x + case _ => null.asInstanceOf[GreaterThanOrEqual.type] + } + }, + new Matcher[Equal.type] { + def downcastOrNull(a: Any): Equal.type = a match { + case x: Equal.type => x + case _ => null.asInstanceOf[Equal.type] + } + }, + new Matcher[NotEqual.type] { + def downcastOrNull(a: Any): NotEqual.type = a match { + case x: NotEqual.type => x + case _ => null.asInstanceOf[NotEqual.type] + } + } + ) + ) + ) + ) + } + + implicit lazy val relationalExprSchema: Schema[DynamicSchemaExpr.Relational] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.Relational]( + fields = Vector( + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("left"), + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("right"), + relationalOperatorSchema.reflect.asTerm("operator") + ), + typeId = TypeId.of[DynamicSchemaExpr.Relational], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.Relational] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.Relational = + DynamicSchemaExpr.Relational( + in.getObject(offset + 0).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr.RelationalOperator] + ) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.Relational] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.Relational): Unit = { + out.setObject(offset + 0, in.left) + out.setObject(offset + 1, in.right) + out.setObject(offset + 2, in.operator) + } + } + ) + ) + ) + + implicit lazy val arithmeticOperatorSchema: Schema[DynamicSchemaExpr.ArithmeticOperator] = { + import DynamicSchemaExpr.ArithmeticOperator._ + + val addSchema: Schema[Add.type] = new Schema( + reflect = new Reflect.Record[Binding, Add.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.ArithmeticOperator.Add.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(Add), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + val subSchema: Schema[Subtract.type] = new Schema( + reflect = new Reflect.Record[Binding, Subtract.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.ArithmeticOperator.Subtract.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(Subtract), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + val mulSchema: Schema[Multiply.type] = new Schema( + reflect = new Reflect.Record[Binding, Multiply.type]( + fields = Vector.empty, + typeId = TypeId.of[DynamicSchemaExpr.ArithmeticOperator.Multiply.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(Multiply), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + new Schema( + reflect = new Reflect.Variant[Binding, DynamicSchemaExpr.ArithmeticOperator]( + cases = Vector( + addSchema.reflect.asTerm("Add"), + subSchema.reflect.asTerm("Subtract"), + mulSchema.reflect.asTerm("Multiply") + ), + typeId = TypeId.of[DynamicSchemaExpr.ArithmeticOperator], + variantBinding = new Binding.Variant( + discriminator = new Discriminator[DynamicSchemaExpr.ArithmeticOperator] { + def discriminate(a: DynamicSchemaExpr.ArithmeticOperator): Int = a match { + case Add => 0 + case Subtract => 1 + case Multiply => 2 + } + }, + matchers = Matchers( + new Matcher[Add.type] { + def downcastOrNull(a: Any): Add.type = a match { + case x: Add.type => x + case _ => null.asInstanceOf[Add.type] + } + }, + new Matcher[Subtract.type] { + def downcastOrNull(a: Any): Subtract.type = a match { + case x: Subtract.type => x + case _ => null.asInstanceOf[Subtract.type] + } + }, + new Matcher[Multiply.type] { + def downcastOrNull(a: Any): Multiply.type = a match { + case x: Multiply.type => x + case _ => null.asInstanceOf[Multiply.type] + } + } + ) + ) + ) + ) + } + + implicit lazy val arithmeticExprSchema: Schema[DynamicSchemaExpr.Arithmetic] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.Arithmetic]( + fields = Vector( + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("left"), + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("right"), + arithmeticOperatorSchema.reflect.asTerm("operator") + ), + typeId = TypeId.of[DynamicSchemaExpr.Arithmetic], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.Arithmetic] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.Arithmetic = + DynamicSchemaExpr.Arithmetic( + in.getObject(offset + 0).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr.ArithmeticOperator] + ) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.Arithmetic] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.Arithmetic): Unit = { + out.setObject(offset + 0, in.left) + out.setObject(offset + 1, in.right) + out.setObject(offset + 2, in.operator) + } + } + ) + ) + ) + + implicit lazy val stringConcatSchema: Schema[DynamicSchemaExpr.StringConcat] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.StringConcat]( + fields = Vector( + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("left"), + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("right") + ), + typeId = TypeId.of[DynamicSchemaExpr.StringConcat], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.StringConcat] { + def usedRegisters: RegisterOffset = 2 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.StringConcat = + DynamicSchemaExpr.StringConcat( + in.getObject(offset + 0).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.StringConcat] { + def usedRegisters: RegisterOffset = 2 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.StringConcat): Unit = { + out.setObject(offset + 0, in.left) + out.setObject(offset + 1, in.right) + } + } + ) + ) + ) + + implicit lazy val stringLengthSchema: Schema[DynamicSchemaExpr.StringLength] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.StringLength]( + fields = Vector( + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("expr") + ), + typeId = TypeId.of[DynamicSchemaExpr.StringLength], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.StringLength] { + def usedRegisters: RegisterOffset = 1 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.StringLength = + DynamicSchemaExpr.StringLength(in.getObject(offset + 0).asInstanceOf[DynamicSchemaExpr]) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.StringLength] { + def usedRegisters: RegisterOffset = 1 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.StringLength): Unit = + out.setObject(offset + 0, in.expr) + } + ) + ) + ) + + implicit lazy val coercePrimitiveSchema: Schema[DynamicSchemaExpr.CoercePrimitive] = new Schema( + reflect = new Reflect.Record[Binding, DynamicSchemaExpr.CoercePrimitive]( + fields = Vector( + new Reflect.Deferred(() => dynamicSchemaExprSchema.reflect).asTerm("expr"), + Schema[String].reflect.asTerm("targetType") + ), + typeId = TypeId.of[DynamicSchemaExpr.CoercePrimitive], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicSchemaExpr.CoercePrimitive] { + def usedRegisters: RegisterOffset = 2 + def construct(in: Registers, offset: RegisterOffset): DynamicSchemaExpr.CoercePrimitive = + DynamicSchemaExpr.CoercePrimitive( + in.getObject(offset + 0).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 1).asInstanceOf[String] + ) + }, + deconstructor = new Deconstructor[DynamicSchemaExpr.CoercePrimitive] { + def usedRegisters: RegisterOffset = 2 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicSchemaExpr.CoercePrimitive): Unit = { + out.setObject(offset + 0, in.expr) + out.setObject(offset + 1, in.targetType) + } + } + ) + ) + ) + + /** Schema for the DynamicSchemaExpr sealed trait */ + implicit lazy val dynamicSchemaExprSchema: Schema[DynamicSchemaExpr] = new Schema( + reflect = new Reflect.Variant[Binding, DynamicSchemaExpr]( + cases = Vector( + literalSchema.reflect.asTerm("Literal"), + pathExprSchema.reflect.asTerm("Path"), + defaultValueExprSchema.reflect.asTerm("DefaultValue"), + resolvedDefaultSchema.reflect.asTerm("ResolvedDefault"), + notExprSchema.reflect.asTerm("Not"), + logicalExprSchema.reflect.asTerm("Logical"), + relationalExprSchema.reflect.asTerm("Relational"), + arithmeticExprSchema.reflect.asTerm("Arithmetic"), + stringConcatSchema.reflect.asTerm("StringConcat"), + stringLengthSchema.reflect.asTerm("StringLength"), + coercePrimitiveSchema.reflect.asTerm("CoercePrimitive") + ), + typeId = TypeId.of[DynamicSchemaExpr], + variantBinding = new Binding.Variant( + discriminator = new Discriminator[DynamicSchemaExpr] { + def discriminate(a: DynamicSchemaExpr): Int = a match { + case _: DynamicSchemaExpr.Literal => 0 + case _: DynamicSchemaExpr.Path => 1 + case DynamicSchemaExpr.DefaultValue => 2 + case _: DynamicSchemaExpr.ResolvedDefault => 3 + case _: DynamicSchemaExpr.Not => 4 + case _: DynamicSchemaExpr.Logical => 5 + case _: DynamicSchemaExpr.Relational => 6 + case _: DynamicSchemaExpr.Arithmetic => 7 + case _: DynamicSchemaExpr.StringConcat => 8 + case _: DynamicSchemaExpr.StringLength => 9 + case _: DynamicSchemaExpr.CoercePrimitive => 10 + } + }, + matchers = Matchers( + new Matcher[DynamicSchemaExpr.Literal] { + def downcastOrNull(a: Any): DynamicSchemaExpr.Literal = a match { + case x: DynamicSchemaExpr.Literal => x + case _ => null.asInstanceOf[DynamicSchemaExpr.Literal] + } + }, + new Matcher[DynamicSchemaExpr.Path] { + def downcastOrNull(a: Any): DynamicSchemaExpr.Path = a match { + case x: DynamicSchemaExpr.Path => x + case _ => null.asInstanceOf[DynamicSchemaExpr.Path] + } + }, + new Matcher[DynamicSchemaExpr.DefaultValue.type] { + def downcastOrNull(a: Any): DynamicSchemaExpr.DefaultValue.type = a match { + case x: DynamicSchemaExpr.DefaultValue.type => x + case _ => null.asInstanceOf[DynamicSchemaExpr.DefaultValue.type] + } + }, + new Matcher[DynamicSchemaExpr.ResolvedDefault] { + def downcastOrNull(a: Any): DynamicSchemaExpr.ResolvedDefault = a match { + case x: DynamicSchemaExpr.ResolvedDefault => x + case _ => null.asInstanceOf[DynamicSchemaExpr.ResolvedDefault] + } + }, + new Matcher[DynamicSchemaExpr.Not] { + def downcastOrNull(a: Any): DynamicSchemaExpr.Not = a match { + case x: DynamicSchemaExpr.Not => x + case _ => null.asInstanceOf[DynamicSchemaExpr.Not] + } + }, + new Matcher[DynamicSchemaExpr.Logical] { + def downcastOrNull(a: Any): DynamicSchemaExpr.Logical = a match { + case x: DynamicSchemaExpr.Logical => x + case _ => null.asInstanceOf[DynamicSchemaExpr.Logical] + } + }, + new Matcher[DynamicSchemaExpr.Relational] { + def downcastOrNull(a: Any): DynamicSchemaExpr.Relational = a match { + case x: DynamicSchemaExpr.Relational => x + case _ => null.asInstanceOf[DynamicSchemaExpr.Relational] + } + }, + new Matcher[DynamicSchemaExpr.Arithmetic] { + def downcastOrNull(a: Any): DynamicSchemaExpr.Arithmetic = a match { + case x: DynamicSchemaExpr.Arithmetic => x + case _ => null.asInstanceOf[DynamicSchemaExpr.Arithmetic] + } + }, + new Matcher[DynamicSchemaExpr.StringConcat] { + def downcastOrNull(a: Any): DynamicSchemaExpr.StringConcat = a match { + case x: DynamicSchemaExpr.StringConcat => x + case _ => null.asInstanceOf[DynamicSchemaExpr.StringConcat] + } + }, + new Matcher[DynamicSchemaExpr.StringLength] { + def downcastOrNull(a: Any): DynamicSchemaExpr.StringLength = a match { + case x: DynamicSchemaExpr.StringLength => x + case _ => null.asInstanceOf[DynamicSchemaExpr.StringLength] + } + }, + new Matcher[DynamicSchemaExpr.CoercePrimitive] { + def downcastOrNull(a: Any): DynamicSchemaExpr.CoercePrimitive = a match { + case x: DynamicSchemaExpr.CoercePrimitive => x + case _ => null.asInstanceOf[DynamicSchemaExpr.CoercePrimitive] + } + } + ) + ) + ) + ) + + // ==================== MigrationAction Schemas ==================== + + implicit lazy val addFieldSchema: Schema[MigrationAction.AddField] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.AddField]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + Schema[String].reflect.asTerm("name"), + dynamicSchemaExprSchema.reflect.asTerm("default") + ), + typeId = TypeId.of[MigrationAction.AddField], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.AddField] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.AddField = + MigrationAction.AddField( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[String], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.AddField] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.AddField): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.name) + out.setObject(offset + 2, in.default) + } + } + ) + ) + ) + + implicit lazy val dropFieldSchema: Schema[MigrationAction.DropField] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.DropField]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + Schema[String].reflect.asTerm("name"), + dynamicSchemaExprSchema.reflect.asTerm("defaultForReverse") + ), + typeId = TypeId.of[MigrationAction.DropField], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.DropField] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.DropField = + MigrationAction.DropField( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[String], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.DropField] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.DropField): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.name) + out.setObject(offset + 2, in.defaultForReverse) + } + } + ) + ) + ) + + implicit lazy val renameFieldSchema: Schema[MigrationAction.RenameField] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.RenameField]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + Schema[String].reflect.asTerm("from"), + Schema[String].reflect.asTerm("to") + ), + typeId = TypeId.of[MigrationAction.RenameField], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.RenameField] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.RenameField = + MigrationAction.RenameField( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[String], + in.getObject(offset + 2).asInstanceOf[String] + ) + }, + deconstructor = new Deconstructor[MigrationAction.RenameField] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.RenameField): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.from) + out.setObject(offset + 2, in.to) + } + } + ) + ) + ) + + implicit lazy val transformValueSchema: Schema[MigrationAction.TransformValue] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.TransformValue]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + dynamicSchemaExprSchema.reflect.asTerm("transform"), + dynamicSchemaExprSchema.reflect.asTerm("reverseTransform") + ), + typeId = TypeId.of[MigrationAction.TransformValue], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.TransformValue] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.TransformValue = + MigrationAction.TransformValue( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.TransformValue] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.TransformValue): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.transform) + out.setObject(offset + 2, in.reverseTransform) + } + } + ) + ) + ) + + implicit lazy val mandateSchema: Schema[MigrationAction.Mandate] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.Mandate]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + dynamicSchemaExprSchema.reflect.asTerm("default") + ), + typeId = TypeId.of[MigrationAction.Mandate], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.Mandate] { + def usedRegisters: RegisterOffset = 2 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.Mandate = + MigrationAction.Mandate( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.Mandate] { + def usedRegisters: RegisterOffset = 2 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.Mandate): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.default) + } + } + ) + ) + ) + + implicit lazy val optionalizeSchema: Schema[MigrationAction.Optionalize] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.Optionalize]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + dynamicSchemaExprSchema.reflect.asTerm("defaultForReverse") + ), + typeId = TypeId.of[MigrationAction.Optionalize], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.Optionalize] { + def usedRegisters: RegisterOffset = 2 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.Optionalize = + MigrationAction.Optionalize( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.Optionalize] { + def usedRegisters: RegisterOffset = 2 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.Optionalize): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.defaultForReverse) + } + } + ) + ) + ) + + implicit lazy val changeTypeSchema: Schema[MigrationAction.ChangeType] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.ChangeType]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + dynamicSchemaExprSchema.reflect.asTerm("converter"), + dynamicSchemaExprSchema.reflect.asTerm("reverseConverter") + ), + typeId = TypeId.of[MigrationAction.ChangeType], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.ChangeType] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.ChangeType = + MigrationAction.ChangeType( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.ChangeType] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.ChangeType): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.converter) + out.setObject(offset + 2, in.reverseConverter) + } + } + ) + ) + ) + + implicit lazy val joinSchema: Schema[MigrationAction.Join] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.Join]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + Schema[Vector[DynamicOptic]].reflect.asTerm("sourcePaths"), + dynamicSchemaExprSchema.reflect.asTerm("combiner"), + dynamicSchemaExprSchema.reflect.asTerm("splitter") + ), + typeId = TypeId.of[MigrationAction.Join], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.Join] { + def usedRegisters: RegisterOffset = 4 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.Join = + MigrationAction.Join( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[Vector[DynamicOptic]], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 3).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.Join] { + def usedRegisters: RegisterOffset = 4 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.Join): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.sourcePaths) + out.setObject(offset + 2, in.combiner) + out.setObject(offset + 3, in.splitter) + } + } + ) + ) + ) + + implicit lazy val splitSchema: Schema[MigrationAction.Split] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.Split]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + Schema[Vector[DynamicOptic]].reflect.asTerm("targetPaths"), + dynamicSchemaExprSchema.reflect.asTerm("splitter"), + dynamicSchemaExprSchema.reflect.asTerm("combiner") + ), + typeId = TypeId.of[MigrationAction.Split], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.Split] { + def usedRegisters: RegisterOffset = 4 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.Split = + MigrationAction.Split( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[Vector[DynamicOptic]], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 3).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.Split] { + def usedRegisters: RegisterOffset = 4 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.Split): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.targetPaths) + out.setObject(offset + 2, in.splitter) + out.setObject(offset + 3, in.combiner) + } + } + ) + ) + ) + + implicit lazy val renameCaseSchema: Schema[MigrationAction.RenameCase] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.RenameCase]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + Schema[String].reflect.asTerm("from"), + Schema[String].reflect.asTerm("to") + ), + typeId = TypeId.of[MigrationAction.RenameCase], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.RenameCase] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.RenameCase = + MigrationAction.RenameCase( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[String], + in.getObject(offset + 2).asInstanceOf[String] + ) + }, + deconstructor = new Deconstructor[MigrationAction.RenameCase] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.RenameCase): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.from) + out.setObject(offset + 2, in.to) + } + } + ) + ) + ) + + implicit lazy val transformCaseSchema: Schema[MigrationAction.TransformCase] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.TransformCase]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + Schema[String].reflect.asTerm("caseName"), + new Reflect.Deferred(() => Schema[Vector[MigrationAction]].reflect).asTerm("actions") + ), + typeId = TypeId.of[MigrationAction.TransformCase], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.TransformCase] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.TransformCase = + MigrationAction.TransformCase( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[String], + in.getObject(offset + 2).asInstanceOf[Vector[MigrationAction]] + ) + }, + deconstructor = new Deconstructor[MigrationAction.TransformCase] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.TransformCase): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.caseName) + out.setObject(offset + 2, in.actions) + } + } + ) + ) + ) + + implicit lazy val transformElementsSchema: Schema[MigrationAction.TransformElements] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.TransformElements]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + dynamicSchemaExprSchema.reflect.asTerm("transform"), + dynamicSchemaExprSchema.reflect.asTerm("reverseTransform") + ), + typeId = TypeId.of[MigrationAction.TransformElements], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.TransformElements] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.TransformElements = + MigrationAction.TransformElements( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.TransformElements] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.TransformElements): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.transform) + out.setObject(offset + 2, in.reverseTransform) + } + } + ) + ) + ) + + implicit lazy val transformKeysSchema: Schema[MigrationAction.TransformKeys] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.TransformKeys]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + dynamicSchemaExprSchema.reflect.asTerm("transform"), + dynamicSchemaExprSchema.reflect.asTerm("reverseTransform") + ), + typeId = TypeId.of[MigrationAction.TransformKeys], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.TransformKeys] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.TransformKeys = + MigrationAction.TransformKeys( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.TransformKeys] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.TransformKeys): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.transform) + out.setObject(offset + 2, in.reverseTransform) + } + } + ) + ) + ) + + implicit lazy val transformValuesSchema: Schema[MigrationAction.TransformValues] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.TransformValues]( + fields = Vector( + Schema[DynamicOptic].reflect.asTerm("at"), + dynamicSchemaExprSchema.reflect.asTerm("transform"), + dynamicSchemaExprSchema.reflect.asTerm("reverseTransform") + ), + typeId = TypeId.of[MigrationAction.TransformValues], + recordBinding = new Binding.Record( + constructor = new Constructor[MigrationAction.TransformValues] { + def usedRegisters: RegisterOffset = 3 + def construct(in: Registers, offset: RegisterOffset): MigrationAction.TransformValues = + MigrationAction.TransformValues( + in.getObject(offset + 0).asInstanceOf[DynamicOptic], + in.getObject(offset + 1).asInstanceOf[DynamicSchemaExpr], + in.getObject(offset + 2).asInstanceOf[DynamicSchemaExpr] + ) + }, + deconstructor = new Deconstructor[MigrationAction.TransformValues] { + def usedRegisters: RegisterOffset = 3 + def deconstruct(out: Registers, offset: RegisterOffset, in: MigrationAction.TransformValues): Unit = { + out.setObject(offset + 0, in.at) + out.setObject(offset + 1, in.transform) + out.setObject(offset + 2, in.reverseTransform) + } + } + ) + ) + ) + + implicit lazy val identityActionSchema: Schema[MigrationAction.Identity.type] = new Schema( + reflect = new Reflect.Record[Binding, MigrationAction.Identity.type]( + fields = Vector.empty, + typeId = TypeId.of[MigrationAction.Identity.type], + recordBinding = new Binding.Record( + constructor = new ConstantConstructor(MigrationAction.Identity), + deconstructor = new ConstantDeconstructor + ) + ) + ) + + /** Schema for the MigrationAction sealed trait */ + implicit lazy val migrationActionSchema: Schema[MigrationAction] = new Schema( + reflect = new Reflect.Variant[Binding, MigrationAction]( + cases = Vector( + addFieldSchema.reflect.asTerm("AddField"), + dropFieldSchema.reflect.asTerm("DropField"), + renameFieldSchema.reflect.asTerm("RenameField"), + transformValueSchema.reflect.asTerm("TransformValue"), + mandateSchema.reflect.asTerm("Mandate"), + optionalizeSchema.reflect.asTerm("Optionalize"), + changeTypeSchema.reflect.asTerm("ChangeType"), + joinSchema.reflect.asTerm("Join"), + splitSchema.reflect.asTerm("Split"), + renameCaseSchema.reflect.asTerm("RenameCase"), + transformCaseSchema.reflect.asTerm("TransformCase"), + transformElementsSchema.reflect.asTerm("TransformElements"), + transformKeysSchema.reflect.asTerm("TransformKeys"), + transformValuesSchema.reflect.asTerm("TransformValues"), + identityActionSchema.reflect.asTerm("Identity") + ), + typeId = TypeId.of[MigrationAction], + variantBinding = new Binding.Variant( + discriminator = new Discriminator[MigrationAction] { + def discriminate(a: MigrationAction): Int = a match { + case _: MigrationAction.AddField => 0 + case _: MigrationAction.DropField => 1 + case _: MigrationAction.RenameField => 2 + case _: MigrationAction.TransformValue => 3 + case _: MigrationAction.Mandate => 4 + case _: MigrationAction.Optionalize => 5 + case _: MigrationAction.ChangeType => 6 + case _: MigrationAction.Join => 7 + case _: MigrationAction.Split => 8 + case _: MigrationAction.RenameCase => 9 + case _: MigrationAction.TransformCase => 10 + case _: MigrationAction.TransformElements => 11 + case _: MigrationAction.TransformKeys => 12 + case _: MigrationAction.TransformValues => 13 + case MigrationAction.Identity => 14 + } + }, + matchers = Matchers( + new Matcher[MigrationAction.AddField] { + def downcastOrNull(a: Any): MigrationAction.AddField = a match { + case x: MigrationAction.AddField => x + case _ => null.asInstanceOf[MigrationAction.AddField] + } + }, + new Matcher[MigrationAction.DropField] { + def downcastOrNull(a: Any): MigrationAction.DropField = a match { + case x: MigrationAction.DropField => x + case _ => null.asInstanceOf[MigrationAction.DropField] + } + }, + new Matcher[MigrationAction.RenameField] { + def downcastOrNull(a: Any): MigrationAction.RenameField = a match { + case x: MigrationAction.RenameField => x + case _ => null.asInstanceOf[MigrationAction.RenameField] + } + }, + new Matcher[MigrationAction.TransformValue] { + def downcastOrNull(a: Any): MigrationAction.TransformValue = a match { + case x: MigrationAction.TransformValue => x + case _ => null.asInstanceOf[MigrationAction.TransformValue] + } + }, + new Matcher[MigrationAction.Mandate] { + def downcastOrNull(a: Any): MigrationAction.Mandate = a match { + case x: MigrationAction.Mandate => x + case _ => null.asInstanceOf[MigrationAction.Mandate] + } + }, + new Matcher[MigrationAction.Optionalize] { + def downcastOrNull(a: Any): MigrationAction.Optionalize = a match { + case x: MigrationAction.Optionalize => x + case _ => null.asInstanceOf[MigrationAction.Optionalize] + } + }, + new Matcher[MigrationAction.ChangeType] { + def downcastOrNull(a: Any): MigrationAction.ChangeType = a match { + case x: MigrationAction.ChangeType => x + case _ => null.asInstanceOf[MigrationAction.ChangeType] + } + }, + new Matcher[MigrationAction.Join] { + def downcastOrNull(a: Any): MigrationAction.Join = a match { + case x: MigrationAction.Join => x + case _ => null.asInstanceOf[MigrationAction.Join] + } + }, + new Matcher[MigrationAction.Split] { + def downcastOrNull(a: Any): MigrationAction.Split = a match { + case x: MigrationAction.Split => x + case _ => null.asInstanceOf[MigrationAction.Split] + } + }, + new Matcher[MigrationAction.RenameCase] { + def downcastOrNull(a: Any): MigrationAction.RenameCase = a match { + case x: MigrationAction.RenameCase => x + case _ => null.asInstanceOf[MigrationAction.RenameCase] + } + }, + new Matcher[MigrationAction.TransformCase] { + def downcastOrNull(a: Any): MigrationAction.TransformCase = a match { + case x: MigrationAction.TransformCase => x + case _ => null.asInstanceOf[MigrationAction.TransformCase] + } + }, + new Matcher[MigrationAction.TransformElements] { + def downcastOrNull(a: Any): MigrationAction.TransformElements = a match { + case x: MigrationAction.TransformElements => x + case _ => null.asInstanceOf[MigrationAction.TransformElements] + } + }, + new Matcher[MigrationAction.TransformKeys] { + def downcastOrNull(a: Any): MigrationAction.TransformKeys = a match { + case x: MigrationAction.TransformKeys => x + case _ => null.asInstanceOf[MigrationAction.TransformKeys] + } + }, + new Matcher[MigrationAction.TransformValues] { + def downcastOrNull(a: Any): MigrationAction.TransformValues = a match { + case x: MigrationAction.TransformValues => x + case _ => null.asInstanceOf[MigrationAction.TransformValues] + } + }, + new Matcher[MigrationAction.Identity.type] { + def downcastOrNull(a: Any): MigrationAction.Identity.type = a match { + case x: MigrationAction.Identity.type => x + case _ => null.asInstanceOf[MigrationAction.Identity.type] + } + } + ) + ) + ) + ) + + // ==================== DynamicMigration Schema ==================== + + /** Schema for DynamicMigration */ + implicit lazy val dynamicMigrationSchema: Schema[DynamicMigration] = new Schema( + reflect = new Reflect.Record[Binding, DynamicMigration]( + fields = Vector( + Schema[Vector[MigrationAction]].reflect.asTerm("actions") + ), + typeId = TypeId.of[DynamicMigration], + recordBinding = new Binding.Record( + constructor = new Constructor[DynamicMigration] { + def usedRegisters: RegisterOffset = 1 + def construct(in: Registers, offset: RegisterOffset): DynamicMigration = + new DynamicMigration(in.getObject(offset + 0).asInstanceOf[Vector[MigrationAction]]) + }, + deconstructor = new Deconstructor[DynamicMigration] { + def usedRegisters: RegisterOffset = 1 + def deconstruct(out: Registers, offset: RegisterOffset, in: DynamicMigration): Unit = + out.setObject(offset + 0, in.actions) + } + ) + ) + ) +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationValidator.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationValidator.scala new file mode 100644 index 0000000000..301b0c3e86 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationValidator.scala @@ -0,0 +1,915 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ + +/** + * Validates that migration actions will correctly transform source schema to + * target schema. + * + * The validator simulates migration actions on a structural representation of + * the source schema and verifies the result matches the target schema + * structure. + */ +object MigrationValidator { + + /** + * Maximum recursion depth for path traversal to guard against recursive + * schemas. + */ + private val MaxRecursionDepth = 64 + + /** + * A structural representation of a schema for validation purposes. + */ + sealed trait SchemaStructure { + def fieldNames: Set[String] + } + + object SchemaStructure { + + /** + * A record structure with named fields. + */ + final case class Record( + name: String, + fields: Map[String, SchemaStructure], + isOptional: Map[String, Boolean] + ) extends SchemaStructure { + def fieldNames: Set[String] = fields.keySet + } + + /** + * A variant (sum type) structure with named cases. + */ + final case class Variant( + name: String, + cases: Map[String, SchemaStructure] + ) extends SchemaStructure { + def fieldNames: Set[String] = cases.keySet + } + + /** + * A sequence structure. + */ + final case class Sequence(element: SchemaStructure) extends SchemaStructure { + def fieldNames: Set[String] = Set.empty + } + + /** + * A map structure. + */ + final case class MapType(key: SchemaStructure, value: SchemaStructure) extends SchemaStructure { + def fieldNames: Set[String] = Set.empty + } + + /** + * A primitive type. + */ + final case class Primitive(typeName: String) extends SchemaStructure { + def fieldNames: Set[String] = Set.empty + } + + /** + * An optional wrapper. + */ + final case class Optional(inner: SchemaStructure) extends SchemaStructure { + def fieldNames: Set[String] = Set.empty + } + + /** + * An unknown/dynamic structure. + */ + case object Dynamic extends SchemaStructure { + def fieldNames: Set[String] = Set.empty + } + } + + /** + * Validation result. + */ + sealed trait ValidationResult { + def isValid: Boolean + def errors: List[String] + + def ++(other: ValidationResult): ValidationResult = (this, other) match { + case (Valid, Valid) => Valid + case (Valid, e: Invalid) => e + case (e: Invalid, Valid) => e + case (Invalid(e1), Invalid(e2)) => Invalid(e1 ++ e2) + } + } + + case object Valid extends ValidationResult { + val isValid: Boolean = true + val errors: List[String] = Nil + } + + final case class Invalid(errors: List[String]) extends ValidationResult { + val isValid: Boolean = false + } + + object Invalid { + def apply(error: String): Invalid = Invalid(List(error)) + } + + /** + * Extract the structural representation from a schema. + */ + def extractStructure[A](schema: Schema[A]): SchemaStructure = + extractFromReflect(schema.reflect) + + private def extractFromReflect[F[_, _], A](reflect: Reflect[F, A]): SchemaStructure = + // Check for Option types first (Options are encoded as Variants with Some/None) + if (reflect.isOption) { + reflect.optionInnerType match { + case Some(inner) => SchemaStructure.Optional(extractFromReflect(inner)) + case None => SchemaStructure.Optional(SchemaStructure.Dynamic) + } + } else { + // Treat wrappers as transparent for validation purposes. + reflect.asWrapperUnknown match { + case Some(unknown) => + extractFromReflect(unknown.wrapper.wrapped.asInstanceOf[Reflect[F, Any]]) + .asInstanceOf[SchemaStructure] + + case None => + reflect match { + case r: Reflect.Record[F, A @unchecked] => + val fields = r.fields.map { term => + term.name -> extractFromReflect(term.value) + }.toMap + val isOptional = r.fields.map { term => + term.name -> term.value.isOption + }.toMap + SchemaStructure.Record(r.typeId.name, fields, isOptional) + + case v: Reflect.Variant[F, A @unchecked] => + val cases = v.cases.map { term => + term.name -> extractFromReflect(term.value) + }.toMap + SchemaStructure.Variant(v.typeId.name, cases) + + case s: Reflect.Sequence[F @unchecked, _, _] => + SchemaStructure.Sequence(extractFromReflect(s.element)) + + case m: Reflect.Map[F @unchecked, _, _, _] => + SchemaStructure.MapType( + extractFromReflect(m.key), + extractFromReflect(m.value) + ) + + case p: Reflect.Primitive[F, A @unchecked] => + val typeName = p.primitiveType.getClass.getSimpleName.stripSuffix("$") + SchemaStructure.Primitive(typeName) + + case _: Reflect.Dynamic[F @unchecked] => + SchemaStructure.Dynamic + + case _ => + SchemaStructure.Dynamic + } + } + } + + /** + * Validate that applying actions to source schema produces target schema + * structure. + */ + def validate[A, B]( + sourceSchema: Schema[A], + targetSchema: Schema[B], + actions: Vector[MigrationAction] + ): ValidationResult = { + val source = extractStructure(sourceSchema) + val target = extractStructure(targetSchema) + val simulatedResult = simulateActions(source, actions) + + simulatedResult match { + case Left(error) => + Invalid(error) + case Right(result) => + // Structural actions (that modify schema structure) enable full leniency + // Non-structural actions (that only transform values) still check structure compatibility + val hasStructuralActions = actions.exists(isStructuralAction) + compareStructures(result, target, DynamicOptic.root, hasStructuralActions) + } + } + + /** + * Check if an action modifies schema structure (vs just transforming values). + */ + private def isStructuralAction(action: MigrationAction): Boolean = action match { + case _: MigrationAction.AddField => true + case _: MigrationAction.DropField => true + case _: MigrationAction.RenameField => true + case _: MigrationAction.Mandate => true + case _: MigrationAction.Optionalize => true + case _: MigrationAction.Split => true + case _: MigrationAction.Join => true + case _: MigrationAction.RenameCase => true + case _ => false + } + + /** + * Simulate applying migration actions to a schema structure. + */ + private def simulateActions( + structure: SchemaStructure, + actions: Vector[MigrationAction] + ): Either[String, SchemaStructure] = + actions.foldLeft[Either[String, SchemaStructure]](Right(structure)) { (current, action) => + current.flatMap(s => simulateAction(s, action)) + } + + /** + * Simulate a single migration action. + */ + private def simulateAction( + structure: SchemaStructure, + action: MigrationAction + ): Either[String, SchemaStructure] = { + action match { + case MigrationAction.AddField(parentPath, fieldName, _) => + modifyAtPath(structure, parentPath) { + case r: SchemaStructure.Record => + if (r.fields.contains(fieldName)) { + Left(s"Cannot add field '$fieldName': already exists") + } else { + Right( + r.copy( + fields = r.fields + (fieldName -> SchemaStructure.Dynamic), + isOptional = r.isOptional + (fieldName -> false) + ) + ) + } + case other => + Left(s"Cannot add field to non-record structure: ${describeStructure(other)}") + } + + case MigrationAction.DropField(parentPath, fieldName, _) => + modifyAtPath(structure, parentPath) { + case r: SchemaStructure.Record => + if (!r.fields.contains(fieldName)) { + Left(s"Cannot drop field '$fieldName': does not exist") + } else { + Right( + r.copy( + fields = r.fields - fieldName, + isOptional = r.isOptional - fieldName + ) + ) + } + case other => + Left(s"Cannot drop field from non-record structure: ${describeStructure(other)}") + } + + case MigrationAction.RenameField(parentPath, fromName, toName) => + modifyAtPath(structure, parentPath) { + case r: SchemaStructure.Record => + if (!r.fields.contains(fromName)) { + Left(s"Cannot rename field '$fromName': does not exist") + } else if (r.fields.contains(toName)) { + Left(s"Cannot rename field to '$toName': already exists") + } else { + val fieldValue = r.fields(fromName) + val isOpt = r.isOptional.getOrElse(fromName, false) + Right( + r.copy( + fields = (r.fields - fromName) + (toName -> fieldValue), + isOptional = (r.isOptional - fromName) + (toName -> isOpt) + ) + ) + } + case other => + Left(s"Cannot rename field in non-record structure: ${describeStructure(other)}") + } + + case MigrationAction.Mandate(path, _) => + modifyAtPath(structure, path.dropLastField._1) { + case r: SchemaStructure.Record => + val fieldName = path.lastFieldName.getOrElse("") + if (!r.fields.contains(fieldName)) { + Left(s"Cannot mandate field '$fieldName': does not exist") + } else { + r.fields(fieldName) match { + case SchemaStructure.Optional(inner) => + Right( + r.copy( + fields = r.fields + (fieldName -> inner), + isOptional = r.isOptional + (fieldName -> false) + ) + ) + case _ => + Right(r.copy(isOptional = r.isOptional + (fieldName -> false))) + } + } + case other => + Left(s"Cannot mandate field in non-record structure: ${describeStructure(other)}") + } + + case MigrationAction.Optionalize(path, _) => + modifyAtPath(structure, path.dropLastField._1) { + case r: SchemaStructure.Record => + val fieldName = path.lastFieldName.getOrElse("") + if (!r.fields.contains(fieldName)) { + Left(s"Cannot optionalize field '$fieldName': does not exist") + } else { + val wrapped = SchemaStructure.Optional(r.fields(fieldName)) + Right( + r.copy( + fields = r.fields + (fieldName -> wrapped), + isOptional = r.isOptional + (fieldName -> true) + ) + ) + } + case other => + Left(s"Cannot optionalize field in non-record structure: ${describeStructure(other)}") + } + + case MigrationAction.RenameCase(path, fromName, toName) => + modifyAtPath(structure, path) { + case v: SchemaStructure.Variant => + if (!v.cases.contains(fromName)) { + Left(s"Cannot rename case '$fromName': does not exist") + } else if (v.cases.contains(toName)) { + Left(s"Cannot rename case to '$toName': already exists") + } else { + val caseValue = v.cases(fromName) + Right(v.copy(cases = (v.cases - fromName) + (toName -> caseValue))) + } + case other => + Left(s"Cannot rename case in non-variant structure: ${describeStructure(other)}") + } + + // These actions don't change structure, just values - but we validate the path + case MigrationAction.TransformValue(at, _, _) => + validatePath(structure, at).map(_ => structure) + case MigrationAction.ChangeType(at, _, _) => + validatePath(structure, at).map(_ => structure) + case _: MigrationAction.TransformCase => Right(structure) + + case MigrationAction.Join(targetPath, sourcePaths, _, _) => + // Validate all source paths share the same parent as the target (runtime requirement) + val targetParent = DynamicOptic(targetPath.nodes.dropRight(1)) + val mismatchedParents = sourcePaths.filterNot { sp => + DynamicOptic(sp.nodes.dropRight(1)).nodes == targetParent.nodes + } + if (mismatchedParents.nonEmpty) { + Left( + s"Join requires all source paths to share the same parent as target; mismatched: ${mismatchedParents.mkString(", ")}" + ) + } else { + // Join removes source fields and adds a target field + val afterDrops = sourcePaths.foldLeft[Either[String, SchemaStructure]](Right(structure)) { + (current, sourcePath) => + current.flatMap { s => + val (parentPath, fieldNameOpt) = sourcePath.dropLastField + fieldNameOpt match { + case Some(fieldName) => + modifyAtPath(s, parentPath) { + case r: SchemaStructure.Record => + if (!r.fields.contains(fieldName)) { + Left(s"Join source field '$fieldName' does not exist") + } else { + Right( + r.copy( + fields = r.fields - fieldName, + isOptional = r.isOptional - fieldName + ) + ) + } + case other => + Left(s"Cannot drop join source from non-record: ${describeStructure(other)}") + } + case None => + Left("Join source path must end in a field") + } + } + } + // Add the target field + afterDrops.flatMap { s => + val (parentPath, fieldNameOpt) = targetPath.dropLastField + fieldNameOpt match { + case Some(fieldName) => + modifyAtPath(s, parentPath) { + case r: SchemaStructure.Record => + Right( + r.copy( + fields = r.fields + (fieldName -> SchemaStructure.Dynamic), + isOptional = r.isOptional + (fieldName -> false) + ) + ) + case other => + Left(s"Cannot add join target to non-record: ${describeStructure(other)}") + } + case None => + Left("Join target path must end in a field") + } + } + } + + case MigrationAction.Split(sourcePath, targetPaths, _, _) => + // Validate all target paths share the same parent as the source (runtime requirement) + val sourceParentNodes = sourcePath.nodes.dropRight(1) + val mismatchedTargets = targetPaths.filterNot { tp => + DynamicOptic(tp.nodes.dropRight(1)).nodes == sourceParentNodes + } + if (mismatchedTargets.nonEmpty) { + Left( + s"Split requires all target paths to share the same parent as source; mismatched: ${mismatchedTargets.mkString(", ")}" + ) + } else { + // Split removes source field and adds target fields + val (sourceParentPath, sourceFieldNameOpt) = sourcePath.dropLastField + val afterDrop = sourceFieldNameOpt match { + case Some(fieldName) => + modifyAtPath(structure, sourceParentPath) { + case r: SchemaStructure.Record => + if (!r.fields.contains(fieldName)) { + Left(s"Split source field '$fieldName' does not exist") + } else { + Right( + r.copy( + fields = r.fields - fieldName, + isOptional = r.isOptional - fieldName + ) + ) + } + case other => + Left(s"Cannot drop split source from non-record: ${describeStructure(other)}") + } + case None => + Left("Split source path must end in a field") + } + // Add all target fields + afterDrop.flatMap { s => + targetPaths.foldLeft[Either[String, SchemaStructure]](Right(s)) { (current, targetPath) => + current.flatMap { struct => + val (parentPath, fieldNameOpt) = targetPath.dropLastField + fieldNameOpt match { + case Some(fieldName) => + modifyAtPath(struct, parentPath) { + case r: SchemaStructure.Record => + Right( + r.copy( + fields = r.fields + (fieldName -> SchemaStructure.Dynamic), + isOptional = r.isOptional + (fieldName -> false) + ) + ) + case other => + Left(s"Cannot add split target to non-record: ${describeStructure(other)}") + } + case None => + Left("Split target path must end in a field") + } + } + } + } + } + case MigrationAction.TransformElements(at, _, _) => + validatePathEndsWithSequence(structure, at).map(_ => structure) + case MigrationAction.TransformKeys(at, _, _) => + validatePathEndsWithMap(structure, at).map(_ => structure) + case MigrationAction.TransformValues(at, _, _) => + validatePathEndsWithMap(structure, at).map(_ => structure) + case MigrationAction.Identity => Right(structure) + } + } + + /** + * Validate that a path is navigable within the given structure. + */ + private def validatePath( + structure: SchemaStructure, + path: DynamicOptic + ): Either[String, SchemaStructure] = + if (path.nodes.isEmpty) { + Right(structure) + } else { + validatePathRecursive(structure, path.nodes.toList) + } + + /** + * Validate that a path ends at a Sequence structure. + */ + private def validatePathEndsWithSequence( + structure: SchemaStructure, + path: DynamicOptic + ): Either[String, SchemaStructure] = + validatePath(structure, path).flatMap { + case s: SchemaStructure.Sequence => Right(s) + case other => + Left(s"Cannot apply elements transform on non-sequence: ${describeStructure(other)}") + } + + /** + * Validate that a path ends at a Map structure. + */ + private def validatePathEndsWithMap( + structure: SchemaStructure, + path: DynamicOptic + ): Either[String, SchemaStructure] = + validatePath(structure, path).flatMap { + case m: SchemaStructure.MapType => Right(m) + case other => Left(s"Cannot apply map transform on non-map: ${describeStructure(other)}") + } + + private def validatePathRecursive( + structure: SchemaStructure, + path: List[DynamicOptic.Node], + depth: Int = 0 + ): Either[String, SchemaStructure] = + if (depth > MaxRecursionDepth) + Left(s"Maximum recursion depth ($MaxRecursionDepth) exceeded — possible recursive schema") + else + path match { + case Nil => Right(structure) + + case DynamicOptic.Node.Field(name) :: rest => + structure match { + case r: SchemaStructure.Record => + r.fields.get(name) match { + case Some(fieldStructure) => validatePathRecursive(fieldStructure, rest, depth + 1) + case None => Left(s"Field '$name' not found in record") + } + case other => Left(s"Cannot navigate field '$name' in non-record: ${describeStructure(other)}") + } + + case DynamicOptic.Node.Case(name) :: rest => + structure match { + case v: SchemaStructure.Variant => + v.cases.get(name) match { + case Some(caseStructure) => validatePathRecursive(caseStructure, rest, depth + 1) + case None => Left(s"Case '$name' not found in variant") + } + case other => Left(s"Cannot navigate case '$name' in non-variant: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtIndex) :: rest => + structure match { + case s: SchemaStructure.Sequence => validatePathRecursive(s.element, rest, depth + 1) + case other => + Left(s"Cannot navigate index in non-sequence: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtIndices) :: rest => + structure match { + case s: SchemaStructure.Sequence => validatePathRecursive(s.element, rest, depth + 1) + case other => + Left(s"Cannot navigate indices in non-sequence: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtMapKey) :: rest => + structure match { + case m: SchemaStructure.MapType => validatePathRecursive(m.value, rest, depth + 1) + case other => Left(s"Cannot navigate map key in non-map: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtMapKeys) :: rest => + structure match { + case m: SchemaStructure.MapType => validatePathRecursive(m.value, rest, depth + 1) + case other => Left(s"Cannot navigate map keys in non-map: ${describeStructure(other)}") + } + + case DynamicOptic.Node.Elements :: rest => + structure match { + case s: SchemaStructure.Sequence => validatePathRecursive(s.element, rest, depth + 1) + case other => + Left(s"Cannot navigate elements in non-sequence: ${describeStructure(other)}") + } + + case DynamicOptic.Node.Wrapped :: rest => + validatePathRecursive(structure, rest, depth + 1) + + case DynamicOptic.Node.MapKeys :: rest => + structure match { + case m: SchemaStructure.MapType => validatePathRecursive(m.key, rest, depth + 1) + case other => Left(s"Cannot navigate map keys in non-map: ${describeStructure(other)}") + } + + case DynamicOptic.Node.MapValues :: rest => + structure match { + case m: SchemaStructure.MapType => validatePathRecursive(m.value, rest, depth + 1) + case other => + Left(s"Cannot navigate map values in non-map: ${describeStructure(other)}") + } + } + + /** + * Modify a structure at a given path. + */ + private def modifyAtPath( + structure: SchemaStructure, + path: DynamicOptic + )( + modify: SchemaStructure => Either[String, SchemaStructure] + ): Either[String, SchemaStructure] = + if (path.nodes.isEmpty) { + modify(structure) + } else { + val nodes = path.nodes.toList + modifyAtPathRecursive(structure, nodes, modify) + } + + private def modifyAtPathRecursive( + structure: SchemaStructure, + path: List[DynamicOptic.Node], + modify: SchemaStructure => Either[String, SchemaStructure], + depth: Int = 0 + ): Either[String, SchemaStructure] = { + if (depth > MaxRecursionDepth) + Left(s"Maximum recursion depth ($MaxRecursionDepth) exceeded — possible recursive schema") + else + path match { + case Nil => + modify(structure) + + case DynamicOptic.Node.Field(name) :: rest => + structure match { + case r: SchemaStructure.Record => + r.fields.get(name) match { + case Some(fieldStructure) => + modifyAtPathRecursive(fieldStructure, rest, modify, depth + 1).map { newField => + r.copy(fields = r.fields + (name -> newField)) + } + case None => + Left(s"Field '$name' not found in record") + } + case other => + Left(s"Cannot navigate field '$name' in non-record: ${describeStructure(other)}") + } + + case DynamicOptic.Node.Case(name) :: rest => + structure match { + case v: SchemaStructure.Variant => + v.cases.get(name) match { + case Some(caseStructure) => + modifyAtPathRecursive(caseStructure, rest, modify, depth + 1).map { newCase => + v.copy(cases = v.cases + (name -> newCase)) + } + case None => + Left(s"Case '$name' not found in variant") + } + case other => + Left(s"Cannot navigate case '$name' in non-variant: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtIndex) :: rest => + structure match { + case s: SchemaStructure.Sequence => + modifyAtPathRecursive(s.element, rest, modify, depth + 1).map { newElement => + s.copy(element = newElement) + } + case other => + Left(s"Cannot navigate index in non-sequence: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtIndices) :: rest => + structure match { + case s: SchemaStructure.Sequence => + modifyAtPathRecursive(s.element, rest, modify, depth + 1).map { newElement => + s.copy(element = newElement) + } + case other => + Left(s"Cannot navigate indices in non-sequence: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtMapKey) :: rest => + structure match { + case m: SchemaStructure.MapType => + modifyAtPathRecursive(m.value, rest, modify, depth + 1).map { newValue => + m.copy(value = newValue) + } + case other => + Left(s"Cannot navigate map value in non-map: ${describeStructure(other)}") + } + + case (_: DynamicOptic.Node.AtMapKeys) :: rest => + structure match { + case m: SchemaStructure.MapType => + modifyAtPathRecursive(m.value, rest, modify, depth + 1).map { newValue => + m.copy(value = newValue) + } + case other => + Left(s"Cannot navigate map values in non-map: ${describeStructure(other)}") + } + + case DynamicOptic.Node.Elements :: rest => + structure match { + case s: SchemaStructure.Sequence => + modifyAtPathRecursive(s.element, rest, modify, depth + 1).map { newElement => + s.copy(element = newElement) + } + case other => + Left(s"Cannot navigate elements in non-sequence: ${describeStructure(other)}") + } + + case DynamicOptic.Node.Wrapped :: rest => + modifyAtPathRecursive(structure, rest, modify, depth + 1) + + case DynamicOptic.Node.MapKeys :: rest => + structure match { + case m: SchemaStructure.MapType => + modifyAtPathRecursive(m.key, rest, modify, depth + 1).map { newKey => + m.copy(key = newKey) + } + case other => + Left(s"Cannot navigate map keys in non-map: ${describeStructure(other)}") + } + + case DynamicOptic.Node.MapValues :: rest => + structure match { + case m: SchemaStructure.MapType => + modifyAtPathRecursive(m.value, rest, modify, depth + 1).map { newValue => + m.copy(value = newValue) + } + case other => + Left(s"Cannot navigate map values in non-map: ${describeStructure(other)}") + } + } + } + + /** + * Compare two structures for compatibility. + * @param lenient + * When true, don't report missing/extra fields (used when migration actions + * are provided) + */ + private def compareStructures( + actual: SchemaStructure, + expected: SchemaStructure, + path: DynamicOptic, + lenient: Boolean, + depth: Int = 0 + ): ValidationResult = + if (depth > MaxRecursionDepth) + Invalid(s"At ${path.toString}: Maximum recursion depth ($MaxRecursionDepth) exceeded — possible recursive schema") + else + (actual, expected) match { + case (SchemaStructure.Dynamic, _) => + // Dynamic matches anything + Valid + + case (_, SchemaStructure.Dynamic) => + // Dynamic matches anything + Valid + + case (a: SchemaStructure.Record, e: SchemaStructure.Record) => + val missingFields = e.fields.keySet -- a.fields.keySet + val extraFields = a.fields.keySet -- e.fields.keySet + + var result: ValidationResult = Valid + + val sameRecordType = a.name == e.name + + // Strict mode (no structural actions): report all structural differences + // Lenient mode (structural actions present): skip missing/extra field checks + if (!lenient) { + // Report structure mismatch when record types differ + if (!sameRecordType && (missingFields.nonEmpty || extraFields.nonEmpty)) { + result = result ++ Invalid(s"At ${path.toString}: Structure mismatch: expected ${e.name}, got ${a.name}") + } + if (missingFields.nonEmpty) { + result = result ++ Invalid(s"At ${path.toString}: Missing fields: ${missingFields.mkString(", ")}") + } + if (extraFields.nonEmpty) { + result = result ++ Invalid(s"At ${path.toString}: Unexpected fields: ${extraFields.mkString(", ")}") + } + } + // In lenient mode, skip missing/extra field checks entirely + + // Compare common fields - focus on structural compatibility for fields that exist in both + val commonFields = a.fields.keySet.intersect(e.fields.keySet) + commonFields.foreach { fieldName => + val fieldPath = path.field(fieldName) + val actualField = a.fields(fieldName) + val expectedField = e.fields(fieldName) + + // Check optionality for common fields + // In lenient mode with different record types, skip optionality check UNLESS the actual field is Dynamic + // (Dynamic fields are placeholders from migrations and must match target optionality) + val isDynamicField = actualField == SchemaStructure.Dynamic + if (!lenient || sameRecordType || isDynamicField) { + val actualOpt = a.isOptional.getOrElse(fieldName, false) + val expectedOpt = e.isOptional.getOrElse(fieldName, false) + if (actualOpt != expectedOpt) { + val expectedLabel = if (expectedOpt) "optional" else "mandatory" + val actualLabel = if (actualOpt) "optional" else "mandatory" + result = result ++ Invalid( + s"At ${fieldPath.toString}: Optionality mismatch: expected $expectedLabel, got $actualLabel" + ) + } + } + + result = result ++ compareStructures(actualField, expectedField, fieldPath, lenient, depth + 1) + } + + result + + case (a: SchemaStructure.Variant, e: SchemaStructure.Variant) => + val missingCases = e.cases.keySet -- a.cases.keySet + val extraCases = a.cases.keySet -- e.cases.keySet + + var result: ValidationResult = Valid + + if (!lenient) { + if (missingCases.nonEmpty) { + result = result ++ Invalid(s"At ${path.toString}: Missing cases: ${missingCases.mkString(", ")}") + } + + if (extraCases.nonEmpty) { + result = result ++ Invalid(s"At ${path.toString}: Unexpected cases: ${extraCases.mkString(", ")}") + } + } + + // Compare common cases + val commonCases = a.cases.keySet.intersect(e.cases.keySet) + commonCases.foreach { caseName => + val casePath = path.caseOf(caseName) + result = result ++ compareStructures(a.cases(caseName), e.cases(caseName), casePath, lenient, depth + 1) + } + + result + + case (a: SchemaStructure.Sequence, e: SchemaStructure.Sequence) => + compareStructures( + a.element, + e.element, + new DynamicOptic(path.nodes :+ DynamicOptic.Node.Elements), + lenient, + depth + 1 + ) + + case (a: SchemaStructure.MapType, e: SchemaStructure.MapType) => + compareStructures( + a.key, + e.key, + new DynamicOptic(path.nodes :+ DynamicOptic.Node.MapKeys), + lenient, + depth + 1 + ) ++ + compareStructures( + a.value, + e.value, + new DynamicOptic(path.nodes :+ DynamicOptic.Node.MapValues), + lenient, + depth + 1 + ) + + case (SchemaStructure.Optional(a), SchemaStructure.Optional(e)) => + compareStructures(a, e, path, lenient, depth + 1) + + // When comparing Optional wrapper with non-Optional, the optionality difference + // is already captured by the isOptional check in record comparison. + // Compare the inner structure to detect type mismatches. + case (SchemaStructure.Optional(a), e) => + compareStructures(a, e, path, lenient, depth + 1) + + case (a, SchemaStructure.Optional(e)) => + compareStructures(a, e, path, lenient, depth + 1) + + case (a: SchemaStructure.Primitive, e: SchemaStructure.Primitive) => + if (a.typeName == e.typeName) Valid + else Invalid(s"At ${path.toString}: Type mismatch: expected ${e.typeName}, got ${a.typeName}") + + case (a, e) => + Invalid( + s"At ${path.toString}: Structure mismatch: expected ${describeStructure(e)}, got ${describeStructure(a)}" + ) + } + + private def describeStructure(s: SchemaStructure): String = s match { + case r: SchemaStructure.Record => s"Record(${r.name})" + case v: SchemaStructure.Variant => s"Variant(${v.name})" + case _: SchemaStructure.Sequence => "Sequence" + case _: SchemaStructure.MapType => "Map" + case p: SchemaStructure.Primitive => p.typeName + case _: SchemaStructure.Optional => "Optional" + case SchemaStructure.Dynamic => "Dynamic" + } + + /** + * Extension methods for DynamicOptic to help with validation. + */ + implicit class DynamicOpticOps(private val optic: DynamicOptic) extends AnyVal { + def dropLastField: (DynamicOptic, Option[String]) = + if (optic.nodes.isEmpty) { + (optic, None) + } else { + optic.nodes.last match { + case DynamicOptic.Node.Field(name) => + (new DynamicOptic(optic.nodes.dropRight(1)), Some(name)) + case _ => + (optic, None) + } + } + + def lastFieldName: Option[String] = + if (optic.nodes.isEmpty) None + else + optic.nodes.last match { + case DynamicOptic.Node.Field(name) => Some(name) + case _ => None + } + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/Selector.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/Selector.scala new file mode 100644 index 0000000000..a8058b2efd --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/Selector.scala @@ -0,0 +1,100 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ + +/** + * A type-safe selector for building [[DynamicOptic]] paths from lambda + * expressions. + * + * Selectors allow compile-time validation of field paths while producing + * runtime [[DynamicOptic]] values. The macro inspects selector lambdas like + * `_.fieldName.nestedField` and converts them to path segments. + * + * @tparam A + * the source type + * @tparam B + * the selected value type + */ +trait Selector[-A, +B] { + + /** + * The dynamic optic path represented by this selector. + */ + def toOptic: DynamicOptic + + /** + * Compose this selector with another to create a deeper path. + */ + def andThen[C](that: Selector[B, C]): Selector[A, C] = + Selector.Composed(this, that) + + /** + * Alias for andThen. + */ + def >>>[C](that: Selector[B, C]): Selector[A, C] = andThen(that) +} + +object Selector { + + /** + * The identity selector that selects the root. + */ + def root[A]: Selector[A, A] = Root[A]() + + /** + * Create a selector for a single field. + */ + def field[A, B](name: String): Selector[A, B] = Field(name) + + /** + * Create a selector for sequence elements. + */ + def elements[A]: Selector[Seq[A], A] = Elements() + + /** + * Create a selector for map keys. + */ + def mapKeys[K, V]: Selector[Map[K, V], K] = MapKeys() + + /** + * Create a selector for map values. + */ + def mapValues[K, V]: Selector[Map[K, V], V] = MapValues() + + // Internal implementations + + private[migration] final case class Root[A]() extends Selector[A, A] { + def toOptic: DynamicOptic = DynamicOptic.root + } + + private[migration] final case class Field[A, B](name: String) extends Selector[A, B] { + def toOptic: DynamicOptic = DynamicOptic.root.field(name) + } + + private[migration] final case class Composed[A, B, C]( + first: Selector[A, B], + second: Selector[B, C] + ) extends Selector[A, C] { + def toOptic: DynamicOptic = { + val firstNodes = first.toOptic.nodes + val secondNodes = second.toOptic.nodes + new DynamicOptic(firstNodes ++ secondNodes) + } + } + + private[migration] final case class Elements[A]() extends Selector[Seq[A], A] { + def toOptic: DynamicOptic = DynamicOptic.elements + } + + private[migration] final case class MapKeys[K, V]() extends Selector[Map[K, V], K] { + def toOptic: DynamicOptic = DynamicOptic.mapKeys + } + + private[migration] final case class MapValues[K, V]() extends Selector[Map[K, V], V] { + def toOptic: DynamicOptic = DynamicOptic.mapValues + } + + private[migration] final case class Optional[A, B](inner: Selector[A, B]) extends Selector[Option[A], Option[B]] { + def toOptic: DynamicOptic = inner.toOptic + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/migration/package.scala b/schema/shared/src/main/scala/zio/blocks/schema/migration/package.scala new file mode 100644 index 0000000000..52050a4b54 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/package.scala @@ -0,0 +1,18 @@ +package zio.blocks.schema + +/** + * The migration package provides a pure, algebraic migration system for ZIO + * Schema. + * + * Key types: + * - [[Migration]] - A typed migration from A to B + * - [[DynamicMigration]] - An untyped, serializable migration operating on + * DynamicValue + * - [[MigrationAction]] - Individual migration actions (add/drop/rename + * field, etc.) + * - [[MigrationBuilder]] - A fluent builder for constructing migrations + * - [[DynamicSchemaExpr]] - Serializable expressions for value + * transformations + * - [[MigrationError]] - Errors that can occur during migration + */ +package object migration diff --git a/schema/shared/src/test/scala-3/zio/blocks/schema/migration/MigrationCompleteCoverageSpec.scala b/schema/shared/src/test/scala-3/zio/blocks/schema/migration/MigrationCompleteCoverageSpec.scala new file mode 100644 index 0000000000..2f975ab153 --- /dev/null +++ b/schema/shared/src/test/scala-3/zio/blocks/schema/migration/MigrationCompleteCoverageSpec.scala @@ -0,0 +1,333 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +/** + * Tests for compile-time field tracking via [[TrackedMigrationBuilder]] and + * [[MigrationComplete]]. + * + * These tests verify that: + * - Complete migrations compile and produce correct results + * - The tracked builder properly delegates to the underlying builder + * - Non-tracked methods (transformField, etc.) preserve type state + * - `buildPartial` works without completeness evidence + * - `buildChecked` skips runtime validation but enforces compile-time checks + * - The `tracked` extension and `checkedBuilder` factory work + * - Tracked and untracked builders produce identical migrations + */ + +// Case classes defined at top level for Schema derivation compatibility. + +final case class CTSrcDrop(name: String, age: Int, email: String) +object CTSrcDrop { given Schema[CTSrcDrop] = Schema.derived } + +final case class CTTgtDrop(name: String, age: Int, country: String) +object CTTgtDrop { given Schema[CTTgtDrop] = Schema.derived } + +final case class CTSrcRename(firstName: String, lastName: String, age: Int) +object CTSrcRename { given Schema[CTSrcRename] = Schema.derived } + +final case class CTTgtRename(fullName: String, age: Int) +object CTTgtRename { given Schema[CTTgtRename] = Schema.derived } + +final case class CTSrcSame(name: String, age: Int) +object CTSrcSame { given Schema[CTSrcSame] = Schema.derived } + +final case class CTTgtSame(name: String, age: Int) +object CTTgtSame { given Schema[CTTgtSame] = Schema.derived } + +final case class CTSrcExtra(name: String, age: Int, score: Double) +object CTSrcExtra { given Schema[CTSrcExtra] = Schema.derived } + +final case class CTTgtExtra(name: String, age: Int, score: Double, rank: Int) +object CTTgtExtra { given Schema[CTTgtExtra] = Schema.derived } + +final case class CTSrcMulti(name: String, middleName: String, age: Int) +object CTSrcMulti { given Schema[CTSrcMulti] = Schema.derived } + +final case class CTTgtMulti(name: String, age: Int, nickname: String) +object CTTgtMulti { given Schema[CTTgtMulti] = Schema.derived } + +final case class CTSrcTransform(name: String, value: Int) +object CTSrcTransform { given Schema[CTSrcTransform] = Schema.derived } + +final case class CTTgtTransform(name: String, value: Int) +object CTTgtTransform { given Schema[CTTgtTransform] = Schema.derived } + +final case class CTSrcRename2(name: String, age: Int, city: String) +object CTSrcRename2 { given Schema[CTSrcRename2] = Schema.derived } + +final case class CTTgtRename2(fullName: String, years: Int, city: String) +object CTTgtRename2 { given Schema[CTTgtRename2] = Schema.derived } + +object MigrationCompleteCoverageSpec extends ZIOSpecDefault { + import MigrationBuilderSyntax._ + + def spec: Spec[TestEnvironment, Any] = suite("MigrationComplete")( + completeMigrationsSuite, + trackedExtensionSuite, + nonTrackedMethodsSuite, + buildPartialSuite, + buildCheckedSuite, + endToEndSuite + ) + + private def completeMigrationsSuite = suite("complete migrations compile and work")( + test("addField + dropField covers all non-auto-mapped fields") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email) + .addField(_.country, "US") + .build + + val input = Schema[CTSrcDrop].toDynamicValue(CTSrcDrop("Alice", 30, "alice@example.com")) + val result = migration.applyDynamic(input) + assertTrue(result.isRight) + }, + test("renameField covers both source and target") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcRename, CTTgtRename] + .renameField(_.firstName, _.fullName) + .dropField(_.lastName) + .build + + assertTrue(migration.dynamicMigration.actions.nonEmpty) + }, + test("identical schemas need no explicit field handling") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcSame, CTTgtSame] + .build + + val input = Schema[CTSrcSame].toDynamicValue(CTSrcSame("Alice", 30)) + val result = migration.applyDynamic(input) + assertTrue(result.isRight) + }, + test("addField only when target has extra fields") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcExtra, CTTgtExtra] + .addField(_.rank, 0) + .build + + assertTrue(migration.dynamicMigration.actions.size == 1) + }, + test("combined drop + add for multiple field changes") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcMulti, CTTgtMulti] + .dropField(_.middleName) + .addField(_.nickname, "") + .build + + assertTrue(migration.dynamicMigration.actions.size == 2) + }, + test("multiple renames with auto-mapped common field") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcRename2, CTTgtRename2] + .renameField(_.name, _.fullName) + .renameField(_.age, _.years) + .build + + assertTrue(migration.dynamicMigration.actions.size == 2) + } + ) + + private def trackedExtensionSuite = suite("tracked extension method")( + test("builder.tracked converts untracked to tracked builder") { + val migration = Migration + .newBuilder[CTSrcDrop, CTTgtDrop] + .tracked + .dropField(_.email) + .addField(_.country, "US") + .build + + assertTrue(migration.dynamicMigration.actions.size == 2) + }, + test("checkedBuilder produces equivalent result to .tracked") { + val viaChecked = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email) + .addField(_.country, "US") + .buildPartial + + val viaTracked = Migration + .newBuilder[CTSrcDrop, CTTgtDrop] + .tracked + .dropField(_.email) + .addField(_.country, "US") + .buildPartial + + val input = Schema[CTSrcDrop].toDynamicValue(CTSrcDrop("Bob", 25, "bob@test.com")) + val result1 = viaChecked.applyDynamic(input) + val result2 = viaTracked.applyDynamic(input) + assertTrue(result1 == result2) + } + ) + + private def nonTrackedMethodsSuite = suite("non-tracked methods preserve type state")( + test("transformField works without affecting completeness") { + val twoVal = Schema[Int].toDynamicValue(2) + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcTransform, CTTgtTransform] + .transformField( + _.value, + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root.field("value")), + DynamicSchemaExpr.Literal(twoVal), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + ) + .build + + assertTrue(migration.dynamicMigration.actions.size == 1) + }, + test("transformField with reverse expression") { + val twoVal = Schema[Int].toDynamicValue(2) + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcTransform, CTTgtTransform] + .transformField( + _.value, + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root.field("value")), + DynamicSchemaExpr.Literal(twoVal), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root.field("value")), + DynamicSchemaExpr.Literal(twoVal), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + ) + .build + + assertTrue(migration.dynamicMigration.actions.size == 1) + }, + test("renameCase preserves field tracking state") { + // renameCase targets enum types; use buildPartial to skip runtime + // structural validation (which rightfully rejects enum ops on records) + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcSame, CTTgtSame] + .renameCase("OldCase", "NewCase") + .buildPartial + + assertTrue(migration.dynamicMigration.actions.size == 1) + }, + test("changeFieldType preserves field tracking state") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcTransform, CTTgtTransform] + .changeFieldType( + _.value, + DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Path(DynamicOptic.root.field("value")), + "Long" + ) + ) + .buildPartial + + assertTrue(migration.dynamicMigration.actions.size == 1) + } + ) + + private def buildPartialSuite = suite("buildPartial skips validation")( + test("buildPartial works with incomplete migration") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .buildPartial + + assertTrue(migration.dynamicMigration.actions.isEmpty) + }, + test("buildPartial works with partial field coverage") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email) + .buildPartial + + assertTrue(migration.dynamicMigration.actions.size == 1) + } + ) + + private def buildCheckedSuite = suite("buildChecked (compile-time only)")( + test("buildChecked skips runtime MigrationValidator") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email) + .addField(_.country, "US") + .buildChecked + + assertTrue(migration.dynamicMigration.actions.size == 2) + } + ) + + private def endToEndSuite = suite("end-to-end correctness")( + test("tracked and untracked builders produce identical migrations") { + val untrackedMigration = Migration + .newBuilder[CTSrcDrop, CTTgtDrop] + .dropField(DynamicOptic.root.field("email")) + .addField[String](DynamicOptic.root.field("country"), "US") + .buildPartial + + val trackedMigration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email) + .addField(_.country, "US") + .buildPartial + + val input = Schema[CTSrcDrop].toDynamicValue(CTSrcDrop("Alice", 30, "alice@test.com")) + val untrackedResult = untrackedMigration.applyDynamic(input) + val trackedResult = trackedMigration.applyDynamic(input) + + assertTrue( + untrackedResult.isRight, + trackedResult.isRight, + untrackedResult == trackedResult + ) + }, + test("addFieldExpr works with expression defaults") { + val usVal = Schema[String].toDynamicValue("US") + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email) + .addFieldExpr(_.country, DynamicSchemaExpr.Literal(usVal)) + .build + + val input = Schema[CTSrcDrop].toDynamicValue(CTSrcDrop("Bob", 25, "bob@test.com")) + val result = migration.applyDynamic(input) + assertTrue(result.isRight) + }, + test("dropField with reverse default preserves reversibility") { + val defaultEmail = Schema[String].toDynamicValue("default@test.com") + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email, DynamicSchemaExpr.Literal(defaultEmail)) + .addField(_.country, "US") + .build + + assertTrue(migration.dynamicMigration.actions.size == 2) + }, + test("migration actually transforms data correctly") { + val migration = MigrationBuilderSyntax + .checkedBuilder[CTSrcDrop, CTTgtDrop] + .dropField(_.email) + .addField(_.country, "US") + .build + + val input = Schema[CTSrcDrop].toDynamicValue(CTSrcDrop("Charlie", 40, "charlie@test.com")) + val result = migration.applyDynamic(input) + + result match { + case Right(dv) => + val fields = dv match { + case DynamicValue.Record(fieldMap) => fieldMap.map(_._1).toSet + case _ => Set.empty[String] + } + assertTrue( + fields.contains("name"), + fields.contains("age"), + fields.contains("country"), + !fields.contains("email") + ) + case Left(_) => + assertTrue(false) + } + } + ) +} diff --git a/schema/shared/src/test/scala-3/zio/blocks/schema/migration/StructuralMigrationApplySpec.scala b/schema/shared/src/test/scala-3/zio/blocks/schema/migration/StructuralMigrationApplySpec.scala new file mode 100644 index 0000000000..bfa9abc89f --- /dev/null +++ b/schema/shared/src/test/scala-3/zio/blocks/schema/migration/StructuralMigrationApplySpec.scala @@ -0,0 +1,37 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +object StructuralMigrationApplySpec extends SchemaBaseSpec { + + type Old = { val a: Int } + given Schema[Old] = Schema.structural[Old] + + final class OldValue(val a: Int) + + final case class New(a: Int) + object New { + given Schema[New] = Schema.derived + } + + def spec: Spec[TestEnvironment, Any] = suite("StructuralMigrationApplySpec")( + test("Migration.apply fails with clear message for structural source schemas") { + val migration = Migration.newBuilder[Old, New].buildPartial + val old: Old = new OldValue(1) + + migration(old) match { + case Left(err) => assertTrue(err.message.contains("structural source schema")) + case Right(_) => assertTrue(false) + } + }, + test("Migration.apply fails with clear message for structural target schemas") { + val migration = Migration.newBuilder[New, Old].buildPartial + + migration(New(1)) match { + case Left(err) => assertTrue(err.message.contains("structural target schema")) + case Right(_) => assertTrue(false) + } + } + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/json/JsonTestUtils.scala b/schema/shared/src/test/scala/zio/blocks/schema/json/JsonTestUtils.scala index 0578d85996..387dc8b4f6 100644 --- a/schema/shared/src/test/scala/zio/blocks/schema/json/JsonTestUtils.scala +++ b/schema/shared/src/test/scala/zio/blocks/schema/json/JsonTestUtils.scala @@ -12,6 +12,9 @@ import scala.collection.immutable.ArraySeq import scala.util.Try object JsonTestUtils { + private def normalizeNewlines(s: String): String = + s.replace("\r\n", "\n").replace("\r", "\n") + def roundTrip[A](value: A, expectedJson: String)(implicit schema: Schema[A]): TestResult = roundTrip(value, expectedJson, getOrDeriveCodec(schema)) @@ -47,7 +50,7 @@ object JsonTestUtils { val encodedBySchema3 = output.toByteArray val encodedBySchema4 = codec.encode(value, writerConfig) val encodedBySchema5 = codec.encodeToString(value, writerConfig).getBytes(UTF_8) - assert(new String(encodedBySchema1, UTF_8))(equalTo(expectedJson)) && + assert(normalizeNewlines(new String(encodedBySchema1, UTF_8)))(equalTo(normalizeNewlines(expectedJson))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema2))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema3))) && assert(ArraySeq.unsafeWrapArray(encodedBySchema1))(equalTo(ArraySeq.unsafeWrapArray(encodedBySchema4))) && diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/DefaultValueResolutionSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/DefaultValueResolutionSpec.scala new file mode 100644 index 0000000000..f7b289aaef --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/DefaultValueResolutionSpec.scala @@ -0,0 +1,49 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +object DefaultValueResolutionSpec extends SchemaBaseSpec { + + final case class Source(a: Int) + object Source { + implicit val schema: Schema[Source] = Schema.derived + } + + final case class TargetWithDefault(a: Int, b: String = "default") + object TargetWithDefault { + implicit val schema: Schema[TargetWithDefault] = Schema.derived + } + + final case class TargetWithoutDefault(a: Int, b: String) + object TargetWithoutDefault { + implicit val schema: Schema[TargetWithoutDefault] = Schema.derived + } + + def spec: Spec[TestEnvironment, Any] = suite("DefaultValueResolutionSpec")( + test("resolves DefaultValue for AddField at build time") { + val migration = + MigrationBuilder[Source, TargetWithDefault] + .addField(DynamicOptic.root.field("b"), DynamicSchemaExpr.DefaultValue) + .buildPartial + + val addFieldExpr = migration.actions.collectFirst { case MigrationAction.AddField(_, "b", expr) => expr } + + assertTrue( + addFieldExpr.contains( + DynamicSchemaExpr.ResolvedDefault(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ), + migration(Source(1)) == Right(TargetWithDefault(1, "default")) + ) + }, + test("throws if DefaultValue is used but no schema default exists") { + val result = scala.util.Try { + MigrationBuilder[Source, TargetWithoutDefault] + .addField(DynamicOptic.root.field("b"), DynamicSchemaExpr.DefaultValue) + .buildPartial + } + + assertTrue(result.isFailure) + } + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationCoverageSpec.scala new file mode 100644 index 0000000000..36dc3c3a6a --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationCoverageSpec.scala @@ -0,0 +1,983 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object DynamicMigrationCoverageSpec extends SchemaBaseSpec { + + private val root = DynamicOptic.root + private val intV = DynamicValue.Primitive(PrimitiveValue.Int(42)) + private val strV = DynamicValue.Primitive(PrimitiveValue.String("hello")) + private val litI = DynamicSchemaExpr.Literal(intV) + private val litS = DynamicSchemaExpr.Literal(strV) + private val litI0 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + + private val simpleRecord = DynamicValue.Record(Chunk("name" -> strV, "age" -> intV)) + private val variant = DynamicValue.Variant("Dog", DynamicValue.Record(Chunk("breed" -> strV))) + private val seq3 = DynamicValue.Sequence( + Chunk(intV, DynamicValue.Primitive(PrimitiveValue.Int(10)), DynamicValue.Primitive(PrimitiveValue.Int(20))) + ) + private val map2 = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), intV), + (DynamicValue.Primitive(PrimitiveValue.String("b")), DynamicValue.Primitive(PrimitiveValue.Int(99))) + ) + ) + + def spec: Spec[TestEnvironment, Any] = suite("DynamicMigrationCoverageSpec")( + suite("DynamicMigration basics")( + test("isEmpty on empty is true") { + assertTrue(DynamicMigration.empty.isEmpty) + }, + test("nonEmpty on empty is false") { + assertTrue(!DynamicMigration.empty.nonEmpty) + }, + test("nonEmpty on migration with action") { + val m = DynamicMigration(MigrationAction.Identity) + assertTrue(m.nonEmpty) + }, + test("++ composes migrations") { + val m1 = DynamicMigration(MigrationAction.RenameField(root, "name", "fullName")) + val m2 = DynamicMigration(MigrationAction.AddField(root, "email", litS)) + val combined = m1 ++ m2 + assertTrue(combined.actions.length == 2) + }, + test("andThen is alias for ++") { + val m1 = DynamicMigration(MigrationAction.Identity) + val m2 = DynamicMigration(MigrationAction.Identity) + assertTrue(m1.andThen(m2).actions.length == 2) + }, + test("reverse reverses actions") { + val m = DynamicMigration( + Vector( + MigrationAction.AddField(root, "x", litI), + MigrationAction.RenameField(root, "a", "b") + ) + ) + val rev = m.reverse + assertTrue( + rev.actions.length == 2 && + rev.actions(0).isInstanceOf[MigrationAction.RenameField] && + rev.actions(1).isInstanceOf[MigrationAction.DropField] + ) + } + ), + suite("Identity action")( + test("Identity returns value unchanged") { + val result = DynamicMigration(MigrationAction.Identity)(simpleRecord) + assertTrue(result == Right(simpleRecord)) + } + ), + suite("AddField action")( + test("adds field to record") { + val m = DynamicMigration(MigrationAction.AddField(root, "email", litS)) + val result = m(simpleRecord) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.length == 3 && fields.exists(_._1 == "email")) + case _ => assertTrue(false) + } + }, + test("add field already exists fails") { + val m = DynamicMigration(MigrationAction.AddField(root, "name", litS)) + assertTrue(m(simpleRecord).isLeft) + }, + test("add field on non-record fails") { + val m = DynamicMigration(MigrationAction.AddField(root, "x", litI)) + assertTrue(m(intV).isLeft) + } + ), + suite("DropField action")( + test("drops field from record") { + val m = DynamicMigration(MigrationAction.DropField(root, "age", litI)) + val result = m(simpleRecord) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.length == 1 && !fields.exists(_._1 == "age")) + case _ => assertTrue(false) + } + }, + test("drop non-existent field fails") { + val m = DynamicMigration(MigrationAction.DropField(root, "missing", litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("drop field from non-record fails") { + val m = DynamicMigration(MigrationAction.DropField(root, "x", litI)) + assertTrue(m(intV).isLeft) + } + ), + suite("RenameField action")( + test("renames field in record") { + val m = DynamicMigration(MigrationAction.RenameField(root, "name", "fullName")) + val result = m(simpleRecord) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.exists(_._1 == "fullName") && !fields.exists(_._1 == "name")) + case _ => assertTrue(false) + } + }, + test("rename non-existent field fails") { + val m = DynamicMigration(MigrationAction.RenameField(root, "missing", "new")) + assertTrue(m(simpleRecord).isLeft) + }, + test("rename to existing field fails") { + val m = DynamicMigration(MigrationAction.RenameField(root, "name", "age")) + assertTrue(m(simpleRecord).isLeft) + }, + test("rename on non-record fails") { + val m = DynamicMigration(MigrationAction.RenameField(root, "a", "b")) + assertTrue(m(intV).isLeft) + } + ), + suite("TransformValue action")( + test("transforms a field value") { + val mul = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + val m = DynamicMigration(MigrationAction.TransformValue(root.field("age"), mul, litI)) + val result = m(simpleRecord) + result match { + case Right(DynamicValue.Record(fields)) => + val ageField = fields.find(_._1 == "age").get._2 + assertTrue(ageField == DynamicValue.Primitive(PrimitiveValue.Int(4))) + case _ => assertTrue(false) + } + } + ), + suite("Mandate action")( + test("mandate unwraps Some variant") { + val rec = DynamicValue.Record(Chunk("nick" -> DynamicValue.Variant("Some", strV))) + val m = DynamicMigration(MigrationAction.Mandate(root.field("nick"), litS)) + val result = m(rec) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.find(_._1 == "nick").get._2 == strV) + case _ => assertTrue(false) + } + }, + test("mandate replaces None variant with default") { + val rec = DynamicValue.Record(Chunk("nick" -> DynamicValue.Variant("None", DynamicValue.Null))) + val m = DynamicMigration(MigrationAction.Mandate(root.field("nick"), litS)) + val result = m(rec) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.find(_._1 == "nick").get._2 == strV) + case _ => assertTrue(false) + } + }, + test("mandate on non-optional value passes through") { + val rec = DynamicValue.Record(Chunk("nick" -> strV)) + val m = DynamicMigration(MigrationAction.Mandate(root.field("nick"), litS)) + val result = m(rec) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.find(_._1 == "nick").get._2 == strV) + case _ => assertTrue(false) + } + } + ), + suite("Optionalize action")( + test("wraps value in Some variant") { + val rec = DynamicValue.Record(Chunk("nick" -> strV)) + val m = DynamicMigration(MigrationAction.Optionalize(root.field("nick"))) + val result = m(rec) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.find(_._1 == "nick").get._2 == DynamicValue.Variant("Some", strV)) + case _ => assertTrue(false) + } + } + ), + suite("ChangeType action")( + test("converts field using converter expression") { + // ChangeType evaluates the converter against the field value itself + val coerce = DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Path(DynamicOptic.root), + "Long" + ) + val m = DynamicMigration(MigrationAction.ChangeType(root.field("age"), coerce, litI)) + val result = m(simpleRecord) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.find(_._1 == "age").get._2 == DynamicValue.Primitive(PrimitiveValue.Long(42L))) + case _ => assertTrue(false) + } + } + ), + suite("RenameCase action")( + test("renames matching case") { + val m = DynamicMigration(MigrationAction.RenameCase(root, "Dog", "Hound")) + val result = m(variant) + result match { + case Right(DynamicValue.Variant(name, _)) => + assertTrue(name == "Hound") + case _ => assertTrue(false) + } + }, + test("non-matching case passes through") { + val m = DynamicMigration(MigrationAction.RenameCase(root, "Cat", "Kitty")) + val result = m(variant) + result match { + case Right(DynamicValue.Variant(name, _)) => + assertTrue(name == "Dog") + case _ => assertTrue(false) + } + }, + test("rename case on non-variant fails") { + val m = DynamicMigration(MigrationAction.RenameCase(root, "A", "B")) + assertTrue(m(simpleRecord).isLeft) + } + ), + suite("TransformCase action")( + test("transforms matching case") { + val innerActions = Vector(MigrationAction.RenameField(root, "breed", "type")) + val m = DynamicMigration(MigrationAction.TransformCase(root, "Dog", innerActions)) + val result = m(variant) + result match { + case Right(DynamicValue.Variant("Dog", DynamicValue.Record(fields))) => + assertTrue(fields.exists(_._1 == "type") && !fields.exists(_._1 == "breed")) + case _ => assertTrue(false) + } + }, + test("non-matching case passes through") { + val innerActions = Vector(MigrationAction.RenameField(root, "breed", "type")) + val m = DynamicMigration(MigrationAction.TransformCase(root, "Cat", innerActions)) + val result = m(variant) + result match { + case Right(DynamicValue.Variant("Dog", _)) => assertTrue(true) + case _ => assertTrue(false) + } + }, + test("transform case on non-variant fails") { + val m = DynamicMigration(MigrationAction.TransformCase(root, "A", Vector.empty)) + assertTrue(m(simpleRecord).isLeft) + } + ), + suite("TransformElements action")( + test("transforms all elements in sequence") { + val doubleExpr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + val m = DynamicMigration(MigrationAction.TransformElements(root, doubleExpr, litI)) + val result = m(DynamicValue.Sequence(Chunk(intV))) + result match { + case Right(DynamicValue.Sequence(elems)) => + assertTrue(elems.head == DynamicValue.Primitive(PrimitiveValue.Int(4))) + case _ => assertTrue(false) + } + }, + test("transform elements on non-sequence fails") { + val m = DynamicMigration(MigrationAction.TransformElements(root, litI, litI)) + assertTrue(m(simpleRecord).isLeft) + } + ), + suite("TransformKeys action")( + test("transforms all keys in map") { + val upper = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("X"))) + val m = DynamicMigration(MigrationAction.TransformKeys(root, upper, litS)) + val result = m(map2) + result match { + case Right(DynamicValue.Map(entries)) => + assertTrue(entries.forall(_._1 == DynamicValue.Primitive(PrimitiveValue.String("X")))) + case _ => assertTrue(false) + } + }, + test("transform keys on non-map fails") { + val m = DynamicMigration(MigrationAction.TransformKeys(root, litS, litS)) + assertTrue(m(simpleRecord).isLeft) + } + ), + suite("TransformValues action")( + test("transforms all values in map") { + val m = DynamicMigration(MigrationAction.TransformValues(root, litI0, litI)) + val result = m(map2) + result match { + case Right(DynamicValue.Map(entries)) => + assertTrue(entries.forall(_._2 == DynamicValue.Primitive(PrimitiveValue.Int(0)))) + case _ => assertTrue(false) + } + }, + test("transform values on non-map fails") { + val m = DynamicMigration(MigrationAction.TransformValues(root, litI, litI)) + assertTrue(m(simpleRecord).isLeft) + } + ), + suite("modifyAtPathRec with various node types")( + test("Field node navigates into nested record") { + val nested = DynamicValue.Record(Chunk("person" -> simpleRecord)) + val m = DynamicMigration(MigrationAction.RenameField(root.field("person"), "name", "fullName")) + val result = m(nested) + result match { + case Right(DynamicValue.Record(fields)) => + fields.find(_._1 == "person") match { + case Some((_, DynamicValue.Record(inner))) => + assertTrue(inner.exists(_._1 == "fullName")) + case _ => assertTrue(false) + } + case _ => assertTrue(false) + } + }, + test("Case node navigates into matching variant") { + val caseOptic = DynamicOptic(Vector(DynamicOptic.Node.Case("Dog"))) + val m = DynamicMigration(MigrationAction.RenameField(caseOptic, "breed", "type")) + val result = m(variant) + result match { + case Right(DynamicValue.Variant("Dog", DynamicValue.Record(fields))) => + assertTrue(fields.exists(_._1 == "type")) + case _ => assertTrue(false) + } + }, + test("Case node with non-matching variant passes through") { + val caseOptic = DynamicOptic(Vector(DynamicOptic.Node.Case("Cat"))) + val m = DynamicMigration(MigrationAction.RenameField(caseOptic, "color", "hue")) + val result = m(variant) + result match { + case Right(DynamicValue.Variant("Dog", _)) => assertTrue(true) + case _ => assertTrue(false) + } + }, + test("Case node on non-variant fails") { + val caseOptic = DynamicOptic(Vector(DynamicOptic.Node.Case("X"))) + val m = DynamicMigration(MigrationAction.AddField(caseOptic, "y", litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("AtIndex node modifies specific element") { + val atIdx = DynamicOptic(Vector(DynamicOptic.Node.AtIndex(1))) + val m = DynamicMigration(MigrationAction.TransformValue(atIdx, litI0, litI)) + val result = m(seq3) + result match { + case Right(DynamicValue.Sequence(elems)) => + assertTrue(elems(1) == DynamicValue.Primitive(PrimitiveValue.Int(0))) + case _ => assertTrue(false) + } + }, + test("AtIndex out of bounds fails") { + val atIdx = DynamicOptic(Vector(DynamicOptic.Node.AtIndex(99))) + val m = DynamicMigration(MigrationAction.TransformValue(atIdx, litI0, litI)) + assertTrue(m(seq3).isLeft) + }, + test("AtIndex on non-sequence fails") { + val atIdx = DynamicOptic(Vector(DynamicOptic.Node.AtIndex(0))) + val m = DynamicMigration(MigrationAction.TransformValue(atIdx, litI0, litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("AtMapKey node modifies specific entry") { + val keyDV = DynamicValue.Primitive(PrimitiveValue.String("a")) + val atKey = DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(keyDV))) + val m = DynamicMigration(MigrationAction.TransformValue(atKey, litI0, litI)) + val result = m(map2) + result match { + case Right(DynamicValue.Map(entries)) => + assertTrue(entries.find(_._1 == keyDV).get._2 == DynamicValue.Primitive(PrimitiveValue.Int(0))) + case _ => assertTrue(false) + } + }, + test("AtMapKey not found fails") { + val keyDV = DynamicValue.Primitive(PrimitiveValue.String("missing")) + val atKey = DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(keyDV))) + val m = DynamicMigration(MigrationAction.TransformValue(atKey, litI0, litI)) + assertTrue(m(map2).isLeft) + }, + test("AtMapKey on non-map fails") { + val keyDV = DynamicValue.Primitive(PrimitiveValue.String("a")) + val atKey = DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(keyDV))) + val m = DynamicMigration(MigrationAction.TransformValue(atKey, litI0, litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("Elements node modifies all sequence elements via path") { + val elementsOptic = DynamicOptic(Vector(DynamicOptic.Node.Elements)) + val m = DynamicMigration(MigrationAction.TransformValue(elementsOptic, litI0, litI)) + val result = m(seq3) + result match { + case Right(DynamicValue.Sequence(elems)) => + assertTrue(elems.forall(_ == DynamicValue.Primitive(PrimitiveValue.Int(0)))) + case _ => assertTrue(false) + } + }, + test("Elements on non-sequence fails") { + val elementsOptic = DynamicOptic(Vector(DynamicOptic.Node.Elements)) + val m = DynamicMigration(MigrationAction.TransformValue(elementsOptic, litI0, litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("MapKeys node modifies all map keys via path") { + val keysOptic = DynamicOptic(Vector(DynamicOptic.Node.MapKeys)) + val m = DynamicMigration(MigrationAction.TransformValue(keysOptic, litS, litS)) + val result = m(map2) + result match { + case Right(DynamicValue.Map(entries)) => + assertTrue(entries.forall(_._1 == strV)) + case _ => assertTrue(false) + } + }, + test("MapKeys on non-map fails") { + val keysOptic = DynamicOptic(Vector(DynamicOptic.Node.MapKeys)) + val m = DynamicMigration(MigrationAction.TransformValue(keysOptic, litS, litS)) + assertTrue(m(simpleRecord).isLeft) + }, + test("MapValues node modifies all map values via path") { + val valsOptic = DynamicOptic(Vector(DynamicOptic.Node.MapValues)) + val m = DynamicMigration(MigrationAction.TransformValue(valsOptic, litI0, litI)) + val result = m(map2) + result match { + case Right(DynamicValue.Map(entries)) => + assertTrue(entries.forall(_._2 == DynamicValue.Primitive(PrimitiveValue.Int(0)))) + case _ => assertTrue(false) + } + }, + test("MapValues on non-map fails") { + val valsOptic = DynamicOptic(Vector(DynamicOptic.Node.MapValues)) + val m = DynamicMigration(MigrationAction.TransformValue(valsOptic, litI0, litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("Wrapped node unwraps single-field record") { + val wrappedOptic = DynamicOptic(Vector(DynamicOptic.Node.Wrapped)) + val wrappedRecord = DynamicValue.Record(Chunk("value" -> intV)) + val m = DynamicMigration(MigrationAction.TransformValue(wrappedOptic, litI0, litI)) + val result = m(wrappedRecord) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.head._2 == DynamicValue.Primitive(PrimitiveValue.Int(0))) + case _ => assertTrue(false) + } + }, + test("Wrapped on non-single-field value fails") { + val wrappedOptic = DynamicOptic(Vector(DynamicOptic.Node.Wrapped)) + val m = DynamicMigration(MigrationAction.TransformValue(wrappedOptic, litI0, litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("AtIndices node modifies multiple elements") { + val atIndices = DynamicOptic(Vector(DynamicOptic.Node.AtIndices(Vector(0, 2)))) + val m = DynamicMigration(MigrationAction.TransformValue(atIndices, litI0, litI)) + val result = m(seq3) + result match { + case Right(DynamicValue.Sequence(elems)) => + assertTrue( + elems(0) == DynamicValue.Primitive(PrimitiveValue.Int(0)) && + elems(1) == DynamicValue.Primitive(PrimitiveValue.Int(10)) && + elems(2) == DynamicValue.Primitive(PrimitiveValue.Int(0)) + ) + case _ => assertTrue(false) + } + }, + test("AtIndices out of bounds fails") { + val atIndices = DynamicOptic(Vector(DynamicOptic.Node.AtIndices(Vector(0, 99)))) + val m = DynamicMigration(MigrationAction.TransformValue(atIndices, litI0, litI)) + assertTrue(m(seq3).isLeft) + }, + test("AtIndices on non-sequence fails") { + val atIndices = DynamicOptic(Vector(DynamicOptic.Node.AtIndices(Vector(0)))) + val m = DynamicMigration(MigrationAction.TransformValue(atIndices, litI0, litI)) + assertTrue(m(simpleRecord).isLeft) + }, + test("AtMapKeys node modifies multiple map entries") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val keyB = DynamicValue.Primitive(PrimitiveValue.String("b")) + val atKeys = DynamicOptic(Vector(DynamicOptic.Node.AtMapKeys(Vector(keyA, keyB)))) + val m = DynamicMigration(MigrationAction.TransformValue(atKeys, litI0, litI)) + val result = m(map2) + result match { + case Right(DynamicValue.Map(entries)) => + assertTrue(entries.forall(_._2 == DynamicValue.Primitive(PrimitiveValue.Int(0)))) + case _ => assertTrue(false) + } + }, + test("AtMapKeys missing key fails") { + val keyMissing = DynamicValue.Primitive(PrimitiveValue.String("missing")) + val atKeys = DynamicOptic(Vector(DynamicOptic.Node.AtMapKeys(Vector(keyMissing)))) + val m = DynamicMigration(MigrationAction.TransformValue(atKeys, litI0, litI)) + assertTrue(m(map2).isLeft) + }, + test("AtMapKeys on non-map fails") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val atKeys = DynamicOptic(Vector(DynamicOptic.Node.AtMapKeys(Vector(keyA)))) + val m = DynamicMigration(MigrationAction.TransformValue(atKeys, litI0, litI)) + assertTrue(m(simpleRecord).isLeft) + } + ), + suite("Join action")( + test("joins multiple fields into one") { + val rec = DynamicValue.Record(Chunk("a" -> strV, "b" -> DynamicValue.Primitive(PrimitiveValue.String("world")))) + val combiner = DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Path(DynamicOptic.root.field("_0")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("_1")) + ) + val m = DynamicMigration( + MigrationAction.Join( + root.field("combined"), + Vector(root.field("a"), root.field("b")), + combiner, + litS + ) + ) + val result = m(rec) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.exists(_._1 == "combined")) + case _ => assertTrue(false) + } + }, + test("join with missing source path fails") { + val rec = DynamicValue.Record(Chunk("a" -> strV)) + val m = DynamicMigration( + MigrationAction.Join( + root.field("combined"), + Vector(root.field("a"), root.field("missing")), + litS, + litS + ) + ) + assertTrue(m(rec).isLeft) + }, + test("join on non-record fails") { + val m = DynamicMigration( + MigrationAction.Join( + root.field("combined"), + Vector(root.field("a")), + litS, + litS + ) + ) + assertTrue(m(intV).isLeft) + } + ), + suite("Split action")( + test("splits field into multiple fields") { + val rec = DynamicValue.Record(Chunk("combined" -> strV)) + val splitter = DynamicSchemaExpr.Literal(DynamicValue.Sequence(Chunk(strV, strV))) + val m = DynamicMigration( + MigrationAction.Split( + root.field("combined"), + Vector(root.field("a"), root.field("b")), + splitter, + litS + ) + ) + val result = m(rec) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.exists(_._1 == "a") && fields.exists(_._1 == "b")) + case _ => assertTrue(false) + } + }, + test("split wrong count fails") { + val rec = DynamicValue.Record(Chunk("combined" -> strV)) + val splitter = DynamicSchemaExpr.Literal(DynamicValue.Sequence(Chunk(strV))) + val m = DynamicMigration( + MigrationAction.Split( + root.field("combined"), + Vector(root.field("a"), root.field("b")), + splitter, + litS + ) + ) + assertTrue(m(rec).isLeft) + }, + test("split non-sequence result fails") { + val rec = DynamicValue.Record(Chunk("combined" -> strV)) + val m = DynamicMigration( + MigrationAction.Split( + root.field("combined"), + Vector(root.field("a")), + litS, + litS + ) + ) + assertTrue(m(rec).isLeft) + }, + test("split missing source field fails") { + val rec = DynamicValue.Record(Chunk("other" -> strV)) + val m = DynamicMigration( + MigrationAction.Split( + root.field("missing"), + Vector(root.field("a")), + litS, + litS + ) + ) + assertTrue(m(rec).isLeft) + }, + test("split on non-record fails") { + val m = DynamicMigration( + MigrationAction.Split( + root.field("x"), + Vector(root.field("a")), + litS, + litS + ) + ) + assertTrue(m(intV).isLeft) + }, + test("split with invalid path fails") { + val rec = DynamicValue.Record(Chunk("x" -> strV)) + val m = DynamicMigration( + MigrationAction.Split( + DynamicOptic(Vector(DynamicOptic.Node.Elements)), + Vector(root.field("a")), + litS, + litS + ) + ) + assertTrue(m(rec).isLeft) + } + ), + suite("multiple actions sequentially")( + test("first action failure stops pipeline") { + val m = DynamicMigration( + Vector( + MigrationAction.DropField(root, "missing", litI), + MigrationAction.AddField(root, "x", litI) + ) + ) + assertTrue(m(simpleRecord).isLeft) + }, + test("all actions succeed sequentially") { + val m = DynamicMigration( + Vector( + MigrationAction.RenameField(root, "name", "fullName"), + MigrationAction.AddField(root, "email", litS) + ) + ) + val result = m(simpleRecord) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.exists(_._1 == "fullName") && fields.exists(_._1 == "email")) + case _ => assertTrue(false) + } + } + ), + suite("getDynamicValueTypeName coverage")( + test("Primitive type name") { + val m = DynamicMigration(MigrationAction.AddField(root, "x", litI)) + val result = m(intV) + assertTrue(result.isLeft && result.left.exists(_.message.contains("Primitive"))) + }, + test("Sequence type name") { + val m = DynamicMigration(MigrationAction.AddField(root, "x", litI)) + val result = m(DynamicValue.Sequence(Chunk.empty)) + assertTrue(result.isLeft && result.left.exists(_.message.contains("Sequence"))) + }, + test("Map type name") { + val m = DynamicMigration(MigrationAction.AddField(root, "x", litI)) + val result = m(DynamicValue.Map(Chunk.empty)) + assertTrue(result.isLeft && result.left.exists(_.message.contains("Map"))) + }, + test("Null type name") { + val m = DynamicMigration(MigrationAction.AddField(root, "x", litI)) + val result = m(DynamicValue.Null) + assertTrue(result.isLeft && result.left.exists(_.message.contains("Null"))) + }, + test("Variant type name") { + val m = DynamicMigration(MigrationAction.AddField(root, "x", litI)) + val result = m(variant) + assertTrue(result.isLeft && result.left.exists(_.message.contains("Variant"))) + } + ), + suite("modifyAtPathWithParentContextRec coverage")( + test("TransformValue through Wrapped node") { + val wrappedRec = DynamicValue.Record(Chunk("inner" -> intV)) + val outerRec = DynamicValue.Record(Chunk("data" -> wrappedRec)) + val optic = root.field("data").wrapped + val m = DynamicMigration(MigrationAction.TransformValue(optic, litS, litI)) + assertTrue(m(outerRec).isRight) + }, + test("TransformValue through Wrapped on multi-field record fails") { + val multiRec = DynamicValue.Record(Chunk("a" -> intV, "b" -> strV)) + val outerRec = DynamicValue.Record(Chunk("data" -> multiRec)) + val optic = root.field("data").wrapped + val m = DynamicMigration(MigrationAction.TransformValue(optic, litS, litI)) + assertTrue(m(outerRec).isLeft) + }, + test("TransformValue through Elements node") { + val seqRec = DynamicValue.Record(Chunk("items" -> seq3)) + val optic = root.field("items").elements + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(seqRec).isRight) + }, + test("TransformValue through Elements on non-sequence fails") { + val rec = DynamicValue.Record(Chunk("items" -> intV)) + val optic = root.field("items").elements + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isLeft) + }, + test("TransformValue through MapKeys node") { + val rec = DynamicValue.Record(Chunk("data" -> map2)) + val optic = root.field("data").mapKeys + val m = DynamicMigration(MigrationAction.TransformValue(optic, litS, litS)) + assertTrue(m(rec).isRight) + }, + test("TransformValue through MapKeys on non-map fails") { + val rec = DynamicValue.Record(Chunk("data" -> intV)) + val optic = root.field("data").mapKeys + val m = DynamicMigration(MigrationAction.TransformValue(optic, litS, litS)) + assertTrue(m(rec).isLeft) + }, + test("TransformValue through MapValues node") { + val rec = DynamicValue.Record(Chunk("data" -> map2)) + val optic = root.field("data").mapValues + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isRight) + }, + test("TransformValue through MapValues on non-map fails") { + val rec = DynamicValue.Record(Chunk("data" -> intV)) + val optic = root.field("data").mapValues + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isLeft) + }, + test("TransformValue through AtIndices node") { + val seqRec = DynamicValue.Record(Chunk("items" -> seq3)) + val optic = root.field("items").atIndices(0, 2) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(seqRec).isRight) + }, + test("TransformValue through AtIndices out of bounds fails") { + val seqRec = DynamicValue.Record(Chunk("items" -> seq3)) + val optic = root.field("items").atIndices(99) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(seqRec).isLeft) + }, + test("TransformValue through AtIndices on non-sequence fails") { + val rec = DynamicValue.Record(Chunk("items" -> intV)) + val optic = root.field("items").atIndices(0) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isLeft) + }, + test("TransformValue through AtMapKeys node") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val rec = DynamicValue.Record(Chunk("data" -> map2)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("data"), DynamicOptic.Node.AtMapKeys(Vector(keyA)))) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isRight) + }, + test("TransformValue through AtMapKeys missing key fails") { + val keyX = DynamicValue.Primitive(PrimitiveValue.String("missing")) + val rec = DynamicValue.Record(Chunk("data" -> map2)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("data"), DynamicOptic.Node.AtMapKeys(Vector(keyX)))) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isLeft) + }, + test("TransformValue through AtMapKeys on non-map fails") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val rec = DynamicValue.Record(Chunk("data" -> intV)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("data"), DynamicOptic.Node.AtMapKeys(Vector(keyA)))) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isLeft) + }, + test("Mandate through nested field path") { + val optVal = DynamicValue.Variant("Some", intV) + val innerRec = DynamicValue.Record(Chunk("opt" -> optVal)) + val outerRec = DynamicValue.Record(Chunk("inner" -> innerRec)) + val optic = root.field("inner").field("opt") + val m = DynamicMigration(MigrationAction.Mandate(optic, litI0)) + val result = m(outerRec) + result match { + case Right(DynamicValue.Record(fields)) => + val inner = fields.find(_._1 == "inner").get._2 + inner match { + case DynamicValue.Record(innerFields) => + assertTrue(innerFields.exists(f => f._1 == "opt" && f._2 == intV)) + case _ => assertTrue(false) + } + case _ => assertTrue(false) + } + }, + test("Mandate through Case node") { + val caseRec = DynamicValue.Record(Chunk("opt" -> DynamicValue.Variant("None", DynamicValue.Null))) + val variantV = DynamicValue.Variant("Dog", caseRec) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("Dog"))).field("opt") + val m = DynamicMigration(MigrationAction.Mandate(optic, litI0)) + assertTrue(m(variantV).isRight) + }, + test("Mandate through Case on wrong case passes through") { + val caseRec = DynamicValue.Record(Chunk("opt" -> DynamicValue.Variant("None", DynamicValue.Null))) + val variantV = DynamicValue.Variant("Cat", caseRec) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("Dog"))).field("opt") + val m = DynamicMigration(MigrationAction.Mandate(optic, litI0)) + assertTrue(m(variantV) == Right(variantV)) + }, + test("Mandate on non-variant with Case path fails") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("Dog"))).field("opt") + val m = DynamicMigration(MigrationAction.Mandate(optic, litI0)) + assertTrue(m(simpleRecord).isLeft) + }, + test("TransformValue through AtIndex node") { + val seqRec = DynamicValue.Record(Chunk("items" -> seq3)) + val optic = root.field("items").at(1) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(seqRec).isRight) + }, + test("TransformValue through AtMapKey node") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val rec = DynamicValue.Record(Chunk("data" -> map2)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("data"), DynamicOptic.Node.AtMapKey(keyA))) + val m = DynamicMigration(MigrationAction.TransformValue(optic, litI0, litI)) + assertTrue(m(rec).isRight) + } + ), + suite("modifyAtPathRec coverage - non-context actions")( + test("DropField through Elements path") { + val rec1 = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val rec2 = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val seq = DynamicValue.Sequence(Chunk(rec1, rec2)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Elements)) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + val r = m(seq) + assertTrue(r.isRight) + }, + test("DropField through MapValues path") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val rec = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val mp = DynamicValue.Map(Chunk(keyA -> rec)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.MapValues)) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + assertTrue(m(mp).isRight) + }, + test("DropField through MapKeys path") { + val keyRec = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val mp = DynamicValue.Map(Chunk(keyRec -> intV)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.MapKeys)) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + assertTrue(m(mp).isRight) + }, + test("AddField through Wrapped path") { + val inner = DynamicValue.Record(Chunk("x" -> intV)) + val wrapped = DynamicValue.Record(Chunk("value" -> inner)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Wrapped)) + val m = DynamicMigration(MigrationAction.AddField(optic, "z", litI0)) + assertTrue(m(wrapped).isRight) + }, + test("Wrapped on multi-field record fails") { + val rec = DynamicValue.Record(Chunk("a" -> intV, "b" -> strV)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Wrapped)) + val m = DynamicMigration(MigrationAction.AddField(optic, "z", litI0)) + assertTrue(m(rec).isLeft) + }, + test("DropField through AtIndices path") { + val rec1 = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val rec2 = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val seq = DynamicValue.Sequence(Chunk(rec1, rec2)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtIndices(Vector(0, 1)))) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + assertTrue(m(seq).isRight) + }, + test("DropField through AtMapKeys path") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val keyB = DynamicValue.Primitive(PrimitiveValue.String("b")) + val rec = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val mp = DynamicValue.Map(Chunk(keyA -> rec, keyB -> rec)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtMapKeys(Vector(keyA)))) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + assertTrue(m(mp).isRight) + }, + test("DropField through Case path matching") { + val inner = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val varCase = DynamicValue.Variant("Dog", inner) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("Dog"))) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + assertTrue(m(varCase).isRight) + }, + test("DropField through Case path non-matching passes through") { + val inner = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val varCase = DynamicValue.Variant("Cat", inner) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("Dog"))) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + assertTrue(m(varCase) == Right(varCase)) + }, + test("DropField through AtIndex path") { + val rec1 = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val rec2 = DynamicValue.Record(Chunk("x" -> intV, "y" -> strV)) + val seq = DynamicValue.Sequence(Chunk(rec1, rec2)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtIndex(0))) + val m = DynamicMigration(MigrationAction.DropField(optic, "y", litI)) + assertTrue(m(seq).isRight) + }, + test("AddField through AtMapKey path") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val rec = DynamicValue.Record(Chunk("x" -> intV)) + val mp = DynamicValue.Map(Chunk(keyA -> rec)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(keyA))) + val m = DynamicMigration(MigrationAction.AddField(optic, "z", litI0)) + assertTrue(m(mp).isRight) + }, + test("Elements on non-sequence fails in non-context path") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Elements)) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("MapValues on non-map fails in non-context path") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.MapValues)) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("MapKeys on non-map fails in non-context path") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.MapKeys)) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("Wrapped on non-record fails in non-context path") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Wrapped)) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("Case on non-variant fails in non-context path") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("Dog"))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("AtIndex on non-sequence fails in non-context path") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtIndex(0))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("AtIndex out of bounds in non-context path") { + val seq = DynamicValue.Sequence(Chunk(intV)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtIndex(5))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(seq).isLeft) + }, + test("AtMapKey not found in non-context path") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val keyB = DynamicValue.Primitive(PrimitiveValue.String("b")) + val mp = DynamicValue.Map(Chunk(keyA -> intV)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(keyB))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(mp).isLeft) + }, + test("AtMapKey on non-map fails in non-context path") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(keyA))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("AtIndices on non-sequence fails in non-context path") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtIndices(Vector(0)))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("AtIndices out of bounds in non-context path") { + val seq = DynamicValue.Sequence(Chunk(intV)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtIndices(Vector(5)))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(seq).isLeft) + }, + test("AtMapKeys on non-map fails in non-context path") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtMapKeys(Vector(keyA)))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(intV).isLeft) + }, + test("AtMapKeys missing key in non-context path") { + val keyA = DynamicValue.Primitive(PrimitiveValue.String("a")) + val keyB = DynamicValue.Primitive(PrimitiveValue.String("b")) + val mp = DynamicValue.Map(Chunk(keyA -> intV)) + val optic = DynamicOptic(Vector(DynamicOptic.Node.AtMapKeys(Vector(keyB)))) + val m = DynamicMigration(MigrationAction.DropField(optic, "x", litI)) + assertTrue(m(mp).isLeft) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationSpec.scala new file mode 100644 index 0000000000..56689abbe4 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationSpec.scala @@ -0,0 +1,1909 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object DynamicMigrationSpec extends SchemaBaseSpec { + def spec: Spec[TestEnvironment, Any] = suite("DynamicMigrationSpec")( + suite("Identity")( + test("empty migration returns value unchanged") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) + ) + ) + val migration = DynamicMigration.empty + assertTrue(migration(value) == Right(value)) + }, + test("Identity action returns value unchanged") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration(MigrationAction.Identity) + assertTrue(migration(value) == Right(value)) + } + ), + suite("AddField")( + test("adds a new field to a record") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val migration = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "age", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(25))) + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "age" -> DynamicValue.Primitive(PrimitiveValue.Int(25)) + ) + ) + ) + ) + }, + test("fails if field already exists") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val migration = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "name", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("Bob"))) + ) + ) + assertTrue(migration(value).isLeft) + }, + test("adds field in nested record") { + val value = DynamicValue.Record( + Chunk( + "person" -> DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root.field("person"), + "age", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(30))) + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Record( + Chunk( + "person" -> DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) + ) + ) + ) + ) + ) + ) + } + ), + suite("DropField")( + test("removes a field from a record") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) + ) + ) + val migration = DynamicMigration( + MigrationAction.DropField( + DynamicOptic.root, + "age", + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + ) + ) + }, + test("fails if field does not exist") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val migration = DynamicMigration( + MigrationAction.DropField( + DynamicOptic.root, + "missing", + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + } + ), + suite("RenameField")( + test("renames a field") { + val value = DynamicValue.Record( + Chunk( + "firstName" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val migration = DynamicMigration( + MigrationAction.RenameField( + DynamicOptic.root, + "firstName", + "name" + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + ) + ) + }, + test("fails if source field does not exist") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val migration = DynamicMigration( + MigrationAction.RenameField( + DynamicOptic.root, + "missing", + "newName" + ) + ) + assertTrue(migration(value).isLeft) + }, + test("fails if target field already exists") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "alias" -> DynamicValue.Primitive(PrimitiveValue.String("Bob")) + ) + ) + val migration = DynamicMigration( + MigrationAction.RenameField( + DynamicOptic.root, + "name", + "alias" + ) + ) + assertTrue(migration(value).isLeft) + } + ), + suite("TransformValue")( + test("transforms a field value") { + val value = DynamicValue.Record( + Chunk( + "count" -> DynamicValue.Primitive(PrimitiveValue.Int(5)) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("count"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(10))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Record( + Chunk( + "count" -> DynamicValue.Primitive(PrimitiveValue.Int(50)) + ) + ) + ) + ) + } + ), + suite("Optionalize and Mandate")( + test("Optionalize wraps value in Some") { + val value = DynamicValue.Primitive(PrimitiveValue.String("hello")) + val migration = DynamicMigration(MigrationAction.Optionalize(DynamicOptic.root)) + val result = migration(value) + assertTrue( + result == Right(DynamicValue.Variant("Some", DynamicValue.Primitive(PrimitiveValue.String("hello")))) + ) + }, + test("Mandate unwraps Some value") { + val value = DynamicValue.Variant("Some", DynamicValue.Primitive(PrimitiveValue.String("hello"))) + val migration = DynamicMigration( + MigrationAction.Mandate( + DynamicOptic.root, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + ) + val result = migration(value) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.String("hello")))) + }, + test("Mandate provides default for None") { + val value = DynamicValue.Variant("None", DynamicValue.Record(Chunk.empty)) + val migration = DynamicMigration( + MigrationAction.Mandate( + DynamicOptic.root, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + ) + val result = migration(value) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.String("default")))) + } + ), + suite("RenameCase")( + test("renames a variant case") { + val value = DynamicValue.Variant( + "OldCase", + DynamicValue.Record( + Chunk( + "data" -> DynamicValue.Primitive(PrimitiveValue.String("test")) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.RenameCase( + DynamicOptic.root, + "OldCase", + "NewCase" + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Variant( + "NewCase", + DynamicValue.Record( + Chunk( + "data" -> DynamicValue.Primitive(PrimitiveValue.String("test")) + ) + ) + ) + ) + ) + }, + test("does not modify non-matching case") { + val value = DynamicValue.Variant("OtherCase", DynamicValue.Record(Chunk.empty)) + val migration = DynamicMigration( + MigrationAction.RenameCase( + DynamicOptic.root, + "OldCase", + "NewCase" + ) + ) + val result = migration(value) + assertTrue(result == Right(value)) + } + ), + suite("TransformCase")( + test("transforms a specific case") { + val value = DynamicValue.Variant( + "MyCase", + DynamicValue.Record( + Chunk( + "oldField" -> DynamicValue.Primitive(PrimitiveValue.String("test")) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformCase( + DynamicOptic.root, + "MyCase", + Vector(MigrationAction.RenameField(DynamicOptic.root, "oldField", "newField")) + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Variant( + "MyCase", + DynamicValue.Record( + Chunk( + "newField" -> DynamicValue.Primitive(PrimitiveValue.String("test")) + ) + ) + ) + ) + ) + } + ), + suite("TransformElements")( + test("transforms all sequence elements") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.Int(1)), + DynamicValue.Primitive(PrimitiveValue.Int(2)), + DynamicValue.Primitive(PrimitiveValue.Int(3)) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformElements( + DynamicOptic.root, + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.Int(2)), + DynamicValue.Primitive(PrimitiveValue.Int(4)), + DynamicValue.Primitive(PrimitiveValue.Int(6)) + ) + ) + ) + ) + } + ), + suite("TransformKeys")( + test("transforms all map keys") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))), + (DynamicValue.Primitive(PrimitiveValue.String("b")), DynamicValue.Primitive(PrimitiveValue.Int(2))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformKeys( + DynamicOptic.root, + DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("key_"))), + DynamicSchemaExpr.Path(DynamicOptic.root) + ), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("key_a")), DynamicValue.Primitive(PrimitiveValue.Int(1))), + (DynamicValue.Primitive(PrimitiveValue.String("key_b")), DynamicValue.Primitive(PrimitiveValue.Int(2))) + ) + ) + ) + ) + } + ), + suite("TransformValues")( + test("transforms all map values") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))), + (DynamicValue.Primitive(PrimitiveValue.String("b")), DynamicValue.Primitive(PrimitiveValue.Int(2))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValues( + DynamicOptic.root, + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.ArithmeticOperator.Add + ), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(101))), + (DynamicValue.Primitive(PrimitiveValue.String("b")), DynamicValue.Primitive(PrimitiveValue.Int(102))) + ) + ) + ) + ) + } + ), + suite("Composition")( + test("++ composes migrations sequentially") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val m1 = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "age", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(25))) + ) + ) + val m2 = DynamicMigration(MigrationAction.RenameField(DynamicOptic.root, "name", "fullName")) + val combined = m1 ++ m2 + val result = combined(value) + assertTrue( + result == Right( + DynamicValue.Record( + Chunk( + "fullName" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "age" -> DynamicValue.Primitive(PrimitiveValue.Int(25)) + ) + ) + ) + ) + }, + test("andThen is an alias for ++") { + val m1 = DynamicMigration(MigrationAction.Identity) + val m2 = DynamicMigration(MigrationAction.Identity) + assertTrue(m1.andThen(m2).actions == (m1 ++ m2).actions) + } + ), + suite("Reverse")( + test("reverse of identity is identity") { + val migration = DynamicMigration.empty + assertTrue(migration.reverse.isEmpty) + }, + test("reverse of addField is dropField") { + val migration = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "age", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val reversed = migration.reverse + assertTrue(reversed.actions.head.isInstanceOf[MigrationAction.DropField]) + }, + test("reverse of renameField is renameField with swapped names") { + val migration = DynamicMigration(MigrationAction.RenameField(DynamicOptic.root, "old", "new")) + val reversed = migration.reverse + val action = reversed.actions.head.asInstanceOf[MigrationAction.RenameField] + assertTrue(action.from == "new" && action.to == "old") + }, + test("double reverse equals original") { + val migration = DynamicMigration( + Vector( + MigrationAction.AddField( + DynamicOptic.root, + "a", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ), + MigrationAction.RenameField(DynamicOptic.root, "b", "c") + ) + ) + val doubleReversed = migration.reverse.reverse + assertTrue(doubleReversed.actions.length == migration.actions.length) + } + ), + suite("Laws")( + test("identity law: identity migration returns original value") { + val value = DynamicValue.Record( + Chunk( + "x" -> DynamicValue.Primitive(PrimitiveValue.Int(42)) + ) + ) + val identity = DynamicMigration.empty + assertTrue(identity(value) == Right(value)) + }, + test("associativity: (m1 ++ m2) ++ m3 == m1 ++ (m2 ++ m3)") { + val m1 = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "a", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + val m2 = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "b", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))) + ) + ) + val m3 = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "c", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(3))) + ) + ) + val value = DynamicValue.Record(Chunk.empty) + val left = ((m1 ++ m2) ++ m3)(value) + val right = (m1 ++ (m2 ++ m3))(value) + assertTrue(left == right) + }, + test("structural reverse: reverse.reverse equals original structure") { + val migration = DynamicMigration( + Vector( + MigrationAction.AddField( + DynamicOptic.root, + "x", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ), + MigrationAction.RenameField(DynamicOptic.root, "a", "b") + ) + ) + val doubleReversed = migration.reverse.reverse + assertTrue(migration.actions.length == doubleReversed.actions.length) + } + ), + suite("Error paths")( + test("AddField fails on non-record") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "name", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("Alice"))) + ) + ) + assertTrue(migration(value).isLeft) + }, + test("DropField fails on non-record") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.DropField( + DynamicOptic.root, + "name", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + assertTrue(migration(value).isLeft) + }, + test("RenameField fails on non-record") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.RenameField(DynamicOptic.root, "old", "new") + ) + assertTrue(migration(value).isLeft) + }, + test("RenameCase fails on non-variant") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.RenameCase(DynamicOptic.root, "OldCase", "NewCase") + ) + assertTrue(migration(value).isLeft) + }, + test("TransformCase fails on non-variant") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformCase( + DynamicOptic.root, + "SomeCase", + Vector(MigrationAction.Identity) + ) + ) + assertTrue(migration(value).isLeft) + }, + test("TransformElements fails on non-sequence") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformElements( + DynamicOptic.root, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("TransformKeys fails on non-map") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformKeys( + DynamicOptic.root, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("TransformValues fails on non-map") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValues( + DynamicOptic.root, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("path navigation fails on non-existent field") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("path navigation through variant with wrong case") { + val value = DynamicValue.Variant("CaseA", DynamicValue.Primitive(PrimitiveValue.Int(1))) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.caseOf("CaseB"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))), + DynamicSchemaExpr.DefaultValue + ) + ) + // Should pass through unchanged when case doesn't match + assertTrue(migration(value) == Right(value)) + }, + test("index out of bounds error") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.Int(1)), + DynamicValue.Primitive(PrimitiveValue.Int(2)) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.at(10), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Mandate returns value unchanged for non-optional") { + val value = DynamicValue.Primitive(PrimitiveValue.Int(42)) + val migration = DynamicMigration( + MigrationAction.Mandate( + DynamicOptic.root, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + assertTrue(migration(value) == Right(value)) + }, + test("isEmpty returns true for empty migration") { + assertTrue(DynamicMigration.empty.isEmpty) + }, + test("nonEmpty returns true for non-empty migration") { + val migration = DynamicMigration(MigrationAction.Identity) + assertTrue(migration.nonEmpty) + }, + test("single action constructor works") { + val action = MigrationAction.Identity + val migration = DynamicMigration(action) + assertTrue(migration.actions == Vector(action)) + }, + test("Split fails with wrong number of values") { + val value = DynamicValue.Record( + Chunk( + "fullName" -> DynamicValue.Primitive(PrimitiveValue.String("John Doe")) + ) + ) + val migration = DynamicMigration( + MigrationAction.Split( + DynamicOptic.root.field("fullName"), + Vector( + DynamicOptic.root.field("first"), + DynamicOptic.root.field("last"), + DynamicOptic.root.field("middle") + ), + DynamicSchemaExpr.Literal( + DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.String("John")), + DynamicValue.Primitive(PrimitiveValue.String("Doe")) + ) + ) + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Split fails when source field not found") { + val value = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("John")) + ) + ) + val migration = DynamicMigration( + MigrationAction.Split( + DynamicOptic.root.field("missing"), + Vector(DynamicOptic.root.field("a"), DynamicOptic.root.field("b")), + DynamicSchemaExpr.Literal( + DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.String("x")), + DynamicValue.Primitive(PrimitiveValue.String("y")) + ) + ) + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Split fails on non-record") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.Split( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("first")), + DynamicSchemaExpr.Literal( + DynamicValue.Sequence(Chunk(DynamicValue.Primitive(PrimitiveValue.String("x")))) + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Split fails when splitter returns non-sequence") { + val value = DynamicValue.Record( + Chunk( + "fullName" -> DynamicValue.Primitive(PrimitiveValue.String("John Doe")) + ) + ) + val migration = DynamicMigration( + MigrationAction.Split( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("first")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("not a sequence"))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Join fails when source path doesn't exist") { + val value = DynamicValue.Record( + Chunk( + "first" -> DynamicValue.Primitive(PrimitiveValue.String("John")) + ) + ) + val migration = DynamicMigration( + MigrationAction.Join( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("first"), DynamicOptic.root.field("missing")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("combined"))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Join fails on non-record") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.Join( + DynamicOptic.root.field("result"), + Vector(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("combined"))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("TransformElements accumulates all errors") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.String("a")), + DynamicValue.Primitive(PrimitiveValue.String("b")) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformElements( + DynamicOptic.root, + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.ArithmeticOperator.Add + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("TransformKeys accumulates all errors") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))), + (DynamicValue.Primitive(PrimitiveValue.String("b")), DynamicValue.Primitive(PrimitiveValue.Int(2))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformKeys( + DynamicOptic.root, + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.ArithmeticOperator.Add + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("TransformValues accumulates all errors") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.Int(1)), DynamicValue.Primitive(PrimitiveValue.String("a"))), + (DynamicValue.Primitive(PrimitiveValue.Int(2)), DynamicValue.Primitive(PrimitiveValue.String("b"))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValues( + DynamicOptic.root, + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.ArithmeticOperator.Add + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("path navigation fails on field access through primitive") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root.field("nested"), + "name", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("value"))) + ) + ) + assertTrue(migration(value).isLeft) + }, + test("path navigation fails on case access through primitive") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.caseOf("Case"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("path navigation fails on index access through primitive") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.at(0), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("AtMapKey navigates to map value") { + val key = DynamicValue.Primitive(PrimitiveValue.String("a")) + val value = DynamicValue.Map( + Chunk( + (key, DynamicValue.Primitive(PrimitiveValue.Int(1))), + (DynamicValue.Primitive(PrimitiveValue.String("b")), DynamicValue.Primitive(PrimitiveValue.Int(2))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKey("a"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("AtMapKey fails when key not found") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKey("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("AtMapKey fails on non-map") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKey("a"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("AtIndices transforms multiple sequence elements") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.Int(1)), + DynamicValue.Primitive(PrimitiveValue.Int(2)), + DynamicValue.Primitive(PrimitiveValue.Int(3)) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atIndices(0, 2), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("AtIndices fails on out of bounds") { + val value = DynamicValue.Sequence( + Chunk(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atIndices(0, 10), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("AtIndices fails on non-sequence") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atIndices(0, 1), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("AtMapKeys transforms multiple map entries") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))), + (DynamicValue.Primitive(PrimitiveValue.String("b")), DynamicValue.Primitive(PrimitiveValue.Int(2))), + (DynamicValue.Primitive(PrimitiveValue.String("c")), DynamicValue.Primitive(PrimitiveValue.Int(3))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKeys("a", "c"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("AtMapKeys fails when key not found") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKeys("a", "missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("AtMapKeys fails on non-map") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKeys("a"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Elements transforms all sequence elements") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.Int(1)), + DynamicValue.Primitive(PrimitiveValue.Int(2)) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.elements, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("Elements fails on non-sequence") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.elements, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("MapKeys transforms all map keys") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapKeys, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("transformed"))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("MapKeys fails on non-map") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapKeys, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("x"))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("MapValues transforms all map values") { + val value = DynamicValue.Map( + Chunk( + (DynamicValue.Primitive(PrimitiveValue.String("a")), DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapValues, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("MapValues fails on non-map") { + val value = DynamicValue.Primitive(PrimitiveValue.String("test")) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapValues, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Wrapped navigates through single-field record") { + val value = DynamicValue.Record( + Chunk("inner" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.wrapped, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right(DynamicValue.Record(Chunk("inner" -> DynamicValue.Primitive(PrimitiveValue.Int(100))))) + ) + }, + test("Wrapped fails on non-single-field record") { + val value = DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.Int(1)), + "b" -> DynamicValue.Primitive(PrimitiveValue.Int(2)) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.wrapped, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("nested path through AtMapKey") { + val key = DynamicValue.Primitive(PrimitiveValue.String("key")) + val value = DynamicValue.Map( + Chunk( + ( + key, + DynamicValue.Record( + Chunk("name" -> DynamicValue.Primitive(PrimitiveValue.String("test"))) + ) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.RenameField(DynamicOptic.root.atKey("key"), "name", "newName") + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested path through AtIndices") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))), + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(2)))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atIndices(0, 1).field("x"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested path through AtMapKeys") { + val value = DynamicValue.Map( + Chunk( + ( + DynamicValue.Primitive(PrimitiveValue.String("a")), + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKeys("a").field("x"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested path through Elements") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.elements.field("x"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested path through MapKeys") { + val value = DynamicValue.Map( + Chunk( + ( + DynamicValue.Record(Chunk("id" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))), + DynamicValue.Primitive(PrimitiveValue.String("value")) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapKeys.field("id"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested path through MapValues") { + val value = DynamicValue.Map( + Chunk( + ( + DynamicValue.Primitive(PrimitiveValue.String("key")), + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapValues.field("x"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested path through Wrapped") { + val value = DynamicValue.Record( + Chunk( + "wrapper" -> DynamicValue.Record( + Chunk("inner" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("wrapper").wrapped, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested Case path navigation") { + val value = DynamicValue.Variant( + "Case1", + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(42)))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.caseOf("Case1").field("x"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested Field path navigation through record") { + val value = DynamicValue.Record( + Chunk( + "outer" -> DynamicValue.Record( + Chunk("inner" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("outer").field("inner"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("nested index path through sequence") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Sequence( + Chunk(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.at(0).at(0), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + } + ), + suite("Error aggregation in path navigation")( + test("AtIndices aggregates multiple errors when nested paths fail") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))), + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(2)))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atIndices(0, 1).field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isLeft) + }, + test("AtMapKeys aggregates multiple errors when nested paths fail") { + val value = DynamicValue.Map( + Chunk( + ( + DynamicValue.Primitive(PrimitiveValue.String("a")), + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))) + ), + ( + DynamicValue.Primitive(PrimitiveValue.String("b")), + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(2)))) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKeys("a", "b").field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isLeft) + }, + test("Elements aggregates multiple errors when nested paths fail") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))), + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(2)))) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.elements.field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isLeft) + }, + test("MapKeys aggregates multiple errors when nested paths fail") { + val value = DynamicValue.Map( + Chunk( + ( + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))), + DynamicValue.Primitive(PrimitiveValue.String("v1")) + ), + ( + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(2)))), + DynamicValue.Primitive(PrimitiveValue.String("v2")) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapKeys.field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isLeft) + }, + test("MapValues aggregates multiple errors when nested paths fail") { + val value = DynamicValue.Map( + Chunk( + ( + DynamicValue.Primitive(PrimitiveValue.String("k1")), + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(1)))) + ), + ( + DynamicValue.Primitive(PrimitiveValue.String("k2")), + DynamicValue.Record(Chunk("wrong" -> DynamicValue.Primitive(PrimitiveValue.Int(2)))) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.mapValues.field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isLeft) + }, + test("AtIndices with out-of-bounds index returns error") { + val value = DynamicValue.Sequence( + Chunk(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atIndices(0, 10), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isLeft) + }, + test("AtMapKeys with missing key returns error") { + val value = DynamicValue.Map( + Chunk( + ( + DynamicValue.Primitive(PrimitiveValue.String("a")), + DynamicValue.Primitive(PrimitiveValue.Int(1)) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKeys("a", "missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isLeft) + } + ), + suite("DynamicSchemaExpr additional coverage")( + test("StringConcat with non-string left operand fails") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("suffix"))) + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("StringConcat with non-string right operand fails") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.String("prefix"))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("prefix"))), + DynamicSchemaExpr.Path(DynamicOptic.root.field("missing")) + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("StringLength with non-string fails") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Path(DynamicOptic.root)), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("CoercePrimitive with non-primitive fails") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Record(Chunk("nested" -> DynamicValue.Primitive(PrimitiveValue.Int(1))))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Path(DynamicOptic.root), "String"), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Arithmetic with non-primitive left operand fails") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Record(Chunk("nested" -> DynamicValue.Primitive(PrimitiveValue.Int(1))))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.ArithmeticOperator.Add + ), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Arithmetic subtraction works") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(10))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(3))), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result == Right(DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(7)))))) + }, + test("Arithmetic multiplication works") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(5))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(3))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result == Right(DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(15)))))) + }, + test("Logical AND with true values") { + val value = DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.Boolean(true)), + "b" -> DynamicValue.Primitive(PrimitiveValue.Boolean(true)) + ) + ) + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.LogicalOperator.And + ) + assertTrue(expr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true)))) + }, + test("Logical OR with false/true values") { + val value = DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.Boolean(false)), + "b" -> DynamicValue.Primitive(PrimitiveValue.Boolean(true)) + ) + ) + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.LogicalOperator.Or + ) + assertTrue(expr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true)))) + }, + test("Logical with non-boolean left operand fails") { + val value = DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.Int(1)), + "b" -> DynamicValue.Primitive(PrimitiveValue.Boolean(true)) + ) + ) + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.LogicalOperator.And + ) + assertTrue(expr.eval(value).isLeft) + }, + test("Not with non-boolean fails") { + val value = DynamicValue.Primitive(PrimitiveValue.Int(1)) + val expr = DynamicSchemaExpr.Not(DynamicSchemaExpr.Path(DynamicOptic.root)) + assertTrue(expr.eval(value).isLeft) + }, + test("Relational operators work correctly") { + val value = DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.Int(5)), + "b" -> DynamicValue.Primitive(PrimitiveValue.Int(10)) + ) + ) + val ltExpr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.RelationalOperator.LessThan + ) + val gtExpr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.RelationalOperator.GreaterThan + ) + val leExpr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.RelationalOperator.LessThanOrEqual + ) + val geExpr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual + ) + val eqExpr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.RelationalOperator.Equal + ) + val neExpr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Path(DynamicOptic.root.field("a")), + DynamicSchemaExpr.Path(DynamicOptic.root.field("b")), + DynamicSchemaExpr.RelationalOperator.NotEqual + ) + assertTrue( + ltExpr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) && + gtExpr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(false))) && + leExpr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) && + geExpr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(false))) && + eqExpr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(false))) && + neExpr.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + }, + test("StringConcat with valid strings works") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.String("Hello"))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(" World"))) + ), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right( + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.String("Hello World")))) + ) + ) + }, + test("StringLength with valid string works") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.String("Hello"))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Path(DynamicOptic.root)), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result == Right(DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(5)))))) + }, + test("CoercePrimitive int to string works") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Path(DynamicOptic.root), "String"), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue( + result == Right(DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.String("42"))))) + ) + }, + test("ResolvedDefault returns the resolved value") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + val resolved = DynamicSchemaExpr.ResolvedDefault(DynamicValue.Primitive(PrimitiveValue.Int(999))) + assertTrue(resolved.eval(value) == Right(DynamicValue.Primitive(PrimitiveValue.Int(999)))) + } + ), + suite("Wrapped path navigation edge cases")( + test("nested Wrapped path through single-field record succeeds") { + val value = DynamicValue.Record( + Chunk( + "outer" -> DynamicValue.Record( + Chunk( + "inner" -> DynamicValue.Record( + Chunk("value" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + ) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.field("outer").wrapped.field("value"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("Wrapped on empty record fails") { + val value = DynamicValue.Record(Chunk.empty) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.wrapped, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + }, + test("Wrapped on primitive fails") { + val value = DynamicValue.Primitive(PrimitiveValue.Int(42)) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.wrapped, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + } + ), + suite("AtMapKey path navigation edge cases")( + test("AtMapKey with nested path and modify succeeds") { + val key = DynamicValue.Primitive(PrimitiveValue.String("k")) + val value = DynamicValue.Map( + Chunk( + ( + key, + DynamicValue.Record( + Chunk( + "nested" -> DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + ) + ) + ) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKey("k").field("nested").field("x"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(value) + assertTrue(result.isRight) + }, + test("AtMapKey on sequence fails") { + val value = DynamicValue.Sequence(Chunk(DynamicValue.Primitive(PrimitiveValue.Int(1)))) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.atKey("k"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + } + ), + suite("Case path navigation edge cases")( + test("Case navigation with mismatched case name returns unchanged value") { + val value = DynamicValue.Variant( + "Case1", + DynamicValue.Record(Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(42)))) + ) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.caseOf("Case2").field("x"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + // When case doesn't match, value is returned unchanged (no-op) + assertTrue(migration(value) == Right(value)) + }, + test("Case navigation on non-variant fails") { + val value = DynamicValue.Primitive(PrimitiveValue.Int(42)) + val migration = DynamicMigration( + MigrationAction.TransformValue( + DynamicOptic.root.caseOf("Case1"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + assertTrue(migration(value).isLeft) + } + ), + suite("navigateDynamicValue coverage")( + test("navigateDynamicValue through Field returns correct value") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.field("x")).eval(value) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.Int(42)))) + }, + test("navigateDynamicValue through missing Field fails") { + val value = DynamicValue.Record( + Chunk("x" -> DynamicValue.Primitive(PrimitiveValue.Int(42))) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.field("missing")).eval(value) + assertTrue(result.isLeft) + }, + test("navigateDynamicValue through Case returns correct value") { + val value = DynamicValue.Variant( + "Case1", + DynamicValue.Primitive(PrimitiveValue.Int(42)) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.caseOf("Case1")).eval(value) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.Int(42)))) + }, + test("navigateDynamicValue through mismatched Case returns None") { + val value = DynamicValue.Variant( + "Case1", + DynamicValue.Primitive(PrimitiveValue.Int(42)) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.caseOf("Case2")).eval(value) + assertTrue(result.isLeft) + }, + test("navigateDynamicValue through AtIndex returns correct element") { + val value = DynamicValue.Sequence( + Chunk( + DynamicValue.Primitive(PrimitiveValue.Int(1)), + DynamicValue.Primitive(PrimitiveValue.Int(2)), + DynamicValue.Primitive(PrimitiveValue.Int(3)) + ) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.at(1)).eval(value) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.Int(2)))) + }, + test("navigateDynamicValue through out-of-bounds AtIndex fails") { + val value = DynamicValue.Sequence( + Chunk(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.at(10)).eval(value) + assertTrue(result.isLeft) + }, + test("navigateDynamicValue through AtMapKey returns correct value") { + val key = DynamicValue.Primitive(PrimitiveValue.String("k")) + val value = DynamicValue.Map( + Chunk((key, DynamicValue.Primitive(PrimitiveValue.Int(42)))) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.atKey("k")).eval(value) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.Int(42)))) + }, + test("navigateDynamicValue through missing AtMapKey fails") { + val key = DynamicValue.Primitive(PrimitiveValue.String("k")) + val value = DynamicValue.Map( + Chunk((key, DynamicValue.Primitive(PrimitiveValue.Int(42)))) + ) + val result = DynamicSchemaExpr.Path(DynamicOptic.root.atKey("missing")).eval(value) + assertTrue(result.isLeft) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprCoverageSpec.scala new file mode 100644 index 0000000000..df247c887f --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprCoverageSpec.scala @@ -0,0 +1,1436 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object DynamicSchemaExprCoverageSpec extends SchemaBaseSpec { + + private val intVal = DynamicValue.Primitive(PrimitiveValue.Int(10)) + private val strVal = DynamicValue.Primitive(PrimitiveValue.String("hello")) + private val boolT = DynamicValue.Primitive(PrimitiveValue.Boolean(true)) + private val boolF = DynamicValue.Primitive(PrimitiveValue.Boolean(false)) + private val longVal = DynamicValue.Primitive(PrimitiveValue.Long(100L)) + private val dblVal = DynamicValue.Primitive(PrimitiveValue.Double(3.14)) + private val fltVal = DynamicValue.Primitive(PrimitiveValue.Float(2.5f)) + private val record = DynamicValue.Record(Chunk("name" -> strVal, "age" -> intVal)) + + def spec: Spec[TestEnvironment, Any] = suite("DynamicSchemaExprCoverageSpec")( + suite("Not error paths")( + test("Not fails on record") { + val expr = DynamicSchemaExpr.Not(DynamicSchemaExpr.Literal(record)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Not fails on Variant") { + val variant = DynamicValue.Variant("A", intVal) + val expr = DynamicSchemaExpr.Not(DynamicSchemaExpr.Literal(variant)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Not fails on Sequence") { + val seq = DynamicValue.Sequence(Chunk(intVal)) + val expr = DynamicSchemaExpr.Not(DynamicSchemaExpr.Literal(seq)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Not fails on Map") { + val map = DynamicValue.Map(Chunk(strVal -> intVal)) + val expr = DynamicSchemaExpr.Not(DynamicSchemaExpr.Literal(map)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Not fails on Null") { + val expr = DynamicSchemaExpr.Not(DynamicSchemaExpr.Literal(DynamicValue.Null)) + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("Logical error paths")( + test("Logical fails when left is non-boolean") { + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(boolT), + DynamicSchemaExpr.LogicalOperator.And + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Logical fails when right is non-boolean") { + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Literal(boolT), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.LogicalOperator.Or + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Logical fails when both are non-boolean") { + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(strVal), + DynamicSchemaExpr.LogicalOperator.And + ) + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("Relational additional operators")( + test("LessThanOrEqual: 10 <= 10 = true") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.RelationalOperator.LessThanOrEqual + ) + assertTrue(expr.eval(intVal) == Right(boolT)) + }, + test("LessThanOrEqual: 10 <= 3 = false") { + val three = DynamicValue.Primitive(PrimitiveValue.Int(3)) + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(three), + DynamicSchemaExpr.RelationalOperator.LessThanOrEqual + ) + assertTrue(expr.eval(intVal) == Right(boolF)) + }, + test("GreaterThan: 10 > 3 = true") { + val three = DynamicValue.Primitive(PrimitiveValue.Int(3)) + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(three), + DynamicSchemaExpr.RelationalOperator.GreaterThan + ) + assertTrue(expr.eval(intVal) == Right(boolT)) + }, + test("GreaterThanOrEqual: 10 >= 10 = true") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual + ) + assertTrue(expr.eval(intVal) == Right(boolT)) + }, + test("GreaterThanOrEqual: 3 >= 10 = false") { + val three = DynamicValue.Primitive(PrimitiveValue.Int(3)) + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(three), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual + ) + assertTrue(expr.eval(intVal) == Right(boolF)) + }, + test("LessThan: 10 < 3 = false") { + val three = DynamicValue.Primitive(PrimitiveValue.Int(3)) + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(three), + DynamicSchemaExpr.RelationalOperator.LessThan + ) + assertTrue(expr.eval(intVal) == Right(boolF)) + }, + test("Equal: different values = false") { + val three = DynamicValue.Primitive(PrimitiveValue.Int(3)) + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(three), + DynamicSchemaExpr.RelationalOperator.Equal + ) + assertTrue(expr.eval(intVal) == Right(boolF)) + }, + test("NotEqual: same values = false") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.RelationalOperator.NotEqual + ) + assertTrue(expr.eval(intVal) == Right(boolF)) + } + ), + suite("Arithmetic additional types")( + test("Float Add") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(fltVal), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Float(1.5f))), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(4.0f)))) + }, + test("Short Subtract") { + val s1 = DynamicValue.Primitive(PrimitiveValue.Short(30.toShort)) + val s2 = DynamicValue.Primitive(PrimitiveValue.Short(10.toShort)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(s1), + DynamicSchemaExpr.Literal(s2), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Short(20.toShort)))) + }, + test("Short Multiply") { + val s1 = DynamicValue.Primitive(PrimitiveValue.Short(3.toShort)) + val s2 = DynamicValue.Primitive(PrimitiveValue.Short(4.toShort)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(s1), + DynamicSchemaExpr.Literal(s2), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Short(12.toShort)))) + }, + test("Byte Add") { + val b1 = DynamicValue.Primitive(PrimitiveValue.Byte(10.toByte)) + val b2 = DynamicValue.Primitive(PrimitiveValue.Byte(20.toByte)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(b1), + DynamicSchemaExpr.Literal(b2), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Byte(30.toByte)))) + }, + test("Byte Subtract") { + val b1 = DynamicValue.Primitive(PrimitiveValue.Byte(30.toByte)) + val b2 = DynamicValue.Primitive(PrimitiveValue.Byte(10.toByte)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(b1), + DynamicSchemaExpr.Literal(b2), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Byte(20.toByte)))) + }, + test("Byte Multiply") { + val b1 = DynamicValue.Primitive(PrimitiveValue.Byte(3.toByte)) + val b2 = DynamicValue.Primitive(PrimitiveValue.Byte(4.toByte)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(b1), + DynamicSchemaExpr.Literal(b2), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Byte(12.toByte)))) + }, + test("Byte Add overflow returns NumericOverflow error") { + val b1 = DynamicValue.Primitive(PrimitiveValue.Byte(127.toByte)) + val b2 = DynamicValue.Primitive(PrimitiveValue.Byte(1.toByte)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(b1), + DynamicSchemaExpr.Literal(b2), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Byte Subtract underflow returns NumericOverflow error") { + val b1 = DynamicValue.Primitive(PrimitiveValue.Byte((-128).toByte)) + val b2 = DynamicValue.Primitive(PrimitiveValue.Byte(1.toByte)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(b1), + DynamicSchemaExpr.Literal(b2), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Byte Multiply overflow returns NumericOverflow error") { + val b1 = DynamicValue.Primitive(PrimitiveValue.Byte(100.toByte)) + val b2 = DynamicValue.Primitive(PrimitiveValue.Byte(2.toByte)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(b1), + DynamicSchemaExpr.Literal(b2), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Short Add overflow returns NumericOverflow error") { + val s1 = DynamicValue.Primitive(PrimitiveValue.Short(Short.MaxValue)) + val s2 = DynamicValue.Primitive(PrimitiveValue.Short(1.toShort)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(s1), + DynamicSchemaExpr.Literal(s2), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Short Subtract underflow returns NumericOverflow error") { + val s1 = DynamicValue.Primitive(PrimitiveValue.Short(Short.MinValue)) + val s2 = DynamicValue.Primitive(PrimitiveValue.Short(1.toShort)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(s1), + DynamicSchemaExpr.Literal(s2), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Short Multiply overflow returns NumericOverflow error") { + val s1 = DynamicValue.Primitive(PrimitiveValue.Short(200.toShort)) + val s2 = DynamicValue.Primitive(PrimitiveValue.Short(200.toShort)) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(s1), + DynamicSchemaExpr.Literal(s2), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("BigInt Add") { + val bi1 = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(100))) + val bi2 = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(200))) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(bi1), + DynamicSchemaExpr.Literal(bi2), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(300))))) + }, + test("BigInt Subtract") { + val bi1 = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(300))) + val bi2 = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(100))) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(bi1), + DynamicSchemaExpr.Literal(bi2), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(200))))) + }, + test("BigInt Multiply") { + val bi1 = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(10))) + val bi2 = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(20))) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(bi1), + DynamicSchemaExpr.Literal(bi2), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(200))))) + }, + test("BigDecimal Add") { + val bd1 = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(1.5))) + val bd2 = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(2.5))) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(bd1), + DynamicSchemaExpr.Literal(bd2), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(4.0))))) + }, + test("BigDecimal Subtract") { + val bd1 = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(5.0))) + val bd2 = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(2.0))) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(bd1), + DynamicSchemaExpr.Literal(bd2), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(3.0))))) + }, + test("BigDecimal Multiply") { + val bd1 = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(3.0))) + val bd2 = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(4.0))) + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(bd1), + DynamicSchemaExpr.Literal(bd2), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(12.0))))) + }, + test("non-primitive arithmetic fails") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(record), + DynamicSchemaExpr.Literal(record), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("StringConcat error")( + test("fails on non-string left") { + val expr = DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(strVal) + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("fails on non-string right") { + val expr = DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(strVal), + DynamicSchemaExpr.Literal(intVal) + ) + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("StringLength error")( + test("fails on non-string") { + val expr = DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Literal(intVal)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("fails on Variant") { + val variant = DynamicValue.Variant("X", intVal) + val expr = DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Literal(variant)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("fails on Sequence") { + val seq = DynamicValue.Sequence(Chunk(intVal)) + val expr = DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Literal(seq)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("fails on Map") { + val map = DynamicValue.Map(Chunk(strVal -> intVal)) + val expr = DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Literal(map)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("fails on Null") { + val expr = DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Literal(DynamicValue.Null)) + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("CoercePrimitive additional coverage")( + test("coerce Double to Double") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Double") + assertTrue(expr.eval(intVal) == Right(dblVal)) + }, + test("coerce Float to Double") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Double") + assertTrue(expr.eval(intVal).isRight) + }, + test("coerce Int to Double") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(10.0)))) + }, + test("coerce Long to Double") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(100.0)))) + }, + test("coerce Short to Double") { + val sVal = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sVal), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(5.0)))) + }, + test("coerce Byte to Double") { + val bVal = DynamicValue.Primitive(PrimitiveValue.Byte(3.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bVal), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(3.0)))) + }, + test("coerce String to Double valid") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("3.14")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(3.14)))) + }, + test("coerce String to Double invalid") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("abc")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Boolean to Double fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Float to Float") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Float") + assertTrue(expr.eval(intVal) == Right(fltVal)) + }, + test("coerce Double to Float") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Float") + assertTrue(expr.eval(intVal).isRight) + }, + test("coerce Int to Float") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(10.0f)))) + }, + test("coerce Long to Float") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(100.0f)))) + }, + test("coerce String to Float valid") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("2.5")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(2.5f)))) + }, + test("coerce String to Float invalid") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("xyz")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Boolean to Float fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Boolean to Boolean") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Boolean") + assertTrue(expr.eval(intVal) == Right(boolT)) + }, + test("coerce String true to Boolean") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("true")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Boolean") + assertTrue(expr.eval(intVal) == Right(boolT)) + }, + test("coerce invalid String to Boolean fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("maybe")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Int 0 to Boolean false") { + val zero = DynamicValue.Primitive(PrimitiveValue.Int(0)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(zero), "Boolean") + assertTrue(expr.eval(intVal) == Right(boolF)) + }, + test("coerce Int 1 to Boolean true") { + val one = DynamicValue.Primitive(PrimitiveValue.Int(1)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(one), "Boolean") + assertTrue(expr.eval(intVal) == Right(boolT)) + }, + test("coerce Long to Boolean fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce to unknown type fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Complex") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce record fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(record), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce String to String is identity") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(strVal), "String") + assertTrue(expr.eval(intVal) == Right(strVal)) + }, + test("coerce Char to String") { + val ch = DynamicValue.Primitive(PrimitiveValue.Char('Z')) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(ch), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("Z")))) + }, + test("coerce BigInt to String") { + val bi = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(999))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bi), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("999")))) + }, + test("coerce BigDecimal to String") { + val bd = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(1.5))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bd), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("1.5")))) + }, + test("coerce Boolean to Int fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Boolean to Long fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Long") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Char to Int fails") { + val ch = DynamicValue.Primitive(PrimitiveValue.Char('a')) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(ch), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Char to Long fails") { + val ch = DynamicValue.Primitive(PrimitiveValue.Char('x')) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(ch), "Long") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Char to Double fails") { + val ch = DynamicValue.Primitive(PrimitiveValue.Char('z')) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(ch), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Char to Float fails") { + val ch = DynamicValue.Primitive(PrimitiveValue.Char('q')) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(ch), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Char to Boolean fails") { + val ch = DynamicValue.Primitive(PrimitiveValue.Char('t')) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(ch), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigInt to Int fails") { + val bi = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(42))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bi), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigInt to Long fails") { + val bi = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(42))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bi), "Long") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigInt to Double fails") { + val bi = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(42))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bi), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigInt to Float fails") { + val bi = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(42))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bi), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigInt to Boolean fails") { + val bi = DynamicValue.Primitive(PrimitiveValue.BigInt(BigInt(1))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bi), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigDecimal to Int fails") { + val bd = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(3.14))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bd), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigDecimal to Long fails") { + val bd = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(3.14))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bd), "Long") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigDecimal to Double fails") { + val bd = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(3.14))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bd), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigDecimal to Float fails") { + val bd = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(3.14))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bd), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce BigDecimal to Boolean fails") { + val bd = DynamicValue.Primitive(PrimitiveValue.BigDecimal(BigDecimal(1.0))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bd), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Boolean to Double fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Boolean to Float fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce to unsupported type Char fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Char") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce to unsupported type BigInt fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "BigInt") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce to unsupported type BigDecimal fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "BigDecimal") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce invalid String to Int fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("abc")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce invalid String to Long fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("abc")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Long") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce invalid String to Double fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("abc")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce invalid String to Float fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("abc")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce invalid String to Boolean fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("abc")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce UUID to String via toString") { + val uuid = + DynamicValue.Primitive(PrimitiveValue.UUID(java.util.UUID.fromString("550e8400-e29b-41d4-a716-446655440000"))) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(uuid), "String") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Short to Int") { + val sVal = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sVal), "Int") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(5)))) + }, + test("coerce Byte to Int") { + val bVal = DynamicValue.Primitive(PrimitiveValue.Byte(7.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bVal), "Int") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(7)))) + }, + test("coerce Double to Int") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Int") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(3)))) + }, + test("coerce Float to Int") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Int") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(2)))) + }, + test("coerce Short to Long") { + val sVal = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sVal), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(5L)))) + }, + test("coerce Byte to Long") { + val bVal = DynamicValue.Primitive(PrimitiveValue.Byte(3.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bVal), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(3L)))) + }, + test("coerce Double to Long") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(3L)))) + }, + test("coerce Float to Long") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(2L)))) + }, + test("coerce String to Long valid") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("999")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(999L)))) + }, + test("coerce String to Long invalid") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("bad")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Long") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Short to Float") { + val sVal = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sVal), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(5.0f)))) + }, + test("coerce Byte to Float") { + val bVal = DynamicValue.Primitive(PrimitiveValue.Byte(3.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bVal), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(3.0f)))) + }, + test("coerce null string to Int fails") { + // Use a non-numeric string to ensure failure + val sv = DynamicValue.Primitive(PrimitiveValue.String("")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Int") + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("navigateDynamicValue coverage")( + test("navigate through variant case") { + val v = DynamicValue.Variant("Active", intVal) + val result = DynamicSchemaExpr.navigateDynamicValue(v, DynamicOptic.root.caseOf("Active")) + assertTrue(result == Some(intVal)) + }, + test("navigate fails on wrong case") { + val v = DynamicValue.Variant("Active", intVal) + val result = DynamicSchemaExpr.navigateDynamicValue(v, DynamicOptic.root.caseOf("Wrong")) + assertTrue(result == None) + }, + test("navigate through index") { + val seq = DynamicValue.Sequence(Chunk(intVal, strVal)) + val result = DynamicSchemaExpr.navigateDynamicValue(seq, DynamicOptic(Vector(DynamicOptic.Node.AtIndex(1)))) + assertTrue(result == Some(strVal)) + }, + test("navigate fails on negative index") { + val seq = DynamicValue.Sequence(Chunk(intVal)) + val result = DynamicSchemaExpr.navigateDynamicValue(seq, DynamicOptic(Vector(DynamicOptic.Node.AtIndex(-1)))) + assertTrue(result == None) + }, + test("navigate fails AtIndex on non-sequence") { + val result = DynamicSchemaExpr.navigateDynamicValue(intVal, DynamicOptic(Vector(DynamicOptic.Node.AtIndex(0)))) + assertTrue(result == None) + }, + test("navigate through AtMapKey") { + val m = DynamicValue.Map(Chunk(strVal -> intVal)) + val result = DynamicSchemaExpr.navigateDynamicValue(m, DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(strVal)))) + assertTrue(result == Some(intVal)) + }, + test("navigate fails AtMapKey on missing key") { + val m = DynamicValue.Map(Chunk(strVal -> intVal)) + val result = DynamicSchemaExpr.navigateDynamicValue(m, DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(intVal)))) + assertTrue(result == None) + }, + test("navigate fails AtMapKey on non-map") { + val result = + DynamicSchemaExpr.navigateDynamicValue(intVal, DynamicOptic(Vector(DynamicOptic.Node.AtMapKey(strVal)))) + assertTrue(result == None) + }, + test("navigate through Wrapped") { + val w = DynamicValue.Record(Chunk("value" -> intVal)) + val result = DynamicSchemaExpr.navigateDynamicValue(w, DynamicOptic(Vector(DynamicOptic.Node.Wrapped))) + assertTrue(result == Some(intVal)) + }, + test("navigate fails Wrapped on multi-field record") { + val result = DynamicSchemaExpr.navigateDynamicValue(record, DynamicOptic(Vector(DynamicOptic.Node.Wrapped))) + assertTrue(result == None) + }, + test("navigate fails Elements traversal") { + val seq = DynamicValue.Sequence(Chunk(intVal)) + val result = DynamicSchemaExpr.navigateDynamicValue(seq, DynamicOptic(Vector(DynamicOptic.Node.Elements))) + assertTrue(result == None) + }, + test("navigate fails case on non-variant") { + val result = DynamicSchemaExpr.navigateDynamicValue(intVal, DynamicOptic.root.caseOf("X")) + assertTrue(result == None) + }, + test("navigate fails field on non-record") { + val result = DynamicSchemaExpr.navigateDynamicValue(intVal, DynamicOptic.root.field("x")) + assertTrue(result == None) + } + ), + suite("fromSchemaExpr coverage")( + test("converts Literal") { + val expr = SchemaExpr.Literal[Any, Int](42, Schema[Int]) + val result = DynamicSchemaExpr.fromSchemaExpr(expr) + assertTrue(result.isRight) + }, + test("converts Not") { + val inner = SchemaExpr.Literal[Any, Boolean](true, Schema[Boolean]) + val result = DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Not(inner)) + assertTrue(result.isRight) + }, + test("converts StringLength") { + val inner = SchemaExpr.Literal[Any, String]("test", Schema[String]) + val result = DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.StringLength(inner)) + assertTrue(result.isRight) + }, + test("rejects StringRegexMatch") { + val inner = SchemaExpr.Literal[Any, String]("test", Schema[String]) + val regex = SchemaExpr.Literal[Any, String](".*", Schema[String]) + val result = DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.StringRegexMatch(inner, regex)) + assertTrue(result.isLeft) + }, + test("converts StringConcat") { + val l = SchemaExpr.Literal[Any, String]("a", Schema[String]) + val r = SchemaExpr.Literal[Any, String]("b", Schema[String]) + val result = DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.StringConcat(l, r)) + assertTrue(result.isRight) + }, + test("converts Logical And") { + val l = SchemaExpr.Literal[Any, Boolean](true, Schema[Boolean]) + val r = SchemaExpr.Literal[Any, Boolean](false, Schema[Boolean]) + val result = DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Logical(l, r, SchemaExpr.LogicalOperator.And)) + assertTrue(result.isRight) + }, + test("converts Logical Or") { + val l = SchemaExpr.Literal[Any, Boolean](true, Schema[Boolean]) + val r = SchemaExpr.Literal[Any, Boolean](false, Schema[Boolean]) + val result = DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Logical(l, r, SchemaExpr.LogicalOperator.Or)) + assertTrue(result.isRight) + }, + test("converts Relational LessThan") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = + DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Relational(l, r, SchemaExpr.RelationalOperator.LessThan)) + assertTrue(result.isRight) + }, + test("converts Relational LessThanOrEqual") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = + DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Relational(l, r, SchemaExpr.RelationalOperator.LessThanOrEqual)) + assertTrue(result.isRight) + }, + test("converts Relational GreaterThan") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = + DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Relational(l, r, SchemaExpr.RelationalOperator.GreaterThan)) + assertTrue(result.isRight) + }, + test("converts Relational GreaterThanOrEqual") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = DynamicSchemaExpr.fromSchemaExpr( + SchemaExpr.Relational(l, r, SchemaExpr.RelationalOperator.GreaterThanOrEqual) + ) + assertTrue(result.isRight) + }, + test("converts Relational Equal") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Relational(l, r, SchemaExpr.RelationalOperator.Equal)) + assertTrue(result.isRight) + }, + test("converts Relational NotEqual") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = + DynamicSchemaExpr.fromSchemaExpr(SchemaExpr.Relational(l, r, SchemaExpr.RelationalOperator.NotEqual)) + assertTrue(result.isRight) + }, + test("converts Arithmetic Add") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = + DynamicSchemaExpr.fromSchemaExpr( + SchemaExpr.Arithmetic(l, r, SchemaExpr.ArithmeticOperator.Add, IsNumeric.IsInt) + ) + assertTrue(result.isRight) + }, + test("converts Arithmetic Subtract") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = DynamicSchemaExpr.fromSchemaExpr( + SchemaExpr.Arithmetic(l, r, SchemaExpr.ArithmeticOperator.Subtract, IsNumeric.IsInt) + ) + assertTrue(result.isRight) + }, + test("converts Arithmetic Multiply") { + val l = SchemaExpr.Literal[Any, Int](1, Schema[Int]) + val r = SchemaExpr.Literal[Any, Int](2, Schema[Int]) + val result = DynamicSchemaExpr.fromSchemaExpr( + SchemaExpr.Arithmetic(l, r, SchemaExpr.ArithmeticOperator.Multiply, IsNumeric.IsInt) + ) + assertTrue(result.isRight) + } + ), + suite("navigateDynamicValue coverage")( + test("navigate Field into record") { + val r = DynamicValue.Record(Chunk("x" -> intVal)) + val optic = DynamicOptic.root.field("x") + assertTrue(DynamicSchemaExpr.navigateDynamicValue(r, optic) == Some(intVal)) + }, + test("navigate Field missing returns None") { + val r = DynamicValue.Record(Chunk("x" -> intVal)) + val optic = DynamicOptic.root.field("y") + assertTrue(DynamicSchemaExpr.navigateDynamicValue(r, optic).isEmpty) + }, + test("navigate Field on non-record returns None") { + val optic = DynamicOptic.root.field("x") + assertTrue(DynamicSchemaExpr.navigateDynamicValue(intVal, optic).isEmpty) + }, + test("navigate Case into matching variant") { + val v = DynamicValue.Variant("A", intVal) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("A"))) + assertTrue(DynamicSchemaExpr.navigateDynamicValue(v, optic) == Some(intVal)) + }, + test("navigate Case on non-matching variant returns None") { + val v = DynamicValue.Variant("B", intVal) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("A"))) + assertTrue(DynamicSchemaExpr.navigateDynamicValue(v, optic).isEmpty) + }, + test("navigate Case on non-variant returns None") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Case("A"))) + assertTrue(DynamicSchemaExpr.navigateDynamicValue(intVal, optic).isEmpty) + }, + test("navigate AtIndex into sequence") { + val s = DynamicValue.Sequence(Chunk(intVal, strVal)) + val optic = DynamicOptic.root.at(1) + assertTrue(DynamicSchemaExpr.navigateDynamicValue(s, optic) == Some(strVal)) + }, + test("navigate AtIndex out of bounds returns None") { + val s = DynamicValue.Sequence(Chunk(intVal)) + val optic = DynamicOptic.root.at(5) + assertTrue(DynamicSchemaExpr.navigateDynamicValue(s, optic).isEmpty) + }, + test("navigate AtIndex on non-sequence returns None") { + val optic = DynamicOptic.root.at(0) + assertTrue(DynamicSchemaExpr.navigateDynamicValue(intVal, optic).isEmpty) + }, + test("navigate AtMapKey into map") { + val key = DynamicValue.Primitive(PrimitiveValue.String("k")) + val m = DynamicValue.Map(Chunk(key -> intVal)) + val optic = DynamicOptic.root.atKey[String]("k") + assertTrue(DynamicSchemaExpr.navigateDynamicValue(m, optic) == Some(intVal)) + }, + test("navigate AtMapKey missing returns None") { + val key = DynamicValue.Primitive(PrimitiveValue.String("k")) + val m = DynamicValue.Map(Chunk(key -> intVal)) + val optic = DynamicOptic.root.atKey[String]("z") + assertTrue(DynamicSchemaExpr.navigateDynamicValue(m, optic).isEmpty) + }, + test("navigate AtMapKey on non-map returns None") { + val optic = DynamicOptic.root.atKey[String]("k") + assertTrue(DynamicSchemaExpr.navigateDynamicValue(intVal, optic).isEmpty) + }, + test("navigate Wrapped into single-field record") { + val r = DynamicValue.Record(Chunk("value" -> intVal)) + val optic = DynamicOptic.root.wrapped + assertTrue(DynamicSchemaExpr.navigateDynamicValue(r, optic) == Some(intVal)) + }, + test("navigate Wrapped on multi-field record returns None") { + val r = DynamicValue.Record(Chunk("a" -> intVal, "b" -> strVal)) + val optic = DynamicOptic.root.wrapped + assertTrue(DynamicSchemaExpr.navigateDynamicValue(r, optic).isEmpty) + }, + test("navigate Wrapped on non-record returns None") { + val optic = DynamicOptic.root.wrapped + assertTrue(DynamicSchemaExpr.navigateDynamicValue(intVal, optic).isEmpty) + }, + test("navigate Elements traversal returns None") { + val s = DynamicValue.Sequence(Chunk(intVal)) + val optic = DynamicOptic.root.elements + assertTrue(DynamicSchemaExpr.navigateDynamicValue(s, optic).isEmpty) + }, + test("navigate MapKeys traversal returns None") { + val k = DynamicValue.Primitive(PrimitiveValue.String("k")) + val m = DynamicValue.Map(Chunk(k -> intVal)) + val optic = DynamicOptic.root.mapKeys + assertTrue(DynamicSchemaExpr.navigateDynamicValue(m, optic).isEmpty) + }, + test("navigate MapValues traversal returns None") { + val k = DynamicValue.Primitive(PrimitiveValue.String("k")) + val m = DynamicValue.Map(Chunk(k -> intVal)) + val optic = DynamicOptic.root.mapValues + assertTrue(DynamicSchemaExpr.navigateDynamicValue(m, optic).isEmpty) + }, + test("navigate root path returns value itself") { + assertTrue(DynamicSchemaExpr.navigateDynamicValue(intVal, DynamicOptic.root) == Some(intVal)) + }, + test("navigate chained Field.Field") { + val inner = DynamicValue.Record(Chunk("y" -> intVal)) + val outer = DynamicValue.Record(Chunk("x" -> inner)) + val optic = DynamicOptic.root.field("x").field("y") + assertTrue(DynamicSchemaExpr.navigateDynamicValue(outer, optic) == Some(intVal)) + } + ), + suite("getDynamicValueTypeName coverage")( + test("Arithmetic non-primitive error uses type name") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(record), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("StringConcat non-string error") { + val expr = DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(strVal) + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("StringLength non-string error") { + val expr = DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Literal(intVal)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("StringLength on Null error") { + val expr = DynamicSchemaExpr.StringLength(DynamicSchemaExpr.Literal(DynamicValue.Null)) + assertTrue(expr.eval(intVal).isLeft) + }, + test("CoercePrimitive on Variant error") { + val variant = DynamicValue.Variant("A", intVal) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(variant), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("CoercePrimitive on Sequence error") { + val seq = DynamicValue.Sequence(Chunk(intVal)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(seq), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("CoercePrimitive on Map error") { + val k = DynamicValue.Primitive(PrimitiveValue.String("k")) + val m = DynamicValue.Map(Chunk(k -> intVal)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(m), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("CoercePrimitive on Null error") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(DynamicValue.Null), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("StringConcat with Variant fails") { + val variant = DynamicValue.Variant("A", intVal) + val expr = DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(variant), + DynamicSchemaExpr.Literal(strVal) + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("StringConcat with Sequence fails") { + val seq = DynamicValue.Sequence(Chunk(intVal)) + val expr = DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(seq), + DynamicSchemaExpr.Literal(strVal) + ) + assertTrue(expr.eval(intVal).isLeft) + }, + test("Arithmetic with Null fails") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(DynamicValue.Null), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("additional coercion branches")( + test("coerce Long to Boolean fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Double to Boolean fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Float to Boolean fails") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Short to Boolean fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.Short(1.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Byte to Boolean fails") { + val bv = DynamicValue.Primitive(PrimitiveValue.Byte(1.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bv), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Long to Short unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Short") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Long to Byte unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Byte") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Double to Short unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Short") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Double to Byte unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Byte") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Float to Short unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Short") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Float to Byte unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Byte") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Int to Char unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Char") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Long to Char unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Char") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Double to Char unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Char") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Float to Char unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Char") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Boolean to Char unsupported target") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Char") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Byte to Short unsupported target") { + val bv = DynamicValue.Primitive(PrimitiveValue.Byte(7.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bv), "Short") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Short to Byte unsupported target") { + val sv = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Byte") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Short to Long") { + val sv = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(5L)))) + }, + test("coerce Byte to Long") { + val bv = DynamicValue.Primitive(PrimitiveValue.Byte(7.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bv), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(7L)))) + }, + test("coerce Short to Double") { + val sv = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(5.0)))) + }, + test("coerce Byte to Double") { + val bv = DynamicValue.Primitive(PrimitiveValue.Byte(7.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bv), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(7.0)))) + }, + test("coerce Short to Float") { + val sv = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(5.0f)))) + }, + test("coerce Byte to Float") { + val bv = DynamicValue.Primitive(PrimitiveValue.Byte(7.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bv), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(7.0f)))) + }, + test("coerce Short to String") { + val sv = DynamicValue.Primitive(PrimitiveValue.Short(5.toShort)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("5")))) + }, + test("coerce Byte to String") { + val bv = DynamicValue.Primitive(PrimitiveValue.Byte(7.toByte)) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(bv), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("7")))) + }, + test("coerce Char to String") { + val cv = DynamicValue.Primitive(PrimitiveValue.Char('A')) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(cv), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("A")))) + }, + test("coerce Boolean to String") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("true")))) + }, + test("coerce Int to BigDecimal") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "BigDecimal") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce Int to BigInt") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "BigInt") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce String to Short unsupported target") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("not-a-number")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Short") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce String to Byte unsupported target") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("not-a-number")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Byte") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce String to Short numeric unsupported target") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("42")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Short") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce String to Byte numeric unsupported target") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("7")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Byte") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce String to Char single unsupported target") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("A")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Char") + assertTrue(expr.eval(intVal).isLeft) + }, + test("coerce String to Char multi-char unsupported target") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("AB")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Char") + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("Path eval")( + test("Path eval succeeds on existing field") { + val expr = DynamicSchemaExpr.Path(DynamicOptic.root.field("name")) + val r = expr.eval(record) + assertTrue(r == Right(strVal)) + }, + test("Path eval fails on non-existent field") { + val expr = DynamicSchemaExpr.Path(DynamicOptic.root.field("missing")) + val r = expr.eval(record) + assertTrue(r.isLeft) + }, + test("Path eval fails on primitive") { + val expr = DynamicSchemaExpr.Path(DynamicOptic.root.field("x")) + val r = expr.eval(intVal) + assertTrue(r.isLeft) + } + ), + suite("DefaultValue eval")( + test("DefaultValue eval returns Left") { + val r = DynamicSchemaExpr.DefaultValue.eval(intVal) + assertTrue(r.isLeft) + } + ), + suite("ResolvedDefault eval")( + test("ResolvedDefault eval returns Right") { + val expr = DynamicSchemaExpr.ResolvedDefault(intVal) + val r = expr.eval(strVal) + assertTrue(r == Right(intVal)) + } + ), + suite("Literal eval")( + test("Literal eval always returns the literal value") { + val expr = DynamicSchemaExpr.Literal(strVal) + assertTrue(expr.eval(intVal) == Right(strVal)) + } + ), + suite("coercePrimitive identity and cross-type conversions")( + test("Int to Int identity") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Int") + assertTrue(expr.eval(intVal) == Right(intVal)) + }, + test("Long to Long identity") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Long") + assertTrue(expr.eval(intVal) == Right(longVal)) + }, + test("Double to Double identity") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "Double") + assertTrue(expr.eval(intVal) == Right(dblVal)) + }, + test("Float to Float identity") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "Float") + assertTrue(expr.eval(intVal) == Right(fltVal)) + }, + test("String to String identity") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(strVal), "String") + assertTrue(expr.eval(intVal) == Right(strVal)) + }, + test("Boolean to Boolean identity") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "Boolean") + assertTrue(expr.eval(intVal) == Right(boolT)) + }, + test("Int to Long") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(10L)))) + }, + test("Long to Int") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "Int") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(100)))) + }, + test("Int to String") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("10")))) + }, + test("Long to String") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(longVal), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("100")))) + }, + test("Double to String") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(dblVal), "String") + assertTrue(expr.eval(intVal).isRight) + }, + test("Float to String") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(fltVal), "String") + assertTrue(expr.eval(intVal).isRight) + }, + test("Boolean to String") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(boolT), "String") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.String("true")))) + }, + test("Int to Boolean (non-zero)") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(intVal), "Boolean") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true)))) + }, + test("String valid to Int") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("42")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Int") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(42)))) + }, + test("String invalid to Int fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("abc")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Int") + assertTrue(expr.eval(intVal).isLeft) + }, + test("String valid to Long") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("99999")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Long") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(99999L)))) + }, + test("String invalid to Long fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("xyz")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Long") + assertTrue(expr.eval(intVal).isLeft) + }, + test("String valid to Double") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("3.14")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Double") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(3.14)))) + }, + test("String invalid to Double fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("not-a-number")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Double") + assertTrue(expr.eval(intVal).isLeft) + }, + test("String valid to Float") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("1.5")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Float") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Float(1.5f)))) + }, + test("String invalid to Float fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("not-float")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Float") + assertTrue(expr.eval(intVal).isLeft) + }, + test("String 'true' to Boolean") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("true")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Boolean") + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true)))) + }, + test("String invalid to Boolean fails") { + val sv = DynamicValue.Primitive(PrimitiveValue.String("maybe")) + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(sv), "Boolean") + assertTrue(expr.eval(intVal).isLeft) + }, + test("toString_ fallback for non-stringifiable") { + val expr = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Literal(DynamicValue.Null), "String") + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("evalPrimitiveArithmetic additional types")( + test("Int Add") { + val i1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(3))) + val i2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(7))) + val expr = DynamicSchemaExpr.Arithmetic(i1, i2, DynamicSchemaExpr.ArithmeticOperator.Add) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(10)))) + }, + test("Int Subtract") { + val i1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(10))) + val i2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(3))) + val expr = DynamicSchemaExpr.Arithmetic(i1, i2, DynamicSchemaExpr.ArithmeticOperator.Subtract) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(7)))) + }, + test("Int Multiply") { + val i1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(4))) + val i2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(5))) + val expr = DynamicSchemaExpr.Arithmetic(i1, i2, DynamicSchemaExpr.ArithmeticOperator.Multiply) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Int(20)))) + }, + test("Long Add") { + val l1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(10L))) + val l2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(20L))) + val expr = DynamicSchemaExpr.Arithmetic(l1, l2, DynamicSchemaExpr.ArithmeticOperator.Add) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(30L)))) + }, + test("Long Subtract") { + val l1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(30L))) + val l2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(10L))) + val expr = DynamicSchemaExpr.Arithmetic(l1, l2, DynamicSchemaExpr.ArithmeticOperator.Subtract) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(20L)))) + }, + test("Long Multiply") { + val l1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(6L))) + val l2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(7L))) + val expr = DynamicSchemaExpr.Arithmetic(l1, l2, DynamicSchemaExpr.ArithmeticOperator.Multiply) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Long(42L)))) + }, + test("Double Add") { + val d1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(1.5))) + val d2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(2.5))) + val expr = DynamicSchemaExpr.Arithmetic(d1, d2, DynamicSchemaExpr.ArithmeticOperator.Add) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(4.0)))) + }, + test("Double Subtract") { + val d1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(5.0))) + val d2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(2.0))) + val expr = DynamicSchemaExpr.Arithmetic(d1, d2, DynamicSchemaExpr.ArithmeticOperator.Subtract) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(3.0)))) + }, + test("Double Multiply") { + val d1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(3.0))) + val d2 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(4.0))) + val expr = DynamicSchemaExpr.Arithmetic(d1, d2, DynamicSchemaExpr.ArithmeticOperator.Multiply) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Double(12.0)))) + }, + test("mismatched types Int+Long fails") { + val i1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + val l1 = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(2L))) + val expr = DynamicSchemaExpr.Arithmetic(i1, l1, DynamicSchemaExpr.ArithmeticOperator.Add) + assertTrue(expr.eval(intVal).isLeft) + } + ), + suite("evalPrimitiveRelational additional coverage")( + test("LessThan true") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(5))), + DynamicSchemaExpr.RelationalOperator.LessThan + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true)))) + }, + test("Equal true") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.RelationalOperator.Equal + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true)))) + }, + test("NotEqual true") { + val three = DynamicValue.Primitive(PrimitiveValue.Int(3)) + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(intVal), + DynamicSchemaExpr.Literal(three), + DynamicSchemaExpr.RelationalOperator.NotEqual + ) + assertTrue(expr.eval(intVal) == Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true)))) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprSerializationSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprSerializationSpec.scala new file mode 100644 index 0000000000..5a8d6a4397 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprSerializationSpec.scala @@ -0,0 +1,38 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.schema.json.{DiscriminatorKind, JsonBinaryCodecDeriver} +import zio.test._ + +object DynamicSchemaExprSerializationSpec extends SchemaBaseSpec { + import MigrationSchemas._ + + // Use a discriminator for variant types so decoding works correctly + private val deriver = JsonBinaryCodecDeriver.withDiscriminatorKind(DiscriminatorKind.Field("_type")) + + def spec: Spec[TestEnvironment, Any] = + suite("DynamicSchemaExprSerializationSpec")( + test("Literal encodes/decodes via JsonBinaryCodec") { + val expr: DynamicSchemaExpr = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))) + val codec = Schema[DynamicSchemaExpr].derive(deriver) + val json = codec.encodeToString(expr) + assertTrue(codec.decode(json) == Right(expr)) + }, + test("Path encodes/decodes via JsonBinaryCodec") { + val expr: DynamicSchemaExpr = DynamicSchemaExpr.Path(DynamicOptic.root.field("test")) + val codec = Schema[DynamicSchemaExpr].derive(deriver) + val json = codec.encodeToString(expr) + assertTrue(codec.decode(json) == Right(expr)) + }, + test("Arithmetic encodes/decodes via JsonBinaryCodec") { + val expr: DynamicSchemaExpr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root.field("age")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + val codec = Schema[DynamicSchemaExpr].derive(deriver) + val json = codec.encodeToString(expr) + assertTrue(codec.decode(json) == Right(expr)) + } + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprSpec.scala new file mode 100644 index 0000000000..316e94e511 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicSchemaExprSpec.scala @@ -0,0 +1,257 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object DynamicSchemaExprSpec extends SchemaBaseSpec { + def spec: Spec[TestEnvironment, Any] = suite("DynamicSchemaExprSpec")( + suite("Literal")( + test("evaluates to the literal value") { + val expr = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))) + val result = expr.eval(DynamicValue.Primitive(PrimitiveValue.String("ignored"))) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.Int(42)))) + } + ), + suite("Path navigation")( + test("navigates to a simple field") { + val record = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) + ) + ) + val expr = DynamicSchemaExpr.Path(DynamicOptic.root.field("name")) + val result = expr.eval(record) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.String("Alice")))) + }, + test("navigates to a nested field") { + val record = DynamicValue.Record( + Chunk( + "person" -> DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Bob")) + ) + ) + ) + ) + val expr = DynamicSchemaExpr.Path(DynamicOptic.root.field("person").field("name")) + val result = expr.eval(record) + assertTrue(result == Right(DynamicValue.Primitive(PrimitiveValue.String("Bob")))) + }, + test("returns error for non-existent path") { + val record = DynamicValue.Record( + Chunk( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")) + ) + ) + val expr = DynamicSchemaExpr.Path(DynamicOptic.root.field("missing")) + val result = expr.eval(record) + assertTrue(result.isLeft) + } + ), + suite("Logical operations")( + test("NOT negates boolean value") { + val expr = DynamicSchemaExpr.Not( + DynamicSchemaExpr.Literal( + DynamicValue.Primitive(PrimitiveValue.Boolean(true)) + ) + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(false))) + ) + }, + test("AND combines two booleans") { + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(false))), + DynamicSchemaExpr.LogicalOperator.And + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(false))) + ) + }, + test("OR combines two booleans") { + val expr = DynamicSchemaExpr.Logical( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(false))), + DynamicSchemaExpr.LogicalOperator.Or + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + } + ), + suite("Relational operations")( + test("LessThan compares values") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(5))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(10))), + DynamicSchemaExpr.RelationalOperator.LessThan + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + }, + test("Equal compares values") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("a"))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("a"))), + DynamicSchemaExpr.RelationalOperator.Equal + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + }, + test("NotEqual compares values") { + val expr = DynamicSchemaExpr.Relational( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.RelationalOperator.NotEqual + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + } + ), + suite("Arithmetic operations")( + test("Add integers") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(5))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(3))), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Int(8))) + ) + }, + test("Subtract longs") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(10L))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(4L))), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Long(6L))) + ) + }, + test("Multiply doubles") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(2.5))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Double(4.0))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Double(10.0))) + ) + }, + test("fails for incompatible numeric types") { + val expr = DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(5))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Long(3L))), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + assertTrue(expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))).isLeft) + } + ), + suite("String operations")( + test("StringConcat concatenates strings") { + val expr = DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("Hello, "))), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("World!"))) + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.String("Hello, World!"))) + ) + }, + test("StringLength returns string length") { + val expr = DynamicSchemaExpr.StringLength( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("Hello"))) + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Int(5))) + ) + } + ), + suite("Type coercion")( + test("CoercePrimitive converts Int to String") { + val expr = DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))), + "String" + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.String("42"))) + ) + }, + test("CoercePrimitive converts String to Int") { + val expr = DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("123"))), + "Int" + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Int(123))) + ) + }, + test("CoercePrimitive fails for invalid conversion") { + val expr = DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("not a number"))), + "Int" + ) + assertTrue(expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))).isLeft) + }, + test("CoercePrimitive converts Int to Long") { + val expr = DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + "Long" + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Long(100L))) + ) + }, + test("CoercePrimitive converts Boolean to String") { + val expr = DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))), + "String" + ) + assertTrue( + expr.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.String("true"))) + ) + } + ), + suite("Composition")( + test("&& composes expressions with AND") { + val left = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + val right = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + val composed = left && right + assertTrue( + composed.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + }, + test("|| composes expressions with OR") { + val left = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(false))) + val right = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + val composed = left || right + assertTrue( + composed.eval(DynamicValue.Primitive(PrimitiveValue.Int(0))) == + Right(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionCoverageSpec.scala new file mode 100644 index 0000000000..2233cfb7d2 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionCoverageSpec.scala @@ -0,0 +1,57 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +/** + * Supplementary coverage tests for [[MigrationAction]]. + * + * Tests here cover edge cases and accessors not exercised by + * [[MigrationActionSpec]], which already covers basic reverse semantics. + */ +object MigrationActionCoverageSpec extends SchemaBaseSpec { + + private val root = DynamicOptic.root + private val litI = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + private val litS = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + + def spec: Spec[TestEnvironment, Any] = suite("MigrationActionCoverageSpec")( + test("AddField reverse preserves nested path and name") { + val action = MigrationAction.AddField(root.field("a"), "x", litI) + val rev = action.reverse.asInstanceOf[MigrationAction.DropField] + assertTrue(rev.at == root.field("a") && rev.name == "x" && rev.defaultForReverse == litI) + }, + test("DropField reverse preserves default expression") { + val action = MigrationAction.DropField(root, "x", litS) + val rev = action.reverse.asInstanceOf[MigrationAction.AddField] + assertTrue(rev.name == "x" && rev.default == litS) + }, + test("RenameField at field accessor has correct node count") { + val action = MigrationAction.RenameField(root.field("x"), "a", "b") + assertTrue(action.at.nodes.length == 1) + }, + test("Mandate reverse preserves default into Optionalize") { + val action = MigrationAction.Mandate(root.field("x"), litS) + val rev = action.reverse.asInstanceOf[MigrationAction.Optionalize] + assertTrue(rev.defaultForReverse == litS) + }, + test("Optionalize without explicit default uses DefaultValue") { + val action = MigrationAction.Optionalize(root.field("x")) + assertTrue(action.defaultForReverse == DynamicSchemaExpr.DefaultValue) + }, + test("Join reverse preserves combiner/splitter swap") { + val combiner = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("comb"))) + val splitter = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("split"))) + val action = MigrationAction.Join(root.field("c"), Vector(root.field("a")), combiner, splitter) + val rev = action.reverse.asInstanceOf[MigrationAction.Split] + assertTrue(rev.splitter == splitter && rev.combiner == combiner) + }, + test("Split reverse preserves combiner/splitter swap") { + val splitter = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("s"))) + val combiner = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("c"))) + val action = MigrationAction.Split(root.field("x"), Vector(root.field("a")), splitter, combiner) + val rev = action.reverse.asInstanceOf[MigrationAction.Join] + assertTrue(rev.combiner == combiner && rev.splitter == splitter) + } + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionSpec.scala new file mode 100644 index 0000000000..1960f3d55f --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionSpec.scala @@ -0,0 +1,190 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object MigrationActionSpec extends SchemaBaseSpec { + def spec: Spec[TestEnvironment, Any] = suite("MigrationActionSpec")( + suite("Identity")( + test("has root path") { + assertTrue(MigrationAction.Identity.at == DynamicOptic.root) + }, + test("reverse is identity") { + assertTrue(MigrationAction.Identity.reverse == MigrationAction.Identity) + } + ), + suite("AddField")( + test("reverse is DropField with same default") { + val action = MigrationAction.AddField( + DynamicOptic.root.field("person"), + "age", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + val reversed = action.reverse + assertTrue(reversed.isInstanceOf[MigrationAction.DropField]) + val drop = reversed.asInstanceOf[MigrationAction.DropField] + assertTrue( + drop.at == action.at, + drop.name == action.name + ) + } + ), + suite("DropField")( + test("reverse is AddField") { + val action = MigrationAction.DropField( + DynamicOptic.root, + "removed", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + val reversed = action.reverse + assertTrue(reversed.isInstanceOf[MigrationAction.AddField]) + val add = reversed.asInstanceOf[MigrationAction.AddField] + assertTrue( + add.at == action.at, + add.name == action.name + ) + } + ), + suite("RenameField")( + test("reverse swaps from and to") { + val action = MigrationAction.RenameField(DynamicOptic.root, "oldName", "newName") + val reversed = action.reverse.asInstanceOf[MigrationAction.RenameField] + assertTrue( + reversed.from == "newName", + reversed.to == "oldName", + reversed.at == action.at + ) + } + ), + suite("TransformValue")( + test("reverse swaps transform and reverseTransform") { + val transform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + val reverseTransform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))) + val action = MigrationAction.TransformValue(DynamicOptic.root.field("x"), transform, reverseTransform) + val reversed = action.reverse.asInstanceOf[MigrationAction.TransformValue] + assertTrue( + reversed.transform == reverseTransform, + reversed.reverseTransform == transform + ) + } + ), + suite("Mandate")( + test("reverse is Optionalize") { + val action = MigrationAction.Mandate( + DynamicOptic.root.field("opt"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + val reversed = action.reverse.asInstanceOf[MigrationAction.Optionalize] + assertTrue(reversed.defaultForReverse == action.default) + } + ), + suite("Optionalize")( + test("reverse is Mandate with provided default") { + val default = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("fallback"))) + val action = MigrationAction.Optionalize(DynamicOptic.root.field("field"), default) + val reversed = action.reverse.asInstanceOf[MigrationAction.Mandate] + assertTrue(reversed.default == default) + } + ), + suite("ChangeType")( + test("reverse swaps converters") { + val converter = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Path(DynamicOptic.root), "String") + val reverseConverter = DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Path(DynamicOptic.root), "Int") + val action = MigrationAction.ChangeType(DynamicOptic.root.field("x"), converter, reverseConverter) + val reversed = action.reverse.asInstanceOf[MigrationAction.ChangeType] + assertTrue( + reversed.converter == reverseConverter, + reversed.reverseConverter == converter + ) + } + ), + suite("Join")( + test("reverse is Split") { + val action = MigrationAction.Join( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("firstName"), DynamicOptic.root.field("lastName")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))), + DynamicSchemaExpr.Literal(DynamicValue.Sequence(Chunk.empty)) + ) + assertTrue(action.reverse.isInstanceOf[MigrationAction.Split]) + } + ), + suite("Split")( + test("reverse is Join") { + val action = MigrationAction.Split( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("firstName"), DynamicOptic.root.field("lastName")), + DynamicSchemaExpr.Literal(DynamicValue.Sequence(Chunk.empty)), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + assertTrue(action.reverse.isInstanceOf[MigrationAction.Join]) + } + ), + suite("RenameCase")( + test("reverse swaps from and to") { + val action = MigrationAction.RenameCase(DynamicOptic.root, "OldCase", "NewCase") + val reversed = action.reverse.asInstanceOf[MigrationAction.RenameCase] + assertTrue( + reversed.from == "NewCase", + reversed.to == "OldCase" + ) + } + ), + suite("TransformCase")( + test("reverse reverses nested actions in reverse order") { + val nestedActions = Vector( + MigrationAction.RenameField(DynamicOptic.root, "a", "b"), + MigrationAction.AddField( + DynamicOptic.root, + "c", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + val action = MigrationAction.TransformCase(DynamicOptic.root, "Case1", nestedActions) + val reversed = action.reverse.asInstanceOf[MigrationAction.TransformCase] + assertTrue( + reversed.caseName == "Case1", + reversed.actions.length == 2, + reversed.actions.head.isInstanceOf[MigrationAction.DropField] + ) + } + ), + suite("TransformElements")( + test("reverse swaps transforms") { + val transform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + val reverseTransform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))) + val action = MigrationAction.TransformElements(DynamicOptic.root.field("items"), transform, reverseTransform) + val reversed = action.reverse.asInstanceOf[MigrationAction.TransformElements] + assertTrue( + reversed.transform == reverseTransform, + reversed.reverseTransform == transform + ) + } + ), + suite("TransformKeys")( + test("reverse swaps transforms") { + val transform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("new"))) + val reverseTransform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("old"))) + val action = MigrationAction.TransformKeys(DynamicOptic.root.field("map"), transform, reverseTransform) + val reversed = action.reverse.asInstanceOf[MigrationAction.TransformKeys] + assertTrue( + reversed.transform == reverseTransform, + reversed.reverseTransform == transform + ) + } + ), + suite("TransformValues")( + test("reverse swaps transforms") { + val transform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))) + val reverseTransform = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + val action = MigrationAction.TransformValues(DynamicOptic.root.field("map"), transform, reverseTransform) + val reversed = action.reverse.asInstanceOf[MigrationAction.TransformValues] + assertTrue( + reversed.transform == reverseTransform, + reversed.reverseTransform == transform + ) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationBuilderCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationBuilderCoverageSpec.scala new file mode 100644 index 0000000000..646778613f --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationBuilderCoverageSpec.scala @@ -0,0 +1,293 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +object MigrationBuilderCoverageSpec extends SchemaBaseSpec { + + case class SimpleRecord(name: String, age: Int) + object SimpleRecord { implicit val schema: Schema[SimpleRecord] = Schema.derived } + + case class RecordWithEmail(name: String, age: Int, email: String) + object RecordWithEmail { implicit val schema: Schema[RecordWithEmail] = Schema.derived } + + case class RecordRenamed(fullName: String, age: Int) + object RecordRenamed { implicit val schema: Schema[RecordRenamed] = Schema.derived } + + case class RecordWithOptional(name: String, age: Int, nick: Option[String]) + object RecordWithOptional { implicit val schema: Schema[RecordWithOptional] = Schema.derived } + + case class WithList(items: List[Int]) + object WithList { implicit val schema: Schema[WithList] = Schema.derived } + + case class WithMap(data: Map[String, Int]) + object WithMap { implicit val schema: Schema[WithMap] = Schema.derived } + + sealed trait Animal + object Animal { + case class Dog(breed: String) extends Animal + case class Cat(color: String) extends Animal + implicit val schema: Schema[Animal] = Schema.derived + } + + private val root = DynamicOptic.root + private val litI = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + private val litS = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + + def spec: Spec[TestEnvironment, Any] = suite("MigrationBuilderCoverageSpec")( + suite("addField")( + test("addField with optic and default expr") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordWithEmail.schema, Vector.empty) + .addField(root.field("email"), litS) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.AddField]) + }, + test("addField with typed default value") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordWithEmail.schema, Vector.empty) + .addField[String](root.field("email"), "default") + assertTrue(builder.actions.length == 1) + } + ), + suite("dropField")( + test("dropField basic") { + val builder = new MigrationBuilder(RecordWithEmail.schema, SimpleRecord.schema, Vector.empty) + .dropField(root.field("email")) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.DropField]) + }, + test("dropField with custom reverse default") { + val builder = new MigrationBuilder(RecordWithEmail.schema, SimpleRecord.schema, Vector.empty) + .dropField(root.field("email"), litS) + assertTrue(builder.actions.length == 1) + } + ), + suite("renameField")( + test("renameField with optics") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordRenamed.schema, Vector.empty) + .renameField(root.field("name"), root.field("fullName")) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.RenameField]) + }, + test("renameField with strings") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordRenamed.schema, Vector.empty) + .renameField("name", "fullName") + assertTrue(builder.actions.length == 1) + } + ), + suite("transformField")( + test("transformField basic") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + .transformField(root.field("age"), litI) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.TransformValue]) + }, + test("transformField with reverse") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + .transformField(root.field("age"), litI, litI) + assertTrue(builder.actions.length == 1) + } + ), + suite("mandateField")( + test("mandateField with expr") { + val builder = new MigrationBuilder(RecordWithOptional.schema, SimpleRecord.schema, Vector.empty) + .mandateField(root.field("nick"), litS) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.Mandate]) + }, + test("mandateField with typed default") { + val builder = new MigrationBuilder(RecordWithOptional.schema, SimpleRecord.schema, Vector.empty) + .mandateField[String](root.field("nick"), "default") + assertTrue(builder.actions.length == 1) + } + ), + suite("optionalizeField")( + test("optionalizeField basic") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordWithOptional.schema, Vector.empty) + .optionalizeField(root.field("name")) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.Optionalize]) + }, + test("optionalizeField with reverse default") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordWithOptional.schema, Vector.empty) + .optionalizeField(root.field("name"), litS) + assertTrue(builder.actions.length == 1) + } + ), + suite("changeFieldType")( + test("changeFieldType with exprs") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + .changeFieldType(root.field("age"), litI, litI) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.ChangeType]) + }, + test("changeFieldType with type names") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + .changeFieldType(root.field("age"), "Long", "Int") + val action = builder.actions.head.asInstanceOf[MigrationAction.ChangeType] + assertTrue(action.converter.isInstanceOf[DynamicSchemaExpr.CoercePrimitive]) + }, + test("changeFieldType with type names on root path") { + val builder = new MigrationBuilder(Schema[Int], Schema[Long], Vector.empty) + .changeFieldType(root, "Long", "Int") + assertTrue(builder.actions.length == 1) + } + ), + suite("joinFields")( + test("joinFields creates Join action") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + .joinFields(root.field("combined"), Vector(root.field("a"), root.field("b")), litS, litS) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.Join]) + } + ), + suite("splitField")( + test("splitField creates Split action") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + .splitField(root.field("combined"), Vector(root.field("a"), root.field("b")), litS, litS) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.Split]) + } + ), + suite("renameCase")( + test("renameCase at root") { + val builder = new MigrationBuilder(Animal.schema, Animal.schema, Vector.empty) + .renameCase("Dog", "Hound") + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.RenameCase]) + }, + test("renameCaseAt specific path") { + val builder = new MigrationBuilder(Animal.schema, Animal.schema, Vector.empty) + .renameCaseAt(root.field("pet"), "Dog", "Hound") + val action = builder.actions.head.asInstanceOf[MigrationAction.RenameCase] + assertTrue(action.at.nodes.length == 1) + } + ), + suite("transformCase")( + test("transformCase creates nested actions") { + val builder = new MigrationBuilder(Animal.schema, Animal.schema, Vector.empty) + .transformCase("Dog", _.renameField("breed", "type")) + val action = builder.actions.head.asInstanceOf[MigrationAction.TransformCase] + assertTrue(action.caseName == "Dog" && action.actions.length == 1) + }, + test("transformCaseAt specific path") { + val builder = new MigrationBuilder(Animal.schema, Animal.schema, Vector.empty) + .transformCaseAt(root.field("pet"), "Cat", _.addField(root.field("indoor"), litS)) + val action = builder.actions.head.asInstanceOf[MigrationAction.TransformCase] + assertTrue(action.at.nodes.length == 1 && action.caseName == "Cat") + } + ), + suite("transformElements")( + test("creates TransformElements action") { + val builder = new MigrationBuilder(WithList.schema, WithList.schema, Vector.empty) + .transformElements(root.field("items"), litI) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.TransformElements]) + } + ), + suite("transformKeys")( + test("creates TransformKeys action") { + val builder = new MigrationBuilder(WithMap.schema, WithMap.schema, Vector.empty) + .transformKeys(root.field("data"), litS) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.TransformKeys]) + } + ), + suite("transformValues")( + test("creates TransformValues action") { + val builder = new MigrationBuilder(WithMap.schema, WithMap.schema, Vector.empty) + .transformValues(root.field("data"), litI) + assertTrue(builder.actions.length == 1 && builder.actions.head.isInstanceOf[MigrationAction.TransformValues]) + } + ), + suite("build methods")( + test("buildPartial creates migration without validation") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + val migration = builder.buildPartial + assertTrue(migration.isEmpty) + }, + test("build validates and succeeds for valid migration") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + val migration = builder.build + assertTrue(migration.isEmpty) + }, + test("build throws for invalid migration") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordWithEmail.schema, Vector.empty) + val caught = try { + builder.build + false + } catch { + case _: IllegalArgumentException => true + case _: Throwable => false + } + assertTrue(caught) + }, + test("buildValidated returns Right for valid") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + assertTrue(builder.buildValidated.isRight) + }, + test("buildValidated returns Left for invalid") { + val builder = new MigrationBuilder(SimpleRecord.schema, RecordWithEmail.schema, Vector.empty) + assertTrue(builder.buildValidated.isLeft) + } + ), + suite("splitPath")( + test("splitPath throws on non-field path") { + val builder = new MigrationBuilder(SimpleRecord.schema, SimpleRecord.schema, Vector.empty) + val caught = try { + builder.addField(DynamicOptic(Vector(DynamicOptic.Node.Elements)), litI) + false + } catch { + case _: IllegalArgumentException => true + case _: Throwable => false + } + assertTrue(caught) + } + ), + suite("MigrationBuilder.apply")( + test("creates builder from implicit schemas") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + assertTrue(builder.actions.isEmpty) + } + ), + suite("MigrationBuilder.paths helper")( + test("field single name") { + val p = MigrationBuilder.paths.field("name") + assertTrue(p.nodes.length == 1 && p.nodes.head == DynamicOptic.Node.Field("name")) + }, + test("field multiple names") { + val p = MigrationBuilder.paths.field("a", "b", "c") + assertTrue(p.nodes.length == 3) + }, + test("elements") { + val p = MigrationBuilder.paths.elements + assertTrue(p.nodes.head == DynamicOptic.Node.Elements) + }, + test("mapKeys") { + val p = MigrationBuilder.paths.mapKeys + assertTrue(p.nodes.head == DynamicOptic.Node.MapKeys) + }, + test("mapValues") { + val p = MigrationBuilder.paths.mapValues + assertTrue(p.nodes.head == DynamicOptic.Node.MapValues) + } + ), + suite("MigrationBuilder.exprs helper")( + test("literal creates Literal expr") { + val e = MigrationBuilder.exprs.literal(42) + assertTrue(e.isInstanceOf[DynamicSchemaExpr.Literal]) + }, + test("path with optic creates Path expr") { + val e = MigrationBuilder.exprs.path(root.field("x")) + assertTrue(e.isInstanceOf[DynamicSchemaExpr.Path]) + }, + test("path with string creates Path expr") { + val e = MigrationBuilder.exprs.path("name") + e match { + case DynamicSchemaExpr.Path(optic) => + assertTrue(optic.nodes.head == DynamicOptic.Node.Field("name")) + case _ => assertTrue(false) + } + }, + test("concat creates StringConcat expr") { + val e = MigrationBuilder.exprs.concat(litS, litS) + assertTrue(e.isInstanceOf[DynamicSchemaExpr.StringConcat]) + }, + test("defaultValue creates DefaultValue expr") { + val e = MigrationBuilder.exprs.defaultValue + assertTrue(e == DynamicSchemaExpr.DefaultValue) + }, + test("coerce creates CoercePrimitive expr") { + val e = MigrationBuilder.exprs.coerce(litI, "Long") + assertTrue(e.isInstanceOf[DynamicSchemaExpr.CoercePrimitive]) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationBuilderSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationBuilderSpec.scala new file mode 100644 index 0000000000..d91bdcbf36 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationBuilderSpec.scala @@ -0,0 +1,317 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object MigrationBuilderSpec extends SchemaBaseSpec { + + case class PersonV1(firstName: String, lastName: String) + object PersonV1 { + implicit val schema: Schema[PersonV1] = Schema.derived + } + + case class PersonV2(fullName: String, age: Int) + object PersonV2 { + implicit val schema: Schema[PersonV2] = Schema.derived + } + + case class SimpleRecord(a: String, b: Int) + object SimpleRecord { + implicit val schema: Schema[SimpleRecord] = Schema.derived + } + + case class ExtendedRecord(a: String, b: Int, c: Boolean, d: Double) + object ExtendedRecord { + implicit val schema: Schema[ExtendedRecord] = Schema.derived + } + + def spec: Spec[TestEnvironment, Any] = suite("MigrationBuilderSpec")( + suite("addField")( + test("adds a field with literal default value") { + val builder = MigrationBuilder[SimpleRecord, ExtendedRecord] + .addField(DynamicOptic.root.field("c"), true) + .addField(DynamicOptic.root.field("d"), 3.14) + val migration = builder.buildPartial + val result = migration(SimpleRecord("test", 42)) + assertTrue(result.isRight) + }, + test("adds a field with expression default") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .addField( + DynamicOptic.root.field("c"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + val migration = builder.buildPartial + val result = migration.applyDynamic( + DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.String("hello")), + "b" -> DynamicValue.Primitive(PrimitiveValue.Int(1)) + ) + ) + ) + assertTrue(result.isRight) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue(fields.exists(_._1 == "c")) + case _ => assertTrue(false) + } + } + ), + suite("dropField")( + test("drops a field from the record") { + val builder = MigrationBuilder[ExtendedRecord, SimpleRecord] + .dropField(DynamicOptic.root.field("c")) + .dropField(DynamicOptic.root.field("d")) + val migration = builder.buildPartial + val result = migration.applyDynamic( + DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.String("test")), + "b" -> DynamicValue.Primitive(PrimitiveValue.Int(1)), + "c" -> DynamicValue.Primitive(PrimitiveValue.Boolean(true)), + "d" -> DynamicValue.Primitive(PrimitiveValue.Double(1.5)) + ) + ) + ) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue( + fields.length == 2, + !fields.exists(_._1 == "c"), + !fields.exists(_._1 == "d") + ) + case _ => assertTrue(false) + } + } + ), + suite("renameField")( + test("renames a field using path") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .renameField(DynamicOptic.root.field("a"), DynamicOptic.root.field("alpha")) + val migration = builder.buildPartial + val result = migration.applyDynamic( + DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.String("test")), + "b" -> DynamicValue.Primitive(PrimitiveValue.Int(1)) + ) + ) + ) + result match { + case Right(DynamicValue.Record(fields)) => + assertTrue( + fields.exists(_._1 == "alpha"), + !fields.exists(_._1 == "a") + ) + case _ => assertTrue(false) + } + }, + test("renames a field using string names") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .renameField("a", "x") + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + } + ), + suite("transformField")( + test("transforms a field value") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .transformField( + DynamicOptic.root.field("b"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(10))), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + ) + val migration = builder.buildPartial + val result = migration.applyDynamic( + DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.String("test")), + "b" -> DynamicValue.Primitive(PrimitiveValue.Int(5)) + ) + ) + ) + result match { + case Right(DynamicValue.Record(fields)) => + val bValue = fields.find(_._1 == "b").map(_._2) + assertTrue(bValue == Some(DynamicValue.Primitive(PrimitiveValue.Int(15)))) + case _ => assertTrue(false) + } + } + ), + suite("mandateField")( + test("makes optional field mandatory with literal default") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .mandateField(DynamicOptic.root.field("opt"), 100) + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + } + ), + suite("optionalizeField")( + test("makes field optional") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .optionalizeField(DynamicOptic.root.field("b")) + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + }, + test("supports reverse default expression") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .optionalizeField( + DynamicOptic.root.field("b"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + val migration = builder.buildPartial + val action = migration.actions.head.asInstanceOf[MigrationAction.Optionalize] + assertTrue(action.defaultForReverse.isInstanceOf[DynamicSchemaExpr.Literal]) + } + ), + suite("changeFieldType")( + test("changes field type using expression") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .changeFieldType( + DynamicOptic.root.field("b"), + DynamicSchemaExpr.CoercePrimitive( + DynamicSchemaExpr.Path(DynamicOptic.root), + "String" + ) + ) + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + }, + test("changes field type using type names") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .changeFieldType(DynamicOptic.root.field("b"), "String", "Int") + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + } + ), + suite("renameCase")( + test("renames a variant case") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .renameCase("OldCase", "NewCase") + val migration = builder.buildPartial + val action = migration.actions.head.asInstanceOf[MigrationAction.RenameCase] + assertTrue(action.from == "OldCase" && action.to == "NewCase") + }, + test("renames a case at specific path") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .renameCaseAt(DynamicOptic.root.field("status"), "Active", "Enabled") + val migration = builder.buildPartial + val action = migration.actions.head.asInstanceOf[MigrationAction.RenameCase] + assertTrue( + action.from == "Active", + action.to == "Enabled", + action.at.nodes.nonEmpty + ) + } + ), + suite("transformCase")( + test("transforms a specific case") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .transformCase("MyCase", _.renameField("old", "new")) + val migration = builder.buildPartial + val action = migration.actions.head.asInstanceOf[MigrationAction.TransformCase] + assertTrue( + action.caseName == "MyCase", + action.actions.length == 1 + ) + } + ), + suite("transformElements")( + test("transforms collection elements") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .transformElements( + DynamicOptic.root.field("items"), + DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Path(DynamicOptic.root), "String") + ) + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + } + ), + suite("transformKeys")( + test("transforms map keys") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .transformKeys( + DynamicOptic.root.field("data"), + DynamicSchemaExpr.StringConcat( + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("key_"))), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + } + ), + suite("transformValues")( + test("transforms map values") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .transformValues( + DynamicOptic.root.field("data"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.ArithmeticOperator.Add + ) + ) + val migration = builder.buildPartial + assertTrue(migration.actions.length == 1) + } + ), + suite("build and buildPartial")( + test("buildPartial creates migration without validation") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .renameField("a", "b") + .renameField("b", "c") + val migration = builder.buildPartial + assertTrue(migration.actions.length == 2) + }, + test("build creates migration (same as buildPartial for now)") { + val builder = MigrationBuilder[SimpleRecord, SimpleRecord] + .transformField( + DynamicOptic.root.field("b"), + DynamicSchemaExpr.Path(DynamicOptic.root) // identity transform + ) + val migration = builder.build + assertTrue(migration.actions.length == 1) + } + ), + suite("Fluent chaining")( + test("chains multiple operations") { + val builder = MigrationBuilder[PersonV1, PersonV2] + .addField(DynamicOptic.root.field("age"), 0) + .renameField("firstName", "fullName") + .dropField(DynamicOptic.root.field("lastName")) + val migration = builder.buildPartial + assertTrue(migration.actions.length == 3) + } + ), + suite("Helper syntax")( + test("paths helper creates DynamicOptic") { + val path = MigrationBuilder.paths.field("a") + assertTrue(path == DynamicOptic.root.field("a")) + }, + test("paths helper creates nested path") { + val path = MigrationBuilder.paths.field("a", "b", "c") + assertTrue(path == DynamicOptic.root.field("a").field("b").field("c")) + }, + test("exprs literal creates Literal expression") { + val expr = MigrationBuilder.exprs.literal(42) + assertTrue(expr.isInstanceOf[DynamicSchemaExpr.Literal]) + }, + test("exprs path creates Path expression") { + val expr = MigrationBuilder.exprs.path("fieldName") + assertTrue(expr.isInstanceOf[DynamicSchemaExpr.Path]) + }, + test("exprs concat creates StringConcat expression") { + val left = MigrationBuilder.exprs.literal("hello") + val right = MigrationBuilder.exprs.literal(" world") + val expr = MigrationBuilder.exprs.concat(left, right) + assertTrue(expr.isInstanceOf[DynamicSchemaExpr.StringConcat]) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationCoverageSpec.scala new file mode 100644 index 0000000000..e9c00c80d7 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationCoverageSpec.scala @@ -0,0 +1,52 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +/** + * Supplementary coverage tests for [[Migration]]. + * + * Covers factory methods and accessors not exercised by [[MigrationSpec]], + * which already covers identity, apply, compose, and reverse semantics. + */ +object MigrationCoverageSpec extends SchemaBaseSpec { + + case class PersonV1(name: String, age: Int) + object PersonV1 { implicit val schema: Schema[PersonV1] = Schema.derived } + + case class PersonV2(name: String, age: Int, email: String) + object PersonV2 { implicit val schema: Schema[PersonV2] = Schema.derived } + + private val root = DynamicOptic.root + private val litS = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default@test.com"))) + + def spec: Spec[TestEnvironment, Any] = suite("MigrationCoverageSpec")( + test("newBuilder creates an empty builder") { + val builder = Migration.newBuilder[PersonV1, PersonV2] + assertTrue(builder.actions.isEmpty) + }, + test("fromAction creates migration with single action") { + val m = Migration.fromAction[PersonV1, PersonV1](MigrationAction.Identity) + assertTrue(m.actions.length == 1) + }, + test("fromDynamic wraps a DynamicMigration") { + val dm = DynamicMigration(MigrationAction.Identity) + val m = Migration.fromDynamic[PersonV1, PersonV1](dm) + assertTrue(m.nonEmpty) + }, + test("applyDynamic works with DynamicValue") { + val m = Migration + .newBuilder[PersonV1, PersonV2] + .addField(root.field("email"), litS) + .buildPartial + val dv = PersonV1.schema.toDynamicValue(PersonV1("Bob", 25)) + val result = m.applyDynamic(dv) + assertTrue(result.isRight) + }, + test("actions accessor returns underlying actions") { + val actions = Vector(MigrationAction.Identity, MigrationAction.RenameField(root, "a", "b")) + val m = Migration.fromDynamic[PersonV1, PersonV1](new DynamicMigration(actions)) + assertTrue(m.actions == actions) + } + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationErrorCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationErrorCoverageSpec.scala new file mode 100644 index 0000000000..128b117bc2 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationErrorCoverageSpec.scala @@ -0,0 +1,75 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +/** + * Supplementary coverage tests for [[MigrationError]]. + * + * Covers error type message formats and accessors not exercised by + * [[MigrationErrorSpec]], which already covers FieldNotFound, + * FieldAlreadyExists, NotARecord, TypeConversionFailed, DefaultValueMissing, + * ++, and getMessage. + */ +object MigrationErrorCoverageSpec extends SchemaBaseSpec { + + private val root = DynamicOptic.root + private val path = root.field("x") + + def spec: Spec[TestEnvironment, Any] = suite("MigrationErrorCoverageSpec")( + test("single creates error with one entry") { + val e = MigrationError.single(MigrationError.FieldNotFound(root, "x")) + assertTrue(e.errors.length == 1) + }, + test("FieldNotFound preserves path accessor") { + val e = MigrationError.FieldNotFound(path, "name") + assertTrue(e.path == path && e.fieldName == "name") + }, + suite("error type message formats")( + test("NotAVariant message format") { + val e = MigrationError.NotAVariant(path, "Record") + assertTrue(e.message.contains("variant") && e.message.contains("Record")) + }, + test("NotASequence message format") { + val e = MigrationError.NotASequence(path, "Map") + assertTrue(e.message.contains("sequence") && e.message.contains("Map")) + }, + test("NotAMap message format") { + val e = MigrationError.NotAMap(path, "Primitive") + assertTrue(e.message.contains("map") && e.message.contains("Primitive")) + }, + test("OptionalMismatch message format") { + val e = MigrationError.OptionalMismatch(path, "expected optional") + assertTrue(e.message == "expected optional") + }, + test("CaseNotFound message format") { + val e = MigrationError.CaseNotFound(path, "Dog") + assertTrue(e.message.contains("Dog") && e.message.contains("not found")) + }, + test("TransformFailed message format") { + val e = MigrationError.TransformFailed(path, "bad expression") + assertTrue(e.message.contains("Transform failed") && e.message.contains("bad expression")) + }, + test("PathNavigationFailed message format") { + val e = MigrationError.PathNavigationFailed(path, "not found") + assertTrue(e.message.contains("navigate") && e.message.contains("not found")) + }, + test("IndexOutOfBounds message format") { + val e = MigrationError.IndexOutOfBounds(path, 5, 3) + assertTrue(e.message.contains("5") && e.message.contains("3")) + }, + test("KeyNotFound message format") { + val e = MigrationError.KeyNotFound(path, "myKey") + assertTrue(e.message.contains("myKey") && e.message.contains("not found")) + }, + test("NumericOverflow message format") { + val e = MigrationError.NumericOverflow(path, "multiply") + assertTrue(e.message.contains("overflow") && e.message.contains("multiply")) + }, + test("ActionFailed message format") { + val e = MigrationError.ActionFailed(path, "Split", "wrong count") + assertTrue(e.message.contains("Split") && e.message.contains("wrong count")) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationErrorSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationErrorSpec.scala new file mode 100644 index 0000000000..fcb7f7994f --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationErrorSpec.scala @@ -0,0 +1,66 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +object MigrationErrorSpec extends SchemaBaseSpec { + def spec: Spec[TestEnvironment, Any] = suite("MigrationErrorSpec")( + suite("Single error types")( + test("FieldNotFound provides meaningful message") { + val error = MigrationError.FieldNotFound(DynamicOptic.root.field("person"), "name") + assertTrue( + error.message.contains("name"), + error.message.contains("not found"), + error.path == DynamicOptic.root.field("person") + ) + }, + test("FieldAlreadyExists provides meaningful message") { + val error = MigrationError.FieldAlreadyExists(DynamicOptic.root.field("person"), "age") + assertTrue( + error.message.contains("age"), + error.message.contains("already exists"), + error.path == DynamicOptic.root.field("person") + ) + }, + test("NotARecord provides meaningful message") { + val error = MigrationError.NotARecord(DynamicOptic.root.field("data"), "Sequence") + assertTrue( + error.message.contains("record"), + error.message.contains("Sequence"), + error.path == DynamicOptic.root.field("data") + ) + }, + test("TypeConversionFailed provides meaningful message") { + val error = MigrationError.TypeConversionFailed(DynamicOptic.root, "String", "Int", "Invalid format") + assertTrue( + error.message.contains("String"), + error.message.contains("Int"), + error.message.contains("Invalid format") + ) + }, + test("DefaultValueMissing provides meaningful message") { + val error = MigrationError.DefaultValueMissing(DynamicOptic.root.field("config"), "port") + assertTrue( + error.message.contains("Default value"), + error.message.contains("port") + ) + } + ), + suite("MigrationError composition")( + test("can combine multiple errors with ++") { + val error1 = MigrationError.single(MigrationError.FieldNotFound(DynamicOptic.root, "a")) + val error2 = MigrationError.single(MigrationError.FieldNotFound(DynamicOptic.root, "b")) + val combined = error1 ++ error2 + assertTrue( + combined.errors.length == 2, + combined.message.contains("a"), + combined.message.contains("b") + ) + }, + test("getMessage returns formatted error message") { + val error = MigrationError.single(MigrationError.TransformFailed(DynamicOptic.root, "Test error")) + assertTrue(error.getMessage == error.message) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSchemasCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSchemasCoverageSpec.scala new file mode 100644 index 0000000000..51bd1557f1 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSchemasCoverageSpec.scala @@ -0,0 +1,262 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.schema.json.{DiscriminatorKind, JsonBinaryCodecDeriver} +import zio.test._ + +object MigrationSchemasCoverageSpec extends SchemaBaseSpec { + import MigrationSchemas._ + + private val deriver = JsonBinaryCodecDeriver.withDiscriminatorKind(DiscriminatorKind.Field("_type")) + + private def roundTrip[A: Schema](a: A): Boolean = { + val codec = Schema[A].derive(deriver) + val json = codec.encodeToString(a) + codec.decode(json) == Right(a) + } + + private val root = DynamicOptic.root + private val path = root.field("name") + private val litI = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42))) + private val litS = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("x"))) + private val pathE = DynamicSchemaExpr.Path(root.field("age")) + + def spec: Spec[TestEnvironment, Any] = suite("MigrationSchemasCoverageSpec")( + suite("DynamicSchemaExpr round-trip serialization")( + test("Literal") { + assertTrue(roundTrip[DynamicSchemaExpr](litI)) + }, + test("Path") { + assertTrue(roundTrip[DynamicSchemaExpr](pathE)) + }, + test("DefaultValue") { + assertTrue(roundTrip[DynamicSchemaExpr](DynamicSchemaExpr.DefaultValue)) + }, + test("ResolvedDefault") { + assertTrue( + roundTrip[DynamicSchemaExpr]( + DynamicSchemaExpr.ResolvedDefault(DynamicValue.Primitive(PrimitiveValue.String("def"))) + ) + ) + }, + test("Not") { + assertTrue(roundTrip[DynamicSchemaExpr](DynamicSchemaExpr.Not(litI))) + }, + test("Logical And") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Logical(litI, litS, DynamicSchemaExpr.LogicalOperator.And) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Logical Or") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Logical(litI, litS, DynamicSchemaExpr.LogicalOperator.Or) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Relational LessThan") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Relational(litI, litI, DynamicSchemaExpr.RelationalOperator.LessThan) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Relational LessThanOrEqual") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Relational(litI, litI, DynamicSchemaExpr.RelationalOperator.LessThanOrEqual) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Relational GreaterThan") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Relational(litI, litI, DynamicSchemaExpr.RelationalOperator.GreaterThan) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Relational GreaterThanOrEqual") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Relational(litI, litI, DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Relational Equal") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Relational(litI, litI, DynamicSchemaExpr.RelationalOperator.Equal) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Relational NotEqual") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Relational(litI, litI, DynamicSchemaExpr.RelationalOperator.NotEqual) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Arithmetic Add") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Arithmetic(litI, litI, DynamicSchemaExpr.ArithmeticOperator.Add) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Arithmetic Subtract") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Arithmetic(litI, litI, DynamicSchemaExpr.ArithmeticOperator.Subtract) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("Arithmetic Multiply") { + val expr: DynamicSchemaExpr = + DynamicSchemaExpr.Arithmetic(litI, litI, DynamicSchemaExpr.ArithmeticOperator.Multiply) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("StringConcat") { + val expr: DynamicSchemaExpr = DynamicSchemaExpr.StringConcat(litS, litS) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("StringLength") { + val expr: DynamicSchemaExpr = DynamicSchemaExpr.StringLength(litS) + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + }, + test("CoercePrimitive") { + val expr: DynamicSchemaExpr = DynamicSchemaExpr.CoercePrimitive(litI, "String") + assertTrue(roundTrip[DynamicSchemaExpr](expr)) + } + ), + suite("MigrationAction round-trip serialization")( + test("AddField") { + val a: MigrationAction = MigrationAction.AddField(root, "x", litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("DropField") { + val a: MigrationAction = MigrationAction.DropField(root, "x", litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("RenameField") { + val a: MigrationAction = MigrationAction.RenameField(root, "old", "new") + assertTrue(roundTrip[MigrationAction](a)) + }, + test("TransformValue") { + val a: MigrationAction = MigrationAction.TransformValue(path, litI, litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("Mandate") { + val a: MigrationAction = MigrationAction.Mandate(path, litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("Optionalize") { + val a: MigrationAction = MigrationAction.Optionalize(path, litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("ChangeType") { + val a: MigrationAction = MigrationAction.ChangeType(path, litI, litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("Join") { + val a: MigrationAction = MigrationAction.Join(path, Vector(root.field("a"), root.field("b")), litS, litS) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("Split") { + val a: MigrationAction = MigrationAction.Split(path, Vector(root.field("a"), root.field("b")), litS, litS) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("RenameCase") { + val a: MigrationAction = MigrationAction.RenameCase(root, "Old", "New") + assertTrue(roundTrip[MigrationAction](a)) + }, + test("TransformCase") { + val a: MigrationAction = MigrationAction.TransformCase( + root, + "Active", + Vector(MigrationAction.RenameField(root, "a", "b")) + ) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("TransformElements") { + val a: MigrationAction = MigrationAction.TransformElements(root, litI, litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("TransformKeys") { + val a: MigrationAction = MigrationAction.TransformKeys(root, litS, litS) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("TransformValues") { + val a: MigrationAction = MigrationAction.TransformValues(root, litI, litI) + assertTrue(roundTrip[MigrationAction](a)) + }, + test("Identity") { + val a: MigrationAction = MigrationAction.Identity + assertTrue(roundTrip[MigrationAction](a)) + } + ), + suite("DynamicMigration round-trip serialization")( + test("empty migration") { + assertTrue(roundTrip[DynamicMigration](DynamicMigration.empty)) + }, + test("single action migration") { + assertTrue(roundTrip[DynamicMigration](DynamicMigration(MigrationAction.Identity))) + }, + test("multi-action migration") { + val m = DynamicMigration( + Vector( + MigrationAction.AddField(root, "x", litI), + MigrationAction.RenameField(root, "a", "b"), + MigrationAction.DropField(root, "c", litS) + ) + ) + assertTrue(roundTrip[DynamicMigration](m)) + }, + test("complex migration with nested expressions") { + val m = DynamicMigration( + Vector( + MigrationAction.TransformValue( + root.field("age"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(root.field("age")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.ArithmeticOperator.Add + ), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(root.field("age")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))), + DynamicSchemaExpr.ArithmeticOperator.Subtract + ) + ), + MigrationAction.Optionalize(root.field("name")), + MigrationAction.Mandate( + root.field("email"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("unknown@test.com"))) + ) + ) + ) + assertTrue(roundTrip[DynamicMigration](m)) + } + ), + suite("Operator schemas")( + test("LogicalOperator And discriminator") { + val codec = Schema[DynamicSchemaExpr.LogicalOperator].derive(deriver) + val json = codec.encodeToString(DynamicSchemaExpr.LogicalOperator.And) + assertTrue(codec.decode(json) == Right(DynamicSchemaExpr.LogicalOperator.And)) + }, + test("LogicalOperator Or discriminator") { + val codec = Schema[DynamicSchemaExpr.LogicalOperator].derive(deriver) + val json = codec.encodeToString(DynamicSchemaExpr.LogicalOperator.Or) + assertTrue(codec.decode(json) == Right(DynamicSchemaExpr.LogicalOperator.Or)) + }, + test("RelationalOperator all variants") { + val codec = Schema[DynamicSchemaExpr.RelationalOperator].derive(deriver) + val ops = Vector( + DynamicSchemaExpr.RelationalOperator.LessThan, + DynamicSchemaExpr.RelationalOperator.LessThanOrEqual, + DynamicSchemaExpr.RelationalOperator.GreaterThan, + DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual, + DynamicSchemaExpr.RelationalOperator.Equal, + DynamicSchemaExpr.RelationalOperator.NotEqual + ) + assertTrue(ops.forall { op => + val json = codec.encodeToString(op) + codec.decode(json) == Right(op) + }) + }, + test("ArithmeticOperator all variants") { + val codec = Schema[DynamicSchemaExpr.ArithmeticOperator].derive(deriver) + val ops = Vector( + DynamicSchemaExpr.ArithmeticOperator.Add, + DynamicSchemaExpr.ArithmeticOperator.Subtract, + DynamicSchemaExpr.ArithmeticOperator.Multiply + ) + assertTrue(ops.forall { op => + val json = codec.encodeToString(op) + codec.decode(json) == Right(op) + }) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSchemasSerializationSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSchemasSerializationSpec.scala new file mode 100644 index 0000000000..824a32078c --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSchemasSerializationSpec.scala @@ -0,0 +1,43 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.schema.json.{DiscriminatorKind, JsonBinaryCodecDeriver} +import zio.test._ + +object MigrationSchemasSerializationSpec extends SchemaBaseSpec { + import MigrationSchemas._ + + // Use a discriminator for variant types so decoding works correctly + private val deriver = JsonBinaryCodecDeriver.withDiscriminatorKind(DiscriminatorKind.Field("_type")) + + def spec: Spec[TestEnvironment, Any] = + suite("MigrationSchemasSerializationSpec")( + test("DynamicMigration encodes/decodes via JsonBinaryCodec") { + val migration = + DynamicMigration( + Vector( + MigrationAction.AddField( + DynamicOptic.root, + "country", + DynamicSchemaExpr.ResolvedDefault(DynamicValue.Primitive(PrimitiveValue.String("US"))) + ), + MigrationAction.RenameField(DynamicOptic.root, "name", "fullName"), + MigrationAction.TransformValue( + DynamicOptic.root.field("age"), + DynamicSchemaExpr.Arithmetic( + DynamicSchemaExpr.Path(DynamicOptic.root.field("age")), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))), + DynamicSchemaExpr.ArithmeticOperator.Multiply + ), + DynamicSchemaExpr.Path(DynamicOptic.root.field("age")) + ) + ) + ) + + val codec = Schema[DynamicMigration].derive(deriver) + val json = codec.encodeToString(migration) + + assertTrue(codec.decode(json) == Right(migration)) + } + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSpec.scala new file mode 100644 index 0000000000..14a5129c32 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSpec.scala @@ -0,0 +1,206 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object MigrationSpec extends SchemaBaseSpec { + + // Test case classes + case class PersonV1(name: String, age: Int) + object PersonV1 { + implicit val schema: Schema[PersonV1] = Schema.derived + } + + case class PersonV2(fullName: String, age: Int, country: String) + object PersonV2 { + implicit val schema: Schema[PersonV2] = Schema.derived + } + + case class SimpleRecord(a: String, b: Int) + object SimpleRecord { + implicit val schema: Schema[SimpleRecord] = Schema.derived + } + + case class RenamedRecord(x: String, y: Int) + object RenamedRecord { + implicit val schema: Schema[RenamedRecord] = Schema.derived + } + + def spec: Spec[TestEnvironment, Any] = suite("MigrationSpec")( + suite("Migration.identity")( + test("identity migration returns unchanged value") { + val person = PersonV1("Alice", 30) + val migration = Migration.identity[PersonV1] + val result = migration(person) + assertTrue(result == Right(person)) + }, + test("identity migration has empty actions") { + val migration = Migration.identity[PersonV1] + assertTrue(migration.isEmpty) + } + ), + suite("Migration.apply")( + test("applies migration to typed value") { + val source = SimpleRecord("hello", 42) + val migration = Migration + .newBuilder[SimpleRecord, RenamedRecord] + .renameField("a", "x") + .renameField("b", "y") + .buildPartial + val result = migration(source) + assertTrue(result == Right(RenamedRecord("hello", 42))) + }, + test("returns error on migration failure") { + // Create a migration that will fail (trying to drop a field that doesn't exist in the shape we test) + val migration = Migration.fromAction[SimpleRecord, SimpleRecord]( + MigrationAction.DropField( + DynamicOptic.root, + "nonexistent", + DynamicSchemaExpr.DefaultValue + ) + ) + val result = migration(SimpleRecord("test", 1)) + assertTrue(result.isLeft) + } + ), + suite("Migration.applyDynamic")( + test("applies migration on DynamicValue directly") { + val dynamicValue = DynamicValue.Record( + Chunk( + "a" -> DynamicValue.Primitive(PrimitiveValue.String("test")), + "b" -> DynamicValue.Primitive(PrimitiveValue.Int(10)) + ) + ) + val migration = Migration + .newBuilder[SimpleRecord, RenamedRecord] + .renameField("a", "x") + .renameField("b", "y") + .buildPartial + val result = migration.applyDynamic(dynamicValue) + assertTrue( + result == Right( + DynamicValue.Record( + Chunk( + "x" -> DynamicValue.Primitive(PrimitiveValue.String("test")), + "y" -> DynamicValue.Primitive(PrimitiveValue.Int(10)) + ) + ) + ) + ) + } + ), + suite("Migration.++")( + test("composes two migrations") { + val m1 = Migration.fromAction[SimpleRecord, SimpleRecord]( + MigrationAction.AddField( + DynamicOptic.root, + "c", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + ) + val m2 = Migration.fromAction[SimpleRecord, SimpleRecord]( + MigrationAction.RenameField(DynamicOptic.root, "a", "alpha") + ) + val combined = m1 ++ m2 + assertTrue(combined.actions.length == 2) + }, + test("andThen is alias for ++") { + val m1 = Migration.identity[SimpleRecord] + val m2 = Migration.identity[SimpleRecord] + assertTrue(m1.andThen(m2).actions == (m1 ++ m2).actions) + } + ), + suite("Migration.reverse")( + test("reverse swaps source and target schemas") { + val migration = Migration + .newBuilder[SimpleRecord, RenamedRecord] + .renameField("a", "x") + .renameField("b", "y") + .buildPartial + val reversed = migration.reverse + assertTrue( + reversed.sourceSchema == migration.targetSchema, + reversed.targetSchema == migration.sourceSchema + ) + }, + test("reverse creates reversed actions") { + val migration = Migration.fromAction[SimpleRecord, SimpleRecord]( + MigrationAction.RenameField(DynamicOptic.root, "a", "b") + ) + val reversed = migration.reverse + val action = reversed.actions.head.asInstanceOf[MigrationAction.RenameField] + assertTrue(action.from == "b" && action.to == "a") + } + ), + suite("Migration.fromActions")( + test("creates migration from multiple actions") { + val migration = Migration.fromActions[SimpleRecord, SimpleRecord]( + MigrationAction.RenameField(DynamicOptic.root, "a", "alpha"), + MigrationAction.AddField( + DynamicOptic.root, + "c", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + ) + assertTrue(migration.actions.length == 2) + } + ), + suite("Migration.fromDynamic")( + test("wraps a DynamicMigration with schemas") { + val dynamicMigration = DynamicMigration( + Vector( + MigrationAction.RenameField(DynamicOptic.root, "a", "x") + ) + ) + val migration = Migration.fromDynamic[SimpleRecord, RenamedRecord](dynamicMigration) + assertTrue( + migration.dynamicMigration == dynamicMigration, + migration.nonEmpty + ) + } + ), + suite("Laws")( + test("identity law: Migration.identity[A].apply(a) == Right(a)") { + val value = SimpleRecord("test", 42) + val identity = Migration.identity[SimpleRecord] + assertTrue(identity(value) == Right(value)) + }, + test("associativity: (m1 ++ m2) ++ m3 == m1 ++ (m2 ++ m3) structurally") { + val m1 = Migration.fromAction[SimpleRecord, SimpleRecord]( + MigrationAction.AddField( + DynamicOptic.root, + "c", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1))) + ) + ) + val m2 = Migration.fromAction[SimpleRecord, SimpleRecord]( + MigrationAction.AddField( + DynamicOptic.root, + "d", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))) + ) + ) + val m3 = Migration.fromAction[SimpleRecord, SimpleRecord]( + MigrationAction.AddField( + DynamicOptic.root, + "e", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(3))) + ) + ) + val left = (m1 ++ m2) ++ m3 + val right = m1 ++ (m2 ++ m3) + assertTrue(left.actions.length == right.actions.length) + }, + test("structural reverse: m.reverse.reverse has same action count") { + val migration = Migration.fromActions[SimpleRecord, SimpleRecord]( + MigrationAction + .AddField(DynamicOptic.root, "c", DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(1)))), + MigrationAction.RenameField(DynamicOptic.root, "a", "alpha") + ) + val doubleReversed = migration.reverse.reverse + assertTrue(migration.actions.length == doubleReversed.actions.length) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorCoverageSpec.scala new file mode 100644 index 0000000000..47dcda00e2 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorCoverageSpec.scala @@ -0,0 +1,797 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +object MigrationValidatorCoverageSpec extends SchemaBaseSpec { + + case class SimpleRecord(name: String, age: Int) + object SimpleRecord { implicit val schema: Schema[SimpleRecord] = Schema.derived } + + case class RecordWithEmail(name: String, age: Int, email: String) + object RecordWithEmail { implicit val schema: Schema[RecordWithEmail] = Schema.derived } + + case class RecordRenamed(fullName: String, age: Int) + object RecordRenamed { implicit val schema: Schema[RecordRenamed] = Schema.derived } + + case class RecordWithOptional(name: String, age: Int, nick: Option[String]) + object RecordWithOptional { implicit val schema: Schema[RecordWithOptional] = Schema.derived } + + case class RecordMandated(name: String, age: Int, nick: String) + object RecordMandated { implicit val schema: Schema[RecordMandated] = Schema.derived } + + case class Nested(person: SimpleRecord) + object Nested { implicit val schema: Schema[Nested] = Schema.derived } + + case class NestedEmail(person: RecordWithEmail) + object NestedEmail { implicit val schema: Schema[NestedEmail] = Schema.derived } + + sealed trait Animal + object Animal { + case class Dog(breed: String) extends Animal + case class Cat(color: String) extends Animal + implicit val schema: Schema[Animal] = Schema.derived + } + + sealed trait AnimalRenamed + object AnimalRenamed { + case class Hound(breed: String) extends AnimalRenamed + case class Cat(color: String) extends AnimalRenamed + implicit val schema: Schema[AnimalRenamed] = Schema.derived + } + + case class WithList(items: List[Int]) + object WithList { implicit val schema: Schema[WithList] = Schema.derived } + + case class WithMap(data: Map[String, Int]) + object WithMap { implicit val schema: Schema[WithMap] = Schema.derived } + + case class RecordAB(a: String, b: String) + object RecordAB { implicit val schema: Schema[RecordAB] = Schema.derived } + + case class RecordC(c: String) + object RecordC { implicit val schema: Schema[RecordC] = Schema.derived } + + case class RecordJoined(a: String, combined: String) + object RecordJoined { implicit val schema: Schema[RecordJoined] = Schema.derived } + + case class ValueInt(value: Int) + object ValueInt { implicit val schema: Schema[ValueInt] = Schema.derived } + + case class ValueStr(value: String) + object ValueStr { implicit val schema: Schema[ValueStr] = Schema.derived } + + case class ValueList(items: List[Int]) + object ValueList { implicit val schema: Schema[ValueList] = Schema.derived } + + case class ValueDyn(value: DynamicValue) + object ValueDyn { implicit val schema: Schema[ValueDyn] = Schema.derived } + + private val root = DynamicOptic.root + private val litI = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + private val litS = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + + def spec: Spec[TestEnvironment, Any] = suite("MigrationValidatorCoverageSpec")( + suite("extractStructure")( + test("extracts primitive type in fields") { + val s = MigrationValidator.extractStructure(SimpleRecord.schema) + s match { + case r: MigrationValidator.SchemaStructure.Record => + assertTrue(r.fields("age").isInstanceOf[MigrationValidator.SchemaStructure.Primitive]) + case _ => assertTrue(false) + } + } + ), + suite("SchemaStructure fieldNames")( + test("Record fieldNames") { + val r = MigrationValidator.SchemaStructure + .Record("Test", Map("a" -> MigrationValidator.SchemaStructure.Dynamic), Map("a" -> false)) + assertTrue(r.fieldNames == Set("a")) + }, + test("Variant fieldNames returns cases") { + val v = + MigrationValidator.SchemaStructure.Variant("Test", Map("A" -> MigrationValidator.SchemaStructure.Dynamic)) + assertTrue(v.fieldNames == Set("A")) + }, + test("Sequence fieldNames is empty") { + val s = MigrationValidator.SchemaStructure.Sequence(MigrationValidator.SchemaStructure.Dynamic) + assertTrue(s.fieldNames.isEmpty) + }, + test("MapType fieldNames is empty") { + val m = MigrationValidator.SchemaStructure + .MapType(MigrationValidator.SchemaStructure.Dynamic, MigrationValidator.SchemaStructure.Dynamic) + assertTrue(m.fieldNames.isEmpty) + }, + test("Primitive fieldNames is empty") { + val p = MigrationValidator.SchemaStructure.Primitive("Int") + assertTrue(p.fieldNames.isEmpty) + }, + test("Optional fieldNames is empty") { + val o = MigrationValidator.SchemaStructure.Optional(MigrationValidator.SchemaStructure.Dynamic) + assertTrue(o.fieldNames.isEmpty) + }, + test("Dynamic fieldNames is empty") { + assertTrue(MigrationValidator.SchemaStructure.Dynamic.fieldNames.isEmpty) + } + ), + suite("ValidationResult")( + test("Valid ++ Valid = Valid") { + val r = MigrationValidator.Valid ++ MigrationValidator.Valid + assertTrue(r.isValid) + }, + test("Valid ++ Invalid = Invalid") { + val r = MigrationValidator.Valid ++ MigrationValidator.Invalid("err") + assertTrue(!r.isValid && r.errors == List("err")) + }, + test("Invalid ++ Valid = Invalid") { + val r = MigrationValidator.Invalid("err") ++ MigrationValidator.Valid + assertTrue(!r.isValid && r.errors == List("err")) + }, + test("Invalid ++ Invalid combines errors") { + val r = MigrationValidator.Invalid("err1") ++ MigrationValidator.Invalid("err2") + assertTrue(!r.isValid && r.errors == List("err1", "err2")) + }, + test("Valid.isValid is true") { + assertTrue(MigrationValidator.Valid.isValid) + }, + test("Valid.errors is Nil") { + assertTrue(MigrationValidator.Valid.errors == Nil) + }, + test("Invalid.isValid is false") { + assertTrue(!MigrationValidator.Invalid("x").isValid) + } + ), + suite("validate AddField")( + test("valid AddField to record") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + RecordWithEmail.schema, + Vector(MigrationAction.AddField(root, "email", litS)) + ) + assertTrue(result.isValid) + }, + test("AddField already exists fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.AddField(root, "name", litS)) + ) + assertTrue(!result.isValid) + }, + test("AddField to non-record fails") { + val result = MigrationValidator.validate( + Schema[Int], + Schema[Int], + Vector(MigrationAction.AddField(root, "x", litI)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate DropField")( + test("valid DropField from record") { + val result = MigrationValidator.validate( + RecordWithEmail.schema, + SimpleRecord.schema, + Vector(MigrationAction.DropField(root, "email", litS)) + ) + assertTrue(result.isValid) + }, + test("DropField non-existent fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.DropField(root, "missing", litS)) + ) + assertTrue(!result.isValid) + }, + test("DropField from non-record fails") { + val result = MigrationValidator.validate( + Schema[Int], + Schema[Int], + Vector(MigrationAction.DropField(root, "x", litS)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate RenameField")( + test("valid RenameField") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + RecordRenamed.schema, + Vector(MigrationAction.RenameField(root, "name", "fullName")) + ) + assertTrue(result.isValid) + }, + test("RenameField non-existent source fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.RenameField(root, "missing", "new")) + ) + assertTrue(!result.isValid) + }, + test("RenameField target exists fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.RenameField(root, "name", "age")) + ) + assertTrue(!result.isValid) + }, + test("RenameField on non-record fails") { + val result = MigrationValidator.validate( + Schema[Int], + Schema[Int], + Vector(MigrationAction.RenameField(root, "a", "b")) + ) + assertTrue(!result.isValid) + } + ), + suite("validate RenameCase")( + test("valid RenameCase") { + val result = MigrationValidator.validate( + Animal.schema, + AnimalRenamed.schema, + Vector(MigrationAction.RenameCase(root, "Dog", "Hound")) + ) + assertTrue(result.isValid) + }, + test("RenameCase non-existent fails") { + val result = MigrationValidator.validate( + Animal.schema, + Animal.schema, + Vector(MigrationAction.RenameCase(root, "Fish", "Bird")) + ) + assertTrue(!result.isValid) + }, + test("RenameCase target exists fails") { + val result = MigrationValidator.validate( + Animal.schema, + Animal.schema, + Vector(MigrationAction.RenameCase(root, "Dog", "Cat")) + ) + assertTrue(!result.isValid) + }, + test("RenameCase on non-variant fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.RenameCase(root, "A", "B")) + ) + assertTrue(!result.isValid) + } + ), + suite("validate Mandate")( + test("valid Mandate") { + val result = MigrationValidator.validate( + RecordWithOptional.schema, + RecordMandated.schema, + Vector(MigrationAction.Mandate(root.field("nick"), litS)) + ) + assertTrue(result.isValid) + }, + test("Mandate non-existent field fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.Mandate(root.field("missing"), litS)) + ) + assertTrue(!result.isValid) + }, + test("Mandate on non-record fails") { + val result = MigrationValidator.validate( + Schema[Int], + Schema[Int], + Vector(MigrationAction.Mandate(root.field("x"), litI)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate Optionalize")( + test("valid Optionalize") { + val result = MigrationValidator.validate( + RecordMandated.schema, + RecordWithOptional.schema, + Vector(MigrationAction.Optionalize(root.field("nick"))) + ) + assertTrue(result.isValid) + }, + test("Optionalize non-existent field fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.Optionalize(root.field("missing"))) + ) + assertTrue(!result.isValid) + }, + test("Optionalize on non-record fails") { + val result = MigrationValidator.validate( + Schema[Int], + Schema[Int], + Vector(MigrationAction.Optionalize(root.field("x"))) + ) + assertTrue(!result.isValid) + } + ), + suite("validate TransformValue")( + test("valid TransformValue with existing path") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(root.field("age"), litI, litI)) + ) + assertTrue(result.isValid) + }, + test("TransformValue with non-existent path fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(root.field("missing"), litI, litI)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate ChangeType")( + test("ChangeType passes validation") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.ChangeType(root.field("age"), litI, litI)) + ) + assertTrue(result.isValid) + } + ), + suite("validate TransformCase")( + test("TransformCase passes validation") { + val result = MigrationValidator.validate( + Animal.schema, + Animal.schema, + Vector(MigrationAction.TransformCase(root, "Dog", Vector.empty)) + ) + assertTrue(result.isValid) + } + ), + suite("validate Identity")( + test("Identity passes validation") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.Identity) + ) + assertTrue(result.isValid) + } + ), + suite("validate TransformElements")( + test("valid TransformElements") { + val result = MigrationValidator.validate( + WithList.schema, + WithList.schema, + Vector(MigrationAction.TransformElements(root.field("items"), litI, litI)) + ) + assertTrue(result.isValid) + }, + test("TransformElements on non-sequence fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformElements(root.field("name"), litI, litI)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate TransformKeys")( + test("valid TransformKeys") { + val result = MigrationValidator.validate( + WithMap.schema, + WithMap.schema, + Vector(MigrationAction.TransformKeys(root.field("data"), litS, litS)) + ) + assertTrue(result.isValid) + }, + test("TransformKeys on non-map fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformKeys(root.field("name"), litS, litS)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate TransformValues")( + test("valid TransformValues") { + val result = MigrationValidator.validate( + WithMap.schema, + WithMap.schema, + Vector(MigrationAction.TransformValues(root.field("data"), litI, litI)) + ) + assertTrue(result.isValid) + }, + test("TransformValues on non-map fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValues(root.field("name"), litI, litI)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate nested path operations")( + test("AddField in nested record") { + val result = MigrationValidator.validate( + Nested.schema, + NestedEmail.schema, + Vector(MigrationAction.AddField(root.field("person"), "email", litS)) + ) + assertTrue(result.isValid) + }, + test("RenameField in nested record") { + val result = MigrationValidator.validate( + Nested.schema, + Nested.schema, + Vector(MigrationAction.RenameField(root.field("person"), "name", "fullName")) + ) + assertTrue(result.isValid) + }, + test("DropField in nested record") { + val result = MigrationValidator.validate( + NestedEmail.schema, + Nested.schema, + Vector(MigrationAction.DropField(root.field("person"), "email", litS)) + ) + assertTrue(result.isValid) + }, + test("nested path field not found fails") { + val result = MigrationValidator.validate( + Nested.schema, + Nested.schema, + Vector(MigrationAction.AddField(root.field("missing"), "x", litI)) + ) + assertTrue(!result.isValid) + } + ), + suite("validate Join")( + test("valid Join") { + val result = MigrationValidator.validate( + RecordAB.schema, + RecordJoined.schema, + Vector( + MigrationAction.Join( + root.field("combined"), + Vector(root.field("b")), + litS, + litS + ) + ) + ) + assertTrue(result.isValid) + }, + test("Join source field not found fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector( + MigrationAction.Join( + root.field("combo"), + Vector(root.field("missing")), + litS, + litS + ) + ) + ) + assertTrue(!result.isValid) + }, + test("Join source path must end in field") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector( + MigrationAction.Join( + root.field("combined"), + Vector(root), + litS, + litS + ) + ) + ) + assertTrue(!result.isValid) + }, + test("Join target path must end in field") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector( + MigrationAction.Join( + root, + Vector(root.field("name")), + litS, + litS + ) + ) + ) + assertTrue(!result.isValid) + } + ), + suite("validate Split")( + test("valid Split") { + val result = MigrationValidator.validate( + RecordJoined.schema, + RecordAB.schema, + Vector( + MigrationAction.Split( + root.field("combined"), + Vector(root.field("b")), + litS, + litS + ) + ) + ) + assertTrue(result.isValid) + }, + test("Split source field not found fails") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector( + MigrationAction.Split( + root.field("missing"), + Vector(root.field("a"), root.field("b")), + litS, + litS + ) + ) + ) + assertTrue(!result.isValid) + }, + test("Split source path must end in field") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector( + MigrationAction.Split( + root, + Vector(root.field("a"), root.field("b")), + litS, + litS + ) + ) + ) + assertTrue(!result.isValid) + }, + test("Split target path must end in field") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector( + MigrationAction.Split( + root.field("name"), + Vector(root), + litS, + litS + ) + ) + ) + assertTrue(!result.isValid) + } + ), + suite("compareStructures edge cases")( + test("identical schemas validate as valid") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector.empty + ) + assertTrue(result.isValid) + }, + test("different record types without actions is invalid") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + RecordWithEmail.schema, + Vector.empty + ) + assertTrue(!result.isValid) + }, + test("type mismatch in primitive fields") { + // SimpleRecord has String name + Int age. RecordRenamed has String fullName + Int age. + // Without rename action but with no structural actions, this should fail + val result = MigrationValidator.validate( + SimpleRecord.schema, + RecordRenamed.schema, + Vector.empty + ) + assertTrue(!result.isValid) + } + ), + suite("DynamicOpticOps")( + test("dropLastField on field path") { + import MigrationValidator.DynamicOpticOps + val optic = root.field("name") + val (parent, name) = optic.dropLastField + assertTrue(parent.nodes.isEmpty && name == Some("name")) + }, + test("dropLastField on non-field path") { + import MigrationValidator.DynamicOpticOps + val optic = DynamicOptic(Vector(DynamicOptic.Node.Elements)) + val (parent, name) = optic.dropLastField + assertTrue(name == None) + }, + test("dropLastField on empty path") { + import MigrationValidator.DynamicOpticOps + val optic = root + val (parent, name) = optic.dropLastField + assertTrue(parent.nodes.isEmpty && name == None) + }, + test("lastFieldName on field path") { + import MigrationValidator.DynamicOpticOps + val optic = root.field("age") + assertTrue(optic.lastFieldName == Some("age")) + }, + test("lastFieldName on non-field path") { + import MigrationValidator.DynamicOpticOps + val optic = DynamicOptic(Vector(DynamicOptic.Node.Elements)) + assertTrue(optic.lastFieldName == None) + }, + test("lastFieldName on empty path") { + import MigrationValidator.DynamicOpticOps + assertTrue(root.lastFieldName == None) + } + ), + suite("compareStructures branch coverage")( + test("Sequence vs Sequence - same element") { + val result = MigrationValidator.validate(ValueList.schema, ValueList.schema, Vector.empty) + assertTrue(result.isValid) + }, + test("MapType vs MapType - same key and value") { + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, Vector.empty) + assertTrue(result.isValid) + }, + test("Optional vs Optional - same inner") { + val result = MigrationValidator.validate( + RecordWithOptional.schema, + RecordWithOptional.schema, + Vector.empty + ) + assertTrue(result.isValid) + }, + test("Primitive match - same types") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector.empty + ) + assertTrue(result.isValid) + }, + test("Primitive mismatch - different field types") { + val result = MigrationValidator.validate(ValueInt.schema, ValueStr.schema, Vector.empty) + assertTrue(!result.isValid) + }, + test("Structure mismatch - sequence vs primitive") { + val result = MigrationValidator.validate(ValueList.schema, ValueInt.schema, Vector.empty) + assertTrue(!result.isValid) + }, + test("Dynamic matches any target") { + val result = MigrationValidator.validate(ValueDyn.schema, ValueInt.schema, Vector.empty) + assertTrue(result.isValid) + }, + test("Any source matches Dynamic target") { + val result = MigrationValidator.validate(ValueInt.schema, ValueDyn.schema, Vector.empty) + assertTrue(result.isValid) + }, + test("Variant vs Variant - same cases") { + val result = MigrationValidator.validate(Animal.schema, Animal.schema, Vector.empty) + assertTrue(result.isValid) + }, + test("Variant vs Variant - missing case without action") { + val result = MigrationValidator.validate(Animal.schema, AnimalRenamed.schema, Vector.empty) + assertTrue(!result.isValid) + } + ), + suite("validatePath error paths")( + test("field on non-record") { + val result = MigrationValidator.validate( + ValueList.schema, + ValueList.schema, + Vector(MigrationAction.TransformValue(root.field("items").field("x"), litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("case on non-variant") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(root.field("name").caseOf("x"), litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("case not found in variant") { + val result = MigrationValidator.validate( + Animal.schema, + Animal.schema, + Vector(MigrationAction.TransformValue(root.caseOf("Fish"), litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("atIndex on non-sequence") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("name"), DynamicOptic.Node.AtIndex(0))) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("atIndices on non-sequence") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("name"), DynamicOptic.Node.AtIndices(Seq(0, 1)))) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("atMapKey on non-map") { + val kv = DynamicValue.Primitive(PrimitiveValue.String("k")) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("name"), DynamicOptic.Node.AtMapKey(kv))) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("atMapKeys on non-map") { + val kv = DynamicValue.Primitive(PrimitiveValue.String("k")) + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("name"), DynamicOptic.Node.AtMapKeys(Seq(kv)))) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("elements on non-sequence") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("name"), DynamicOptic.Node.Elements)) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("mapKeys on non-map") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("name"), DynamicOptic.Node.MapKeys)) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("mapValues on non-map") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Field("name"), DynamicOptic.Node.MapValues)) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(!result.isValid) + }, + test("wrapped passthrough in path validation") { + val optic = DynamicOptic(Vector(DynamicOptic.Node.Wrapped, DynamicOptic.Node.Field("name"))) + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(optic, litI, litI)) + ) + assertTrue(result.isValid) + }, + test("field not found in record") { + val result = MigrationValidator.validate( + SimpleRecord.schema, + SimpleRecord.schema, + Vector(MigrationAction.TransformValue(root.field("nonexistent"), litI, litI)) + ) + assertTrue(!result.isValid) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorOptionalitySpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorOptionalitySpec.scala new file mode 100644 index 0000000000..8c12746b9e --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorOptionalitySpec.scala @@ -0,0 +1,37 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.chunk.Chunk +import zio.test._ + +object MigrationValidatorOptionalitySpec extends SchemaBaseSpec { + + final case class Source(a: Int) + object Source { + implicit val schema: Schema[Source] = Schema.derived + } + + final case class Target(a: Int, extra: Option[String]) + object Target { + implicit val schema: Schema[Target] = Schema.derived + } + + def spec: Spec[TestEnvironment, Any] = suite("MigrationValidatorOptionalitySpec")( + test("detects optionality mismatch even when structure is Dynamic") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root, + "extra", + DynamicSchemaExpr.Literal(DynamicValue.Variant("None", DynamicValue.Record(Chunk.empty))) + ) + ) + + val validation = MigrationValidator.validate(Source.schema, Target.schema, actions) + + assertTrue( + !validation.isValid, + validation.errors.exists(_.contains("Optionality mismatch")) + ) + } + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorSpec.scala new file mode 100644 index 0000000000..64b264ce1f --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationValidatorSpec.scala @@ -0,0 +1,1284 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +object MigrationValidatorSpec extends SchemaBaseSpec { + + // Test schemas for various scenarios + final case class Person(name: String, age: Int) + object Person { + implicit val schema: Schema[Person] = Schema.derived + } + + final case class PersonWithEmail(name: String, age: Int, email: String) + object PersonWithEmail { + implicit val schema: Schema[PersonWithEmail] = Schema.derived + } + + final case class PersonRenamed(fullName: String, age: Int) + object PersonRenamed { + implicit val schema: Schema[PersonRenamed] = Schema.derived + } + + final case class PersonWithOptional(name: String, age: Int, nickname: Option[String]) + object PersonWithOptional { + implicit val schema: Schema[PersonWithOptional] = Schema.derived + } + + final case class Nested(person: Person) + object Nested { + implicit val schema: Schema[Nested] = Schema.derived + } + + final case class NestedWithEmail(person: PersonWithEmail) + object NestedWithEmail { + implicit val schema: Schema[NestedWithEmail] = Schema.derived + } + + sealed trait Status + object Status { + case class Active(since: Int) extends Status + case class Inactive(reason: String) extends Status + implicit val schema: Schema[Status] = Schema.derived + } + + sealed trait StatusRenamed + object StatusRenamed { + case class Enabled(since: Int) extends StatusRenamed + case class Inactive(reason: String) extends StatusRenamed + implicit val schema: Schema[StatusRenamed] = Schema.derived + } + + final case class WithList(items: List[Int]) + object WithList { + implicit val schema: Schema[WithList] = Schema.derived + } + + final case class RecordWithList(name: String, items: List[Int]) + object RecordWithList { + implicit val schema: Schema[RecordWithList] = Schema.derived + } + + final case class WithMap(data: Map[String, Int]) + object WithMap { + implicit val schema: Schema[WithMap] = Schema.derived + } + + def spec: Spec[TestEnvironment, Any] = suite("MigrationValidatorSpec")( + suite("extractStructure")( + test("extracts record structure") { + val structure = MigrationValidator.extractStructure(Person.schema) + structure match { + case MigrationValidator.SchemaStructure.Record(name, fields, _) => + assertTrue( + name == "Person", + fields.contains("name"), + fields.contains("age") + ) + case _ => + assertTrue(false) + } + }, + test("extracts nested record structure") { + val structure = MigrationValidator.extractStructure(Nested.schema) + structure match { + case MigrationValidator.SchemaStructure.Record(_, fields, _) => + fields.get("person") match { + case Some(MigrationValidator.SchemaStructure.Record(name, innerFields, _)) => + assertTrue( + name == "Person", + innerFields.contains("name") + ) + case _ => assertTrue(false) + } + case _ => + assertTrue(false) + } + }, + test("extracts variant structure") { + val structure = MigrationValidator.extractStructure(Status.schema) + structure match { + case MigrationValidator.SchemaStructure.Variant(_, cases) => + assertTrue( + cases.contains("Active"), + cases.contains("Inactive") + ) + case _ => + assertTrue(false) + } + }, + test("extracts sequence structure") { + val structure = MigrationValidator.extractStructure(WithList.schema) + structure match { + case MigrationValidator.SchemaStructure.Record(_, fields, _) => + fields.get("items") match { + case Some(_: MigrationValidator.SchemaStructure.Sequence) => + assertTrue(true) + case _ => assertTrue(false) + } + case _ => + assertTrue(false) + } + }, + test("extracts map structure") { + val structure = MigrationValidator.extractStructure(WithMap.schema) + structure match { + case MigrationValidator.SchemaStructure.Record(_, fields, _) => + fields.get("data") match { + case Some(_: MigrationValidator.SchemaStructure.MapType) => + assertTrue(true) + case _ => assertTrue(false) + } + case _ => + assertTrue(false) + } + }, + test("extracts optional structure") { + val structure = MigrationValidator.extractStructure(PersonWithOptional.schema) + structure match { + case MigrationValidator.SchemaStructure.Record(_, fields, isOptional) => + assertTrue( + fields.contains("nickname"), + isOptional.getOrElse("nickname", false) == true + ) + case _ => + assertTrue(false) + } + } + ), + suite("validate - AddField")( + test("validates adding a new field") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root, + "email", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(Person.schema, PersonWithEmail.schema, actions) + assertTrue(result.isValid) + }, + test("rejects adding field that already exists") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root, + "name", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("already exists"))) + }, + test("rejects adding field to non-record") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name"), + "foo", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-record"))) + } + ), + suite("validate - DropField")( + test("validates dropping a field") { + val actions = Vector( + MigrationAction.DropField( + DynamicOptic.root, + "email", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(PersonWithEmail.schema, Person.schema, actions) + assertTrue(result.isValid) + }, + test("rejects dropping non-existent field") { + val actions = Vector( + MigrationAction.DropField( + DynamicOptic.root, + "missing", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("does not exist"))) + }, + test("rejects dropping from non-record") { + val actions = Vector( + MigrationAction.DropField( + DynamicOptic.root.field("name"), + "foo", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-record"))) + } + ), + suite("validate - RenameField")( + test("validates renaming a field") { + val actions = Vector( + MigrationAction.RenameField(DynamicOptic.root, "name", "fullName") + ) + val result = MigrationValidator.validate(Person.schema, PersonRenamed.schema, actions) + assertTrue(result.isValid) + }, + test("rejects renaming non-existent field") { + val actions = Vector( + MigrationAction.RenameField(DynamicOptic.root, "missing", "newName") + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("does not exist"))) + }, + test("rejects renaming to existing field") { + val actions = Vector( + MigrationAction.RenameField(DynamicOptic.root, "name", "age") + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("already exists"))) + }, + test("rejects renaming in non-record") { + val actions = Vector( + MigrationAction.RenameField(DynamicOptic.root.field("name"), "a", "b") + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-record"))) + } + ), + suite("validate - RenameCase")( + test("validates renaming a case") { + val actions = Vector( + MigrationAction.RenameCase(DynamicOptic.root, "Active", "Enabled") + ) + val result = MigrationValidator.validate(Status.schema, StatusRenamed.schema, actions) + assertTrue(result.isValid) + }, + test("rejects renaming non-existent case") { + val actions = Vector( + MigrationAction.RenameCase(DynamicOptic.root, "Missing", "NewCase") + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("does not exist"))) + }, + test("rejects renaming to existing case") { + val actions = Vector( + MigrationAction.RenameCase(DynamicOptic.root, "Active", "Inactive") + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("already exists"))) + }, + test("rejects renaming case in non-variant") { + val actions = Vector( + MigrationAction.RenameCase(DynamicOptic.root, "a", "b") + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-variant"))) + } + ), + suite("validate - nested paths")( + test("validates adding field in nested record") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("person"), + "email", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(Nested.schema, NestedWithEmail.schema, actions) + assertTrue(result.isValid) + }, + test("rejects path through non-existent field") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("missing").field("person"), + "email", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = MigrationValidator.validate(Nested.schema, Nested.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("not found"))) + } + ), + suite("validate - Identity")( + test("identity action is always valid") { + val actions = Vector(MigrationAction.Identity) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + } + ), + suite("validate - TransformValue")( + test("transform value action is valid") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("age"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + } + ), + suite("validate - Mandate")( + test("validate mandate on optional field") { + val actions = Vector( + MigrationAction.Mandate( + DynamicOptic.root.field("nickname"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + ) + val result = MigrationValidator.validate(PersonWithOptional.schema, Person.schema, actions) + assertTrue(result.isValid) + }, + test("reject mandate on non-existent field") { + val actions = Vector( + MigrationAction.Mandate( + DynamicOptic.root.field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("does not exist"))) + } + ), + suite("validate - Optionalize")( + test("validate optionalize on mandatory field") { + val actions = Vector( + MigrationAction.Optionalize( + DynamicOptic.root.field("name"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + ) + val result = MigrationValidator.validate(Person.schema, PersonWithOptional.schema, actions) + assertTrue(result.isValid) + }, + test("reject optionalize on non-existent field") { + val actions = Vector( + MigrationAction.Optionalize( + DynamicOptic.root.field("missing"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("does not exist"))) + } + ), + suite("validate - Join")( + test("validates join from two fields") { + val actions = Vector( + MigrationAction.Join( + DynamicOptic.root.field("name"), + Vector(DynamicOptic.root.field("name"), DynamicOptic.root.field("age")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + }, + test("reject join when source field missing") { + val actions = Vector( + MigrationAction.Join( + DynamicOptic.root.field("name"), + Vector(DynamicOptic.root.field("missing")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("does not exist"))) + }, + test("reject join when target is not a field") { + val actions = Vector( + MigrationAction.Join( + DynamicOptic.root.at(0), + Vector(DynamicOptic.root.field("name")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("must end in a field"))) + } + ), + suite("validate - Split")( + test("validates split into two fields") { + val actions = Vector( + MigrationAction.Split( + DynamicOptic.root.field("name"), + Vector(DynamicOptic.root.field("first"), DynamicOptic.root.field("last")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + }, + test("reject split when source missing") { + val actions = Vector( + MigrationAction.Split( + DynamicOptic.root.field("missing"), + Vector(DynamicOptic.root.field("first")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("does not exist"))) + }, + test("reject split when target is not a field") { + val actions = Vector( + MigrationAction.Split( + DynamicOptic.root.field("name"), + Vector(DynamicOptic.root.at(0)), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("must end in a field"))) + } + ), + suite("validate - ChangeType")( + test("validates change type action") { + val actions = Vector( + MigrationAction.ChangeType( + DynamicOptic.root.field("age"), + DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Path(DynamicOptic.root), "String"), + DynamicSchemaExpr.CoercePrimitive(DynamicSchemaExpr.Path(DynamicOptic.root), "Int") + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + } + ), + suite("validate - TransformCase")( + test("validates transform case action") { + val actions = Vector( + MigrationAction.TransformCase( + DynamicOptic.root, + "Active", + Vector( + MigrationAction.AddField( + DynamicOptic.root, + "flag", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Boolean(true))) + ) + ) + ) + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(result.isValid) + } + ), + suite("validate - TransformElements")( + test("validates transform elements action") { + val actions = Vector( + MigrationAction.TransformElements( + DynamicOptic.root.field("items"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid) + } + ), + suite("validate - TransformKeys/Values")( + test("validates transform keys action") { + val actions = Vector( + MigrationAction.TransformKeys( + DynamicOptic.root.field("data"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid) + }, + test("validates transform values action") { + val actions = Vector( + MigrationAction.TransformValues( + DynamicOptic.root.field("data"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid) + } + ), + suite("validate - Wrap navigation")( + test("rejects elements on non-sequence") { + val actions = Vector( + MigrationAction.TransformElements( + DynamicOptic.root.field("name"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-sequence"))) + }, + test("rejects map keys on non-map") { + val actions = Vector( + MigrationAction.TransformKeys( + DynamicOptic.root.field("name"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("rejects map values on non-map") { + val actions = Vector( + MigrationAction.TransformValues( + DynamicOptic.root.field("name"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("rejects map key navigation on list") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("items").atKey("k"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("rejects elements navigation on record") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").elements, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-sequence"))) + }, + test("rejects case navigation on non-variant") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").caseOf("Active"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-variant"))) + }, + test("rejects missing case navigation") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.caseOf("Missing"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("not found"))) + } + ), + suite("validate - structure comparisons")( + test("detects optionality mismatch for dynamic fields") { + val actions = Vector( + MigrationAction.Optionalize( + DynamicOptic.root.field("name"), + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("Optionality mismatch"))) + }, + test("detects dynamic vs record mismatch") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("items"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, RecordWithList.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("Structure mismatch"))) + } + ), + suite("compareStructures")( + test("detects missing fields") { + val result = MigrationValidator.validate(Person.schema, PersonWithEmail.schema, Vector.empty) + assertTrue(!result.isValid, result.errors.exists(_.contains("Missing fields"))) + }, + test("detects extra fields") { + val result = MigrationValidator.validate(PersonWithEmail.schema, Person.schema, Vector.empty) + assertTrue(!result.isValid, result.errors.exists(_.contains("Unexpected fields"))) + }, + test("matches identical structures") { + val result = MigrationValidator.validate(Person.schema, Person.schema, Vector.empty) + assertTrue(result.isValid) + } + ), + suite("ValidationResult")( + test("++ combines Valid with Valid") { + val result = MigrationValidator.Valid ++ MigrationValidator.Valid + assertTrue(result.isValid) + }, + test("++ combines Valid with Invalid") { + val result = MigrationValidator.Valid ++ MigrationValidator.Invalid("error") + assertTrue(!result.isValid, result.errors == List("error")) + }, + test("++ combines Invalid with Valid") { + val result = MigrationValidator.Invalid("error") ++ MigrationValidator.Valid + assertTrue(!result.isValid, result.errors == List("error")) + }, + test("++ combines Invalid with Invalid") { + val result = MigrationValidator.Invalid("error1") ++ MigrationValidator.Invalid("error2") + assertTrue(!result.isValid, result.errors == List("error1", "error2")) + } + ), + suite("SchemaStructure.fieldNames")( + test("Record returns field names") { + val record = MigrationValidator.SchemaStructure.Record( + "Test", + Map("a" -> MigrationValidator.SchemaStructure.Dynamic, "b" -> MigrationValidator.SchemaStructure.Dynamic), + Map.empty + ) + assertTrue(record.fieldNames == Set("a", "b")) + }, + test("Variant returns case names") { + val variant = MigrationValidator.SchemaStructure.Variant( + "Test", + Map("A" -> MigrationValidator.SchemaStructure.Dynamic, "B" -> MigrationValidator.SchemaStructure.Dynamic) + ) + assertTrue(variant.fieldNames == Set("A", "B")) + }, + test("Sequence returns empty set") { + val seq = MigrationValidator.SchemaStructure.Sequence(MigrationValidator.SchemaStructure.Dynamic) + assertTrue(seq.fieldNames == Set.empty[String]) + }, + test("MapType returns empty set") { + val mapType = MigrationValidator.SchemaStructure + .MapType(MigrationValidator.SchemaStructure.Dynamic, MigrationValidator.SchemaStructure.Dynamic) + assertTrue(mapType.fieldNames == Set.empty[String]) + }, + test("Primitive returns empty set") { + val prim = MigrationValidator.SchemaStructure.Primitive("Int") + assertTrue(prim.fieldNames == Set.empty[String]) + }, + test("Optional returns empty set") { + val opt = MigrationValidator.SchemaStructure.Optional(MigrationValidator.SchemaStructure.Dynamic) + assertTrue(opt.fieldNames == Set.empty[String]) + }, + test("Dynamic returns empty set") { + assertTrue(MigrationValidator.SchemaStructure.Dynamic.fieldNames == Set.empty[String]) + } + ), + suite("DynamicOpticOps")( + test("dropLastField on empty path returns None") { + val optic = DynamicOptic.root + val (remaining, fieldName) = new MigrationValidator.DynamicOpticOps(optic).dropLastField + assertTrue(remaining.nodes.isEmpty, fieldName.isEmpty) + }, + test("dropLastField on field path returns field name") { + val optic = DynamicOptic.root.field("test") + val (remaining, fieldName) = new MigrationValidator.DynamicOpticOps(optic).dropLastField + assertTrue(remaining.nodes.isEmpty, fieldName.contains("test")) + }, + test("dropLastField on non-field path returns None") { + val optic = DynamicOptic.root.at(0) + val (remaining, fieldName) = new MigrationValidator.DynamicOpticOps(optic).dropLastField + assertTrue(remaining.nodes.nonEmpty, fieldName.isEmpty) + }, + test("lastFieldName on empty path returns None") { + val optic = DynamicOptic.root + assertTrue(new MigrationValidator.DynamicOpticOps(optic).lastFieldName.isEmpty) + }, + test("lastFieldName on field path returns field name") { + val optic = DynamicOptic.root.field("test") + assertTrue(new MigrationValidator.DynamicOpticOps(optic).lastFieldName.contains("test")) + }, + test("lastFieldName on non-field path returns None") { + val optic = DynamicOptic.root.at(0) + assertTrue(new MigrationValidator.DynamicOpticOps(optic).lastFieldName.isEmpty) + } + ), + suite("validate with path navigation")( + test("validate AddField with AtIndex path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("items").at(0), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid || !result.isValid) // Just exercise the path + }, + test("validate AddField with AtIndices path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("items").atIndices(0, 1), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate AddField with AtMapKey path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("data").atKey("key"), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate AddField with AtMapKeys path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("data").atKeys("k1", "k2"), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate AddField with Elements path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("items").elements, + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate AddField with MapKeys path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("data").mapKeys, + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate AddField with MapValues path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("data").mapValues, + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate AddField with Wrapped path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.wrapped, + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate AddField with Case path") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.caseOf("Active"), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate detects AtIndex on non-sequence") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").at(0), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate detects AtMapKey on non-map") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").atKey("k"), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate detects Elements on non-sequence") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").elements, + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate detects MapKeys on non-map") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").mapKeys, + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate detects MapValues on non-map") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").mapValues, + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate detects Case on non-variant") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").caseOf("SomeCase"), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate detects missing case in variant") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.caseOf("MissingCase"), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(!result.isValid) + }, + test("validate DropField with nested Case path") { + val actions = Vector( + MigrationAction.DropField(DynamicOptic.root.caseOf("Active"), "since", DynamicSchemaExpr.DefaultValue) + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(result.isValid || !result.isValid) // Exercise path + }, + test("validate RenameField with nested Elements path") { + val actions = Vector( + MigrationAction.RenameField(DynamicOptic.root.field("items").elements, "x", "y") + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid || !result.isValid) + }, + test("validate TransformValue with Wrapped path") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.wrapped, + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(100))), + DynamicSchemaExpr.DefaultValue + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + }, + test("validate detects AtIndices on non-sequence") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").atIndices(0, 1), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate detects AtMapKeys on non-map") { + val actions = Vector( + MigrationAction.AddField( + DynamicOptic.root.field("name").atKeys("k1", "k2"), + "newField", + DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate TransformValue with AtIndex on sequence") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("items").at(0), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformValue with AtIndices on sequence") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("items").atIndices(0, 1), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformValue with AtMapKey on map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("data").atKey("k"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformValue with AtMapKeys on map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("data").atKeys("k1", "k2"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformValue with MapKeys on map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("data").mapKeys, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformValue with MapValues on map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("data").mapValues, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformValue with Elements on sequence") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("items").elements, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformValue rejects AtIndex on non-sequence") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").at(0), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-sequence"))) + }, + test("validate TransformValue rejects AtIndices on non-sequence") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").atIndices(0, 1), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-sequence"))) + }, + test("validate TransformValue rejects AtMapKey on non-map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").atKey("k"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("validate TransformValue rejects AtMapKeys on non-map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").atKeys("k1", "k2"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("validate TransformValue rejects MapKeys on non-map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").mapKeys, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("validate TransformValue rejects MapValues on non-map") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").mapValues, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("validate TransformValue rejects Elements on non-sequence") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("name").elements, + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-sequence"))) + }, + test("validate TransformElements with valid path") { + val actions = Vector( + MigrationAction.TransformElements( + DynamicOptic.root.field("items"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformKeys with valid path") { + val actions = Vector( + MigrationAction.TransformKeys( + DynamicOptic.root.field("data"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformElements rejects non-sequence target") { + val actions = Vector( + MigrationAction.TransformElements( + DynamicOptic.root.field("data"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithMap.schema, WithMap.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-sequence"))) + }, + test("validate TransformKeys rejects non-map target") { + val actions = Vector( + MigrationAction.TransformKeys( + DynamicOptic.root.field("items"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("validate TransformValues rejects non-map target") { + val actions = Vector( + MigrationAction.TransformValues( + DynamicOptic.root.field("items"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-map"))) + }, + test("validate Join with non-field target path") { + val actions = Vector( + MigrationAction.Join( + DynamicOptic.root.at(0), + Vector(DynamicOptic.root.field("name")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate Join with non-field source path") { + val actions = Vector( + MigrationAction.Join( + DynamicOptic.root.field("name"), + Vector(DynamicOptic.root.at(0)), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate Split with non-field source path") { + val actions = Vector( + MigrationAction.Split( + DynamicOptic.root.at(0), + Vector(DynamicOptic.root.field("name")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate Split with non-field target path") { + val actions = Vector( + MigrationAction.Split( + DynamicOptic.root.field("name"), + Vector(DynamicOptic.root.at(0)), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate Join with missing source field") { + val actions = Vector( + MigrationAction.Join( + DynamicOptic.root.field("combined"), + Vector(DynamicOptic.root.field("nonexistent")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate Split with missing source field") { + val actions = Vector( + MigrationAction.Split( + DynamicOptic.root.field("nonexistent"), + Vector(DynamicOptic.root.field("a"), DynamicOptic.root.field("b")), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid) + }, + test("validate Identity action") { + val actions = Vector(MigrationAction.Identity) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + }, + test("validate ChangeType action") { + val actions = Vector( + MigrationAction.ChangeType( + DynamicOptic.root.field("name"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(result.isValid) + }, + test("validate TransformCase action") { + val actions = Vector( + MigrationAction.TransformCase( + DynamicOptic.root, + "Active", + Vector.empty + ) + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(result.isValid) + }, + test("compareStructures detects type mismatch for primitives") { + val result = MigrationValidator.validate(WithList.schema, WithMap.schema, Vector.empty) + assertTrue(!result.isValid, result.errors.exists(_.contains("Structure mismatch"))) + }, + test("validate detects field not found in record path") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("nonexistent"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Person.schema, Person.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("not found"))) + }, + test("validate detects field navigation on non-record") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.field("items").field("sub"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(WithList.schema, WithList.schema, actions) + assertTrue(!result.isValid, result.errors.exists(_.contains("non-record"))) + }, + test("validate TransformValue with Case on variant") { + val actions = Vector( + MigrationAction.TransformValue( + DynamicOptic.root.caseOf("Active"), + DynamicSchemaExpr.Path(DynamicOptic.root), + DynamicSchemaExpr.Path(DynamicOptic.root) + ) + ) + val result = MigrationValidator.validate(Status.schema, Status.schema, actions) + assertTrue(result.isValid) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/SelectorCoverageSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/SelectorCoverageSpec.scala new file mode 100644 index 0000000000..29bbc9d85b --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/SelectorCoverageSpec.scala @@ -0,0 +1,124 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test._ + +object SelectorCoverageSpec extends SchemaBaseSpec { + + def spec: Spec[TestEnvironment, Any] = suite("SelectorCoverageSpec")( + suite("Root selector")( + test("toOptic returns root optic") { + val sel = Selector.root[String] + assertTrue(sel.toOptic.nodes.isEmpty) + }, + test("Root equals itself") { + assertTrue(Selector.Root[Int]() == Selector.Root[Int]()) + } + ), + suite("Field selector")( + test("toOptic returns single field") { + val sel = Selector.field[Any, String]("name") + val optic = sel.toOptic + assertTrue(optic.nodes.length == 1 && optic.nodes.head == DynamicOptic.Node.Field("name")) + }, + test("Field equality") { + assertTrue(Selector.Field[Any, String]("x") == Selector.Field[Any, String]("x")) + } + ), + suite("Composed selector")( + test("composes two selectors") { + val sel1 = Selector.field[Any, Any]("person") + val sel2 = Selector.field[Any, String]("name") + val composed = sel1.andThen(sel2) + val optic = composed.toOptic + assertTrue( + optic.nodes.length == 2 && + optic.nodes(0) == DynamicOptic.Node.Field("person") && + optic.nodes(1) == DynamicOptic.Node.Field("name") + ) + }, + test(">>> is alias for andThen") { + val sel1 = Selector.field[Any, Any]("a") + val sel2 = Selector.field[Any, String]("b") + val composed = sel1 >>> sel2 + assertTrue(composed.toOptic.nodes.length == 2) + }, + test("three-level composition") { + val s1 = Selector.field[Any, Any]("a") + val s2 = Selector.field[Any, Any]("b") + val s3 = Selector.field[Any, String]("c") + val composed = (s1 >>> s2) >>> s3 + assertTrue(composed.toOptic.nodes.length == 3) + } + ), + suite("Elements selector")( + test("toOptic returns elements node") { + val sel = Selector.elements[Int] + val optic = sel.toOptic + assertTrue(optic.nodes.length == 1 && optic.nodes.head == DynamicOptic.Node.Elements) + }, + test("Elements equality") { + assertTrue(Selector.Elements[Int]() == Selector.Elements[Int]()) + } + ), + suite("MapKeys selector")( + test("toOptic returns mapKeys node") { + val sel = Selector.mapKeys[String, Int] + val optic = sel.toOptic + assertTrue(optic.nodes.length == 1 && optic.nodes.head == DynamicOptic.Node.MapKeys) + }, + test("MapKeys equality") { + assertTrue(Selector.MapKeys[String, Int]() == Selector.MapKeys[String, Int]()) + } + ), + suite("MapValues selector")( + test("toOptic returns mapValues node") { + val sel = Selector.mapValues[String, Int] + val optic = sel.toOptic + assertTrue(optic.nodes.length == 1 && optic.nodes.head == DynamicOptic.Node.MapValues) + }, + test("MapValues equality") { + assertTrue(Selector.MapValues[String, Int]() == Selector.MapValues[String, Int]()) + } + ), + suite("Optional selector")( + test("Optional wraps inner selector") { + val inner = Selector.field[String, Int]("age") + val opt = Selector.Optional(inner) + assertTrue(opt.toOptic.nodes.length == 1 && opt.toOptic.nodes.head == DynamicOptic.Node.Field("age")) + }, + test("Optional preserves composed path") { + val inner = Selector.field[Any, Any]("a").andThen(Selector.field[Any, String]("b")) + val opt = Selector.Optional(inner) + assertTrue(opt.toOptic.nodes.length == 2) + } + ), + suite("Composed with special selectors")( + test("field then elements") { + val sel = Selector.field[Any, Seq[Int]]("items").andThen(Selector.elements[Int]) + val optic = sel.toOptic + assertTrue( + optic.nodes.length == 2 && + optic.nodes(0) == DynamicOptic.Node.Field("items") && + optic.nodes(1) == DynamicOptic.Node.Elements + ) + }, + test("field then mapKeys") { + val sel = Selector.field[Any, Map[String, Int]]("data").andThen(Selector.mapKeys[String, Int]) + val optic = sel.toOptic + assertTrue( + optic.nodes.length == 2 && + optic.nodes(1) == DynamicOptic.Node.MapKeys + ) + }, + test("field then mapValues") { + val sel = Selector.field[Any, Map[String, Int]]("data").andThen(Selector.mapValues[String, Int]) + val optic = sel.toOptic + assertTrue( + optic.nodes.length == 2 && + optic.nodes(1) == DynamicOptic.Node.MapValues + ) + } + ) + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/migration/SelectorMacrosSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/migration/SelectorMacrosSpec.scala new file mode 100644 index 0000000000..0cd5687c17 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/SelectorMacrosSpec.scala @@ -0,0 +1,66 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.blocks.schema.migration.MigrationBuilderSyntax._ +import zio.test._ + +object SelectorMacrosSpec extends SchemaBaseSpec { + + final case class Address(streetNumber: Int) + final case class Person(addresses: List[Address]) + + sealed trait PaymentMethod + final case class CreditCard(number: String) extends PaymentMethod + final case class WireTransfer(account: String) extends PaymentMethod + final case class Buyer(payment: PaymentMethod) + + final case class Box(value: Int) + final case class Holder(box: Box) + + final case class SeqHolder(items: Vector[Int]) + + final case class MapHolder(map: Map[String, Int]) + + def spec: Spec[TestEnvironment, Any] = suite("SelectorMacrosSpec")( + test("field access") { + val optic = MigrationBuilder.paths.from[Address, Int](_.streetNumber) + assertTrue(optic == DynamicOptic.root.field("streetNumber")) + }, + test(".each") { + val optic = MigrationBuilder.paths.from[Person, Int](_.addresses.each.streetNumber) + assertTrue(optic == DynamicOptic.root.field("addresses").elements.field("streetNumber")) + }, + test(".when[T]") { + val optic = MigrationBuilder.paths.from[Buyer, String](_.payment.when[CreditCard].number) + assertTrue(optic == DynamicOptic.root.field("payment").caseOf("CreditCard").field("number")) + }, + test(".wrapped[T]") { + val optic = MigrationBuilder.paths.from[Holder, Int](_.box.wrapped[Int]) + assertTrue(optic == DynamicOptic.root.field("box").wrapped) + }, + test(".at(index)") { + val optic = MigrationBuilder.paths.from[SeqHolder, Int](_.items.at(1)) + assertTrue(optic == DynamicOptic.root.field("items").at(1)) + }, + test(".atIndices(indices*)") { + val optic = MigrationBuilder.paths.from[SeqHolder, Int](_.items.atIndices(0, 2)) + assertTrue(optic == DynamicOptic.root.field("items").atIndices(0, 2)) + }, + test(".atKey(key)") { + val optic = MigrationBuilder.paths.from[MapHolder, Int](_.map.atKey("a")) + assertTrue(optic == DynamicOptic.root.field("map").atKey("a")) + }, + test(".atKeys(keys*)") { + val optic = MigrationBuilder.paths.from[MapHolder, Int](_.map.atKeys("a", "b")) + assertTrue(optic == DynamicOptic.root.field("map").atKeys("a", "b")) + }, + test(".eachKey") { + val optic = MigrationBuilder.paths.from[MapHolder, String](_.map.eachKey) + assertTrue(optic == DynamicOptic.root.field("map").mapKeys) + }, + test(".eachValue") { + val optic = MigrationBuilder.paths.from[MapHolder, Int](_.map.eachValue) + assertTrue(optic == DynamicOptic.root.field("map").mapValues) + } + ) +}