From c184e9201f9822dfca0c23c10332b42fb77db400 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Thu, 19 Mar 2026 20:26:38 +0530 Subject: [PATCH] feat(schema): implement purely algebraic schema migration system (#519) Implemented a purely algebraic migration system representing schema transformations as first-class, serializable data structures. * Created MigrationAction for 14 actions utilizing DynamicOptic * Implemented DynamicMigration and typed Migration[A, B] wrapper * Added comprehensive ZIO tests for laws and behaviors --- .../schema/migration/DynamicMigration.scala | 100 ++++ .../blocks/schema/migration/Migration.scala | 108 ++++ .../schema/migration/MigrationAction.scala | 552 ++++++++++++++++++ .../schema/migration/MigrationBuilder.scala | 285 +++++++++ .../schema/migration/MigrationError.scala | 77 +++ .../migration/DynamicMigrationSpec.scala | 284 +++++++++ .../migration/MigrationActionSpec.scala | 368 ++++++++++++ .../schema/migration/MigrationSpec.scala | 188 ++++++ 8 files changed, 1962 insertions(+) create mode 100644 schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicMigration.scala create mode 100644 schema/shared/src/main/scala/zio/blocks/schema/migration/Migration.scala create mode 100644 schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationAction.scala create mode 100644 schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationBuilder.scala create mode 100644 schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationError.scala create mode 100644 schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationSpec.scala create mode 100644 schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionSpec.scala create mode 100644 schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSpec.scala 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..d0b58d65e4 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/DynamicMigration.scala @@ -0,0 +1,100 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.schema.migration + +import zio.blocks.schema.DynamicValue + +/** + * An untyped, fully serializable migration that operates on [[DynamicValue]]. + * + * `DynamicMigration` is the pure-data core of the migration system. It contains + * a sequence of [[MigrationAction]]s that are applied sequentially to transform + * data from one schema version to another. + * + * Key properties: + * - '''Fully serializable''': No user functions, closures, or runtime code + * generation. + * - '''Composable''': Migrations can be sequentially composed with `++`. + * - '''Reversible''': Every migration has a structural reverse via `reverse`. + * - '''Introspectable''': The ADT can be inspected, transformed, and used to + * generate DDL, upgraders, downgraders, etc. + * + * Laws: + * - '''Identity''': `DynamicMigration.identity.apply(v) == Right(v)` + * - '''Associativity''': `(m1 ++ m2) ++ m3` behaves the same as + * `m1 ++ (m2 ++ m3)` + * - '''Structural Reverse''': `m.reverse.reverse == m` + */ +case class DynamicMigration(actions: Vector[MigrationAction]) { + + /** + * Applies this migration to a [[DynamicValue]], executing all actions + * sequentially. + * + * @param value + * the input value to migrate + * @return + * Right(migrated) on success, Left(error) on first failure + */ + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + var current: DynamicValue = value + val iter = actions.iterator + while (iter.hasNext) { + iter.next().apply(current) match { + case Right(next) => current = next + case left => return left + } + } + Right(current) + } + + /** + * Composes this migration with another, creating a new migration that first + * applies this migration's actions, then the other's. + */ + def ++(that: DynamicMigration): DynamicMigration = + new DynamicMigration(this.actions ++ that.actions) + + /** Alias for `++`. */ + def andThen(that: DynamicMigration): DynamicMigration = this ++ that + + /** + * Returns the structural reverse of this migration. Each action is reversed + * and the order is reversed. + * + * Law: `m.reverse.reverse == m` + */ + def reverse: DynamicMigration = + new DynamicMigration(actions.reverseIterator.map(_.reverse).toVector) + + /** Returns true if this migration has no actions. */ + def isEmpty: Boolean = actions.isEmpty +} + +object DynamicMigration { + + /** The identity migration — applies no transformations. */ + val identity: DynamicMigration = new DynamicMigration(Vector.empty) + + /** Creates a migration from a single action. */ + def apply(action: MigrationAction): DynamicMigration = + new DynamicMigration(Vector(action)) + + /** Creates a migration from multiple actions. */ + def apply(actions: MigrationAction*): DynamicMigration = + new DynamicMigration(actions.toVector) +} 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..3c189a18fb --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/Migration.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.schema.migration + +import zio.blocks.schema.{DynamicOptic, Schema} + +/** + * A typed migration from schema version A to schema version B. + * + * `Migration[A, B]` wraps a [[DynamicMigration]] together with the source and + * target schemas, providing a type-safe API for applying migrations to typed + * values. + * + * The migration works by: + * 1. Converting the input value `A` to a [[DynamicValue]] using + * `sourceSchema` + * 2. Applying the [[DynamicMigration]] + * 3. Converting the result back to `B` using `targetSchema` + * + * Laws: + * - '''Identity''': `Migration.identity[A].apply(a) == Right(a)` + * - '''Associativity''': `(m1 ++ m2) ++ m3` behaves the same as + * `m1 ++ (m2 ++ m3)` + * - '''Structural Reverse''': `m.reverse.reverse == m` (structurally) + * - '''Best-Effort Semantic Inverse''': + * `m.apply(a) == Right(b) ⇒ m.reverse.apply(b) == Right(a)` (when + * sufficient information exists) + */ +case class Migration[A, B]( + dynamicMigration: DynamicMigration, + sourceSchema: Schema[A], + targetSchema: Schema[B] +) { + + /** + * Applies this migration to transform a value of type A into type B. + * + * @param value + * the input value + * @return + * Right(migrated) on success, Left(error) on failure + */ + def apply(value: A): Either[MigrationError, B] = { + val dynamicValue = sourceSchema.toDynamicValue(value) + dynamicMigration(dynamicValue).flatMap { migratedDynamic => + targetSchema.fromDynamicValue(migratedDynamic) match { + case Right(result) => Right(result) + case Left(err) => + Left( + MigrationError.ActionFailed( + "Migration", + DynamicOptic.root, + s"Failed to convert migrated value to target type: ${err.message}" + ) + ) + } + } + } + + /** + * Composes this migration with another, creating a migration from A to C. + */ + def ++[C](that: Migration[B, C]): Migration[A, C] = + Migration( + this.dynamicMigration ++ that.dynamicMigration, + this.sourceSchema, + that.targetSchema + ) + + /** Alias for `++`. */ + def andThen[C](that: Migration[B, C]): Migration[A, C] = this ++ that + + /** + * Returns the structural reverse of this migration (from B to A). + */ + def reverse: Migration[B, A] = + Migration(dynamicMigration.reverse, targetSchema, sourceSchema) +} + +object Migration { + + /** + * Creates an identity migration that passes values through unchanged. + */ + def identity[A](implicit schema: Schema[A]): Migration[A, A] = + Migration(DynamicMigration.identity, schema, schema) + + /** + * Creates a new [[MigrationBuilder]] for constructing a migration 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) +} 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..afcd46fe47 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationAction.scala @@ -0,0 +1,552 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.schema.migration + +import zio.blocks.schema.{DynamicOptic, DynamicValue} + +/** + * A migration action represents a single, atomic structural transformation on a + * [[DynamicValue]]. All actions are path-based, operating at a location + * specified by a [[DynamicOptic]]. + * + * Migration actions form a sealed ADT that is fully serializable — no user + * functions, closures, reflection, or runtime code generation. + * + * Each action has: + * - `at`: the path where the action operates + * - `reverse`: a structurally inverse action + * - `apply(value)`: execute the transformation on a DynamicValue + */ +sealed trait MigrationAction { + + /** The path at which this action operates. */ + def at: DynamicOptic + + /** Returns the structurally inverse action. */ + def reverse: MigrationAction + + /** + * Applies this action to a DynamicValue. + * + * @param value + * the input value to transform + * @return + * Right(transformed) on success, Left(error) on failure + */ + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] +} + +object MigrationAction { + + // ───────────────────────────────────────────────────────────────────────── + // Record Actions + // ───────────────────────────────────────────────────────────────────────── + + /** + * Adds a new field with a default value at the specified path. The reverse + * action is [[DropField]]. + */ + final case class AddField( + at: DynamicOptic, + fieldName: String, + default: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = DropField(at, fieldName, default) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + val fieldPath = at.field(fieldName) + value.insert(fieldPath, default) match { + case dv if dv ne value => Right(dv) + case _ => + if (at.nodes.isEmpty) { + value match { + case r: DynamicValue.Record => + Right(DynamicValue.Record(r.fields :+ (fieldName -> default))) + case _ => + Left(MigrationError.ActionFailed("AddField", at, s"Expected Record, got ${value.valueType}")) + } + } else { + val modified = value.modify(at) { + case r: DynamicValue.Record => + DynamicValue.Record(r.fields :+ (fieldName -> default)) + case other => other + } + if (modified ne value) Right(modified) + else Left(MigrationError.PathNotFound(at)) + } + } + } + } + + /** + * Removes a field at the specified path. The reverse action is [[AddField]] + * using the stored default. + */ + final case class DropField( + at: DynamicOptic, + fieldName: String, + defaultForReverse: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = AddField(at, fieldName, defaultForReverse) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = + if (at.nodes.isEmpty) { + value match { + case r: DynamicValue.Record => + val newFields = r.fields.filter(_._1 != fieldName) + if (newFields.length == r.fields.length) + Left(MigrationError.ActionFailed("DropField", at, s"Field '$fieldName' not found")) + else + Right(DynamicValue.Record(newFields)) + case _ => + Left(MigrationError.ActionFailed("DropField", at, s"Expected Record, got ${value.valueType}")) + } + } else { + var found = false + val modified = value.modify(at) { + case r: DynamicValue.Record => + found = true + DynamicValue.Record(r.fields.filter(_._1 != fieldName)) + case other => + other + } + if (found) Right(modified) + else Left(MigrationError.PathNotFound(at)) + } + } + + /** + * Renames a field from one name to another at the specified path. The reverse + * action renames back. + */ + final case class Rename( + at: DynamicOptic, + from: String, + to: String + ) extends MigrationAction { + def reverse: MigrationAction = Rename(at, to, from) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + def renameInRecord(r: DynamicValue.Record): DynamicValue.Record = { + val newFields = r.fields.map { + case (name, v) if name == from => (to, v) + case other => other + } + DynamicValue.Record(newFields) + } + + if (at.nodes.isEmpty) { + value match { + case r: DynamicValue.Record => + if (r.fields.exists(_._1 == from)) Right(renameInRecord(r)) + else Left(MigrationError.ActionFailed("Rename", at, s"Field '$from' not found")) + case _ => + Left(MigrationError.ActionFailed("Rename", at, s"Expected Record, got ${value.valueType}")) + } + } else { + var found = false + val modified = value.modify(at) { + case r: DynamicValue.Record => + found = true + renameInRecord(r) + case other => + other // Don't set found=true for non-Record — this is intentional + } + if (found) Right(modified) + else Left(MigrationError.PathNotFound(at)) + } + } + } + + /** + * Transforms the value at a field by replacing it with a computed value. Both + * `transform` and `reverseTransform` are DynamicValue constants for full + * serializability. + */ + final case class TransformValue( + at: DynamicOptic, + transform: DynamicValue, + reverseTransform: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = TransformValue(at, reverseTransform, transform) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + val modified = value.set(at, transform) + if (modified ne value) Right(modified) + else Left(MigrationError.PathNotFound(at)) + } + } + + /** + * Makes an optional field mandatory, providing a default for None values. The + * reverse action is [[Optionalize]]. + */ + final case class Mandate( + at: DynamicOptic, + default: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = Optionalize(at, default) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + val modified = value.modify(at) { + case DynamicValue.Null => default + case v: DynamicValue.Variant if v.caseNameValue == "None" => default + case v: DynamicValue.Variant if v.caseNameValue == "Some" => v.value + case other => other + } + if (modified ne value) Right(modified) + else Left(MigrationError.PathNotFound(at)) + } + } + + /** + * Makes a mandatory field optional (wraps in Some). The reverse action is + * [[Mandate]] with the stored default. + */ + final case class Optionalize( + at: DynamicOptic, + defaultForReverse: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = Mandate(at, defaultForReverse) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + val modified = value.modify(at) { + case DynamicValue.Null => DynamicValue.Null + case v => DynamicValue.Variant("Some", v) + } + if (modified ne value) Right(modified) + else Left(MigrationError.PathNotFound(at)) + } + } + + /** + * Changes the type of a value at the specified path by replacing it with a + * pre-computed converted value. + */ + final case class ChangeType( + at: DynamicOptic, + converter: DynamicValue, + reverseConverter: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = ChangeType(at, reverseConverter, converter) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + val modified = value.set(at, converter) + if (modified ne value) Right(modified) + else Left(MigrationError.PathNotFound(at)) + } + } + + /** + * Joins multiple source fields into a single target field using a combiner + * value. The reverse is [[Split]]. + */ + final case class Join( + at: DynamicOptic, + sourcePaths: Vector[DynamicOptic], + combiner: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = Split(at, sourcePaths, combiner) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = + // Join sets the target path to the combiner value + // (the combiner is a pre-computed DynamicValue constant) + if (at.nodes.isEmpty) { + // Replace root — unusual but supported + Right(combiner) + } else { + val modified = value.set(at, combiner) + if (modified ne value) Right(modified) + else { + // Path doesn't exist yet — insert it + val inserted = value.insert(at, combiner) + if (inserted ne value) Right(inserted) + else Left(MigrationError.PathNotFound(at)) + } + } + } + + /** + * Splits a single field into multiple target fields using a splitter value. + * The reverse is [[Join]]. + */ + final case class Split( + at: DynamicOptic, + targetPaths: Vector[DynamicOptic], + splitter: DynamicValue + ) extends MigrationAction { + def reverse: MigrationAction = Join(at, targetPaths, splitter) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = + // Split sets the source path to the splitter value + // (the splitter is a pre-computed DynamicValue constant) + if (at.nodes.isEmpty) { + Right(splitter) + } else { + val modified = value.set(at, splitter) + if (modified ne value) Right(modified) + else { + val inserted = value.insert(at, splitter) + if (inserted ne value) Right(inserted) + else Left(MigrationError.PathNotFound(at)) + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Enum Actions + // ───────────────────────────────────────────────────────────────────────── + + /** + * Renames a case in a variant/enum at the specified path. + */ + final case class RenameCase( + at: DynamicOptic, + from: String, + to: String + ) extends MigrationAction { + def reverse: MigrationAction = RenameCase(at, to, from) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + def renameCaseInVariant(dv: DynamicValue): DynamicValue = dv match { + case v: DynamicValue.Variant if v.caseNameValue == from => + DynamicValue.Variant(to, v.value) + case other => other + } + + if (at.nodes.isEmpty) { + val result = renameCaseInVariant(value) + if (result ne value) Right(result) + else Left(MigrationError.ActionFailed("RenameCase", at, s"Case '$from' not found")) + } else { + var found = false + val modified = value.modify(at) { dv => + val result = renameCaseInVariant(dv) + if (result ne dv) found = true + result + } + if (found) Right(modified) + else Left(MigrationError.ActionFailed("RenameCase", at, s"Case '$from' not found at path")) + } + } + } + + /** + * Transforms a specific case in a variant by applying nested migration + * actions to the case value. + */ + final case class TransformCase( + at: DynamicOptic, + caseName: String, + actions: Vector[MigrationAction] + ) extends MigrationAction { + def reverse: MigrationAction = TransformCase(at, caseName, actions.reverse.map(_.reverse)) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + def transformCaseValue(dv: DynamicValue): Either[MigrationError, DynamicValue] = dv match { + case v: DynamicValue.Variant if v.caseNameValue == caseName => + var current: DynamicValue = v.value + var error: MigrationError = null + val iter = actions.iterator + while (iter.hasNext && (error eq null)) { + iter.next().apply(current) match { + case Right(next) => current = next + case Left(err) => error = err + } + } + if (error ne null) Left(error) + else Right(DynamicValue.Variant(caseName, current)) + case other => Right(other) + } + + if (at.nodes.isEmpty) { + transformCaseValue(value) + } else { + var result: Either[MigrationError, DynamicValue] = null + val modified = value.modify(at) { dv => + transformCaseValue(dv) match { + case Right(v) => + result = Right(v) + v + case Left(err) => + result = Left(err) + dv + } + } + if (result ne null) result.map(_ => modified) + else Left(MigrationError.PathNotFound(at)) + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Collection / Map Actions + // ───────────────────────────────────────────────────────────────────────── + + /** + * Transforms all elements in a sequence at the specified path. + */ + final case class TransformElements( + at: DynamicOptic, + elementActions: Vector[MigrationAction] + ) extends MigrationAction { + def reverse: MigrationAction = TransformElements(at, elementActions.reverse.map(_.reverse)) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + def transformSeq(dv: DynamicValue): Either[MigrationError, DynamicValue] = dv match { + case s: DynamicValue.Sequence => + var error: MigrationError = null + val newElements = s.elements.map { elem => + if (error ne null) elem + else { + var current = elem + val iter = elementActions.iterator + while (iter.hasNext && (error eq null)) { + iter.next().apply(current) match { + case Right(next) => current = next + case Left(err) => error = err + } + } + current + } + } + if (error ne null) Left(error) + else Right(DynamicValue.Sequence(newElements)) + case _ => Left(MigrationError.ActionFailed("TransformElements", at, "Expected Sequence")) + } + + if (at.nodes.isEmpty) transformSeq(value) + else { + var result: Either[MigrationError, DynamicValue] = null + val modified = value.modify(at) { dv => + transformSeq(dv) match { + case Right(v) => + result = Right(v) + v + case Left(err) => + result = Left(err) + dv + } + } + if (result ne null) result.map(_ => modified) + else Left(MigrationError.PathNotFound(at)) + } + } + } + + /** + * Transforms all keys in a map at the specified path. + */ + final case class TransformKeys( + at: DynamicOptic, + keyActions: Vector[MigrationAction] + ) extends MigrationAction { + def reverse: MigrationAction = TransformKeys(at, keyActions.reverse.map(_.reverse)) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + def transformMap(dv: DynamicValue): Either[MigrationError, DynamicValue] = dv match { + case m: DynamicValue.Map => + var error: MigrationError = null + val newEntries = m.entries.map { case (k, v) => + if (error ne null) (k, v) + else { + var current = k + val iter = keyActions.iterator + while (iter.hasNext && (error eq null)) { + iter.next().apply(current) match { + case Right(next) => current = next + case Left(err) => error = err + } + } + (current, v) + } + } + if (error ne null) Left(error) + else Right(DynamicValue.Map(newEntries)) + case _ => Left(MigrationError.ActionFailed("TransformKeys", at, "Expected Map")) + } + + if (at.nodes.isEmpty) transformMap(value) + else { + var result: Either[MigrationError, DynamicValue] = null + val modified = value.modify(at) { dv => + transformMap(dv) match { + case Right(v) => + result = Right(v) + v + case Left(err) => + result = Left(err) + dv + } + } + if (result ne null) result.map(_ => modified) + else Left(MigrationError.PathNotFound(at)) + } + } + } + + /** + * Transforms all values in a map at the specified path. + */ + final case class TransformValues( + at: DynamicOptic, + valueActions: Vector[MigrationAction] + ) extends MigrationAction { + def reverse: MigrationAction = TransformValues(at, valueActions.reverse.map(_.reverse)) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = { + def transformMap(dv: DynamicValue): Either[MigrationError, DynamicValue] = dv match { + case m: DynamicValue.Map => + var error: MigrationError = null + val newEntries = m.entries.map { case (k, v) => + if (error ne null) (k, v) + else { + var current = v + val iter = valueActions.iterator + while (iter.hasNext && (error eq null)) { + iter.next().apply(current) match { + case Right(next) => current = next + case Left(err) => error = err + } + } + (k, current) + } + } + if (error ne null) Left(error) + else Right(DynamicValue.Map(newEntries)) + case _ => Left(MigrationError.ActionFailed("TransformValues", at, "Expected Map")) + } + + if (at.nodes.isEmpty) transformMap(value) + else { + var result: Either[MigrationError, DynamicValue] = null + val modified = value.modify(at) { dv => + transformMap(dv) match { + case Right(v) => + result = Right(v) + v + case Left(err) => + result = Left(err) + dv + } + } + if (result ne null) result.map(_ => modified) + else Left(MigrationError.PathNotFound(at)) + } + } + } +} 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..31b304f89f --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationBuilder.scala @@ -0,0 +1,285 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.schema.migration + +import zio.blocks.schema.{DynamicOptic, DynamicValue, Schema} + +/** + * A fluent builder for constructing [[Migration]]s. + * + * The builder accumulates [[MigrationAction]]s and produces a `Migration[A, B]` + * via [[build]] or [[buildPartial]]. + * + * Paths are specified using [[DynamicOptic]] directly. A future version will + * add macro-based selector support for `(A => Any)` style selectors. + * + * Example usage: + * {{{ + * val migration = Migration.newBuilder[PersonV0, Person] + * .addField(DynamicOptic.root, "age", DynamicValue.Primitive(PrimitiveValue.Int(0))) + * .renameField(DynamicOptic.root, "name", "fullName") + * .build + * }}} + */ +class MigrationBuilder[A, B]( + val sourceSchema: Schema[A], + val targetSchema: Schema[B], + val actions: Vector[MigrationAction] +) { + + // ───────────────────────────────────────────────────────────────────────── + // Record Operations + // ───────────────────────────────────────────────────────────────────────── + + /** + * Adds a new field with a default value. + * + * @param at + * the record path (e.g., `DynamicOptic.root` for root-level) + * @param fieldName + * the name of the new field + * @param default + * the default value for the new field + */ + def addField(at: DynamicOptic, fieldName: String, default: DynamicValue): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.AddField(at, fieldName, default)) + + /** + * Drops a field from the record. + * + * @param at + * the record path + * @param fieldName + * the name of the field to drop + * @param defaultForReverse + * the value to use when reversing (re-adding) the field + */ + def dropField( + at: DynamicOptic, + fieldName: String, + defaultForReverse: DynamicValue = DynamicValue.Null + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.DropField(at, fieldName, defaultForReverse) + ) + + /** + * Renames a field. + * + * @param at + * the record path where the field lives + * @param from + * the current field name + * @param to + * the new field name + */ + def renameField(at: DynamicOptic, from: String, to: String): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.Rename(at, from, to)) + + /** + * Transforms the value at a specific field path. + * + * @param at + * the path to the value to transform + * @param newValue + * the new value to set + * @param reverseValue + * the value to restore on reverse + */ + def transformValue( + at: DynamicOptic, + newValue: DynamicValue, + reverseValue: DynamicValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformValue(at, newValue, reverseValue) + ) + + /** + * Makes an optional field mandatory. + * + * @param at + * the path to the optional field + * @param default + * the default value to use when the field is None/Null + */ + def mandateField(at: DynamicOptic, default: DynamicValue): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.Mandate(at, default)) + + /** + * Makes a mandatory field optional. + * + * @param at + * the path to the mandatory field + * @param defaultForReverse + * the default value to use for reverse (Mandate) action + */ + def optionalizeField(at: DynamicOptic, defaultForReverse: DynamicValue = DynamicValue.Null): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.Optionalize(at, defaultForReverse)) + + /** + * Changes the type of a field. + * + * @param at + * the path to the field + * @param converter + * the new converted value + * @param reverseConverter + * the value to restore on reverse + */ + def changeFieldType( + at: DynamicOptic, + converter: DynamicValue, + reverseConverter: DynamicValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.ChangeType(at, converter, reverseConverter) + ) + + /** + * Joins multiple source fields into a single target field. + * + * @param at + * the target path for the joined value + * @param sourcePaths + * the paths of the source fields + * @param combiner + * the combined value (pre-computed) + */ + def joinFields( + at: DynamicOptic, + sourcePaths: Vector[DynamicOptic], + combiner: DynamicValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.Join(at, sourcePaths, combiner) + ) + + /** + * Splits a single field into multiple target fields. + * + * @param at + * the source path of the field to split + * @param targetPaths + * the paths for the split results + * @param splitter + * the split value (pre-computed) + */ + def splitField( + at: DynamicOptic, + targetPaths: Vector[DynamicOptic], + splitter: DynamicValue + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.Split(at, targetPaths, splitter) + ) + + // ───────────────────────────────────────────────────────────────────────── + // Enum Operations + // ───────────────────────────────────────────────────────────────────────── + + /** + * Renames a case in a variant/enum. + * + * @param at + * the path to the variant + * @param from + * the current case name + * @param to + * the new case name + */ + def renameCase(at: DynamicOptic, from: String, to: String): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.RenameCase(at, from, to)) + + /** + * Transforms the inner structure of a specific variant case. + * + * @param at + * the path to the variant + * @param caseName + * the name of the case to transform + * @param caseActions + * the actions to apply to the case value + */ + def transformCase( + at: DynamicOptic, + caseName: String, + caseActions: Vector[MigrationAction] + ): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformCase(at, caseName, caseActions) + ) + + // ───────────────────────────────────────────────────────────────────────── + // Collection / Map Operations + // ───────────────────────────────────────────────────────────────────────── + + /** + * Transforms all elements in a sequence. + */ + def transformElements(at: DynamicOptic, elementActions: Vector[MigrationAction]): MigrationBuilder[A, B] = + new MigrationBuilder( + sourceSchema, + targetSchema, + actions :+ MigrationAction.TransformElements(at, elementActions) + ) + + /** + * Transforms all keys in a map. + */ + def transformKeys(at: DynamicOptic, keyActions: Vector[MigrationAction]): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.TransformKeys(at, keyActions)) + + /** + * Transforms all values in a map. + */ + def transformValues(at: DynamicOptic, valueActions: Vector[MigrationAction]): MigrationBuilder[A, B] = + new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.TransformValues(at, valueActions)) + + // ───────────────────────────────────────────────────────────────────────── + // Build + // ───────────────────────────────────────────────────────────────────────── + + /** + * Builds the migration with full validation. Validates that all required + * fields in the target schema are accounted for. + * + * Note: Full macro-based validation is a future enhancement. Currently + * performs the same construction as [[buildPartial]]. + */ + def build: Migration[A, B] = + Migration(new DynamicMigration(actions), sourceSchema, targetSchema) + + /** + * Builds the migration without full validation. Useful when the migration is + * known to be correct or when validation is not needed. + */ + def buildPartial: Migration[A, B] = + Migration(new DynamicMigration(actions), sourceSchema, targetSchema) +} 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..276020d99b --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/migration/MigrationError.scala @@ -0,0 +1,77 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.schema.migration + +import zio.blocks.schema.DynamicOptic + +/** + * Represents errors that can occur during migration execution. + * + * All errors capture path information via [[DynamicOptic]] for diagnostics, + * enabling messages like: + * {{{ + * "Failed to apply TransformValue at .addresses.each.streetNumber" + * }}} + */ +sealed trait MigrationError { self => + def message: String + + def ++(that: MigrationError): MigrationError = (self, that) match { + case (MigrationError.Multiple(es1), MigrationError.Multiple(es2)) => MigrationError.Multiple(es1 ++ es2) + case (MigrationError.Multiple(es1), _) => MigrationError.Multiple(es1 :+ that) + case (_, MigrationError.Multiple(es2)) => MigrationError.Multiple(self +: es2) + case _ => MigrationError.Multiple(Vector(self, that)) + } +} + +object MigrationError { + + /** + * A migration action failed at the specified path. + * + * @param action + * the name of the action that failed (e.g., "AddField", "Rename") + * @param at + * the path where the failure occurred + * @param details + * a human-readable description of the failure + */ + final case class ActionFailed(action: String, at: DynamicOptic, details: String) extends MigrationError { + override def message: String = s"Failed to apply $action at ${at.toString}: $details" + } + + /** + * The specified path was not found in the DynamicValue being migrated. + */ + final case class PathNotFound(at: DynamicOptic) extends MigrationError { + override def message: String = s"Path not found: ${at.toString}" + } + + /** + * A type conversion failed during migration. + */ + final case class TypeConversionFailed(at: DynamicOptic, from: String, to: String) extends MigrationError { + override def message: String = s"Type conversion failed at ${at.toString}: cannot convert $from to $to" + } + + /** + * Multiple migration errors occurred. + */ + final case class Multiple(errors: Vector[MigrationError]) extends MigrationError { + override def message: String = errors.map(_.message).mkString("; ") + } +} 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..451fce240d --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/DynamicMigrationSpec.scala @@ -0,0 +1,284 @@ +package zio.blocks.schema.migration + +import zio.blocks.chunk.Chunk +import zio.blocks.schema._ +import zio.test.Assertion._ +import zio.test.{Spec, TestEnvironment, assert} + +object DynamicMigrationSpec extends SchemaBaseSpec { + def spec: Spec[TestEnvironment, Any] = suite("DynamicMigrationSpec")( + suite("Identity")( + test("identity migration returns the value unchanged") { + val value = DynamicValue.Record( + Chunk( + ("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(30))) + ) + ) + assert(DynamicMigration.identity(value))(isRight(equalTo(value))) + }, + test("identity migration has no actions") { + assert(DynamicMigration.identity.isEmpty)(equalTo(true)) + } + ), + suite("Associativity")( + test("(m1 ++ m2) ++ m3 produces same result as m1 ++ (m2 ++ m3)") { + val m1 = DynamicMigration( + MigrationAction.AddField(DynamicOptic.root, "age", DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + val m2 = DynamicMigration(MigrationAction.Rename(DynamicOptic.root, "name", "fullName")) + val m3 = DynamicMigration(MigrationAction.DropField(DynamicOptic.root, "unused", DynamicValue.Null)) + + val value = DynamicValue.Record( + Chunk( + ("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("unused", DynamicValue.Primitive(PrimitiveValue.String("data"))) + ) + ) + + val leftAssoc = ((m1 ++ m2) ++ m3)(value) + val rightAssoc = (m1 ++ (m2 ++ m3))(value) + + assert(leftAssoc)(equalTo(rightAssoc)) + } + ), + suite("Structural Reverse")( + test("m.reverse.reverse has same actions as m") { + val m = DynamicMigration( + Vector( + MigrationAction.AddField(DynamicOptic.root, "age", DynamicValue.Primitive(PrimitiveValue.Int(0))), + MigrationAction.Rename(DynamicOptic.root, "name", "fullName") + ) + ) + assert(m.reverse.reverse)(equalTo(m)) + }, + test("identity reverse is identity") { + assert(DynamicMigration.identity.reverse)(equalTo(DynamicMigration.identity)) + }, + test("reverse with all action types") { + val m = DynamicMigration( + Vector( + MigrationAction.AddField(DynamicOptic.root, "x", DynamicValue.Null), + MigrationAction.DropField(DynamicOptic.root, "y", DynamicValue.Null), + MigrationAction.Rename(DynamicOptic.root, "a", "b"), + MigrationAction.RenameCase(DynamicOptic.root, "Old", "New"), + MigrationAction.ChangeType( + DynamicOptic.root.field("z"), + DynamicValue.Primitive(PrimitiveValue.String("0")), + DynamicValue.Primitive(PrimitiveValue.Int(0)) + ) + ) + ) + assert(m.reverse.reverse)(equalTo(m)) + } + ), + suite("AddField")( + test("adds a field with default value") { + val m = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "age", + DynamicValue.Primitive(PrimitiveValue.Int(25)) + ) + ) + val input = DynamicValue.Record( + Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice")))) + ) + val expected = DynamicValue.Record( + Chunk( + ("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(25))) + ) + ) + assert(m(input))(isRight(equalTo(expected))) + } + ), + suite("DropField")( + test("removes a field from a record") { + val m = DynamicMigration( + MigrationAction.DropField( + DynamicOptic.root, + "age", + DynamicValue.Primitive(PrimitiveValue.Int(0)) + ) + ) + val input = DynamicValue.Record( + Chunk( + ("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(30))) + ) + ) + val expected = DynamicValue.Record( + Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice")))) + ) + assert(m(input))(isRight(equalTo(expected))) + } + ), + suite("Rename")( + test("renames a field in a record") { + val m = DynamicMigration(MigrationAction.Rename(DynamicOptic.root, "name", "fullName")) + val input = DynamicValue.Record( + Chunk( + ("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(30))) + ) + ) + val expected = DynamicValue.Record( + Chunk( + ("fullName", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(30))) + ) + ) + assert(m(input))(isRight(equalTo(expected))) + }, + test("rename round-trips via reverse") { + val m = DynamicMigration(MigrationAction.Rename(DynamicOptic.root, "name", "fullName")) + val input = DynamicValue.Record( + Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice")))) + ) + val result = for { + migrated <- m(input) + restored <- m.reverse(migrated) + } yield restored + assert(result)(isRight(equalTo(input))) + } + ), + suite("AddField + DropField round-trip")( + test("add then drop restores original via reverse") { + val m = DynamicMigration( + MigrationAction.AddField( + DynamicOptic.root, + "country", + DynamicValue.Primitive(PrimitiveValue.String("US")) + ) + ) + val input = DynamicValue.Record( + Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice")))) + ) + val result = for { + migrated <- m(input) + restored <- m.reverse(migrated) + } yield restored + assert(result)(isRight(equalTo(input))) + } + ), + suite("Composition")( + test("multiple actions compose correctly") { + val m = DynamicMigration( + Vector( + MigrationAction.AddField(DynamicOptic.root, "age", DynamicValue.Primitive(PrimitiveValue.Int(0))), + MigrationAction.Rename(DynamicOptic.root, "name", "fullName") + ) + ) + val input = DynamicValue.Record( + Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice")))) + ) + val expected = DynamicValue.Record( + Chunk( + ("fullName", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(0))) + ) + ) + assert(m(input))(isRight(equalTo(expected))) + }, + test("andThen is alias for ++") { + val m1 = DynamicMigration( + MigrationAction.AddField(DynamicOptic.root, "x", DynamicValue.Null) + ) + val m2 = DynamicMigration( + MigrationAction.Rename(DynamicOptic.root, "a", "b") + ) + assert((m1 ++ m2).actions)(equalTo(m1.andThen(m2).actions)) + } + ), + suite("RenameCase")( + test("renames a variant case") { + val m = DynamicMigration(MigrationAction.RenameCase(DynamicOptic.root, "OldName", "NewName")) + val input = DynamicValue.Variant( + "OldName", + DynamicValue.Record(Chunk(("value", DynamicValue.Primitive(PrimitiveValue.Int(42))))) + ) + val expected = DynamicValue.Variant( + "NewName", + DynamicValue.Record(Chunk(("value", DynamicValue.Primitive(PrimitiveValue.Int(42))))) + ) + assert(m(input))(isRight(equalTo(expected))) + }, + test("rename case round-trips via reverse") { + val m = DynamicMigration(MigrationAction.RenameCase(DynamicOptic.root, "OldName", "NewName")) + val input = DynamicValue.Variant("OldName", DynamicValue.Primitive(PrimitiveValue.Int(42))) + val result = for { + migrated <- m(input) + restored <- m.reverse(migrated) + } yield restored + assert(result)(isRight(equalTo(input))) + } + ), + suite("TransformCase")( + test("transforms case with nested rename action") { + val m = DynamicMigration( + MigrationAction.TransformCase( + DynamicOptic.root, + "Person", + Vector(MigrationAction.Rename(DynamicOptic.root, "name", "fullName")) + ) + ) + val input = DynamicValue.Variant( + "Person", + DynamicValue.Record(Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + ) + val expected = DynamicValue.Variant( + "Person", + DynamicValue.Record(Chunk(("fullName", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + ) + assert(m(input))(isRight(equalTo(expected))) + } + ), + suite("TransformElements")( + test("transforms each element in sequence") { + val m = DynamicMigration( + MigrationAction.TransformElements( + DynamicOptic.root, + Vector(MigrationAction.Rename(DynamicOptic.root, "old", "new")) + ) + ) + val input = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk(("old", DynamicValue.Primitive(PrimitiveValue.Int(1))))), + DynamicValue.Record(Chunk(("old", DynamicValue.Primitive(PrimitiveValue.Int(2))))) + ) + ) + val expected = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk(("new", DynamicValue.Primitive(PrimitiveValue.Int(1))))), + DynamicValue.Record(Chunk(("new", DynamicValue.Primitive(PrimitiveValue.Int(2))))) + ) + ) + assert(m(input))(isRight(equalTo(expected))) + } + ), + suite("Error Handling")( + test("errors include path information") { + val m = DynamicMigration(MigrationAction.Rename(DynamicOptic.root, "nonexistent", "newName")) + val input = DynamicValue.Record( + Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice")))) + ) + val result = m(input) + assert(result.isLeft)(equalTo(true)) + }, + test("action on non-record fails with descriptive error") { + val m = DynamicMigration( + MigrationAction.AddField(DynamicOptic.root, "field", DynamicValue.Null) + ) + val input = DynamicValue.Primitive(PrimitiveValue.String("not a record")) + val result = m(input) + assert(result.isLeft)(equalTo(true)) + }, + test("error message contains action name") { + val err = MigrationError.ActionFailed("Rename", DynamicOptic.root.field("x"), "test details") + assert(err.message.contains("Rename"))(equalTo(true)) && + assert(err.message.contains("test details"))(equalTo(true)) + } + ) + ) +} 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..b5c0a3b1a2 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationActionSpec.scala @@ -0,0 +1,368 @@ +package zio.blocks.schema.migration + +import zio.blocks.chunk.Chunk +import zio.blocks.schema._ +import zio.test.Assertion._ +import zio.test.{Spec, TestEnvironment, assert} + +object MigrationActionSpec extends SchemaBaseSpec { + def spec: Spec[TestEnvironment, Any] = suite("MigrationActionSpec")( + suite("AddField")( + test("adds a field to root record") { + val action = MigrationAction.AddField( + DynamicOptic.root, + "age", + DynamicValue.Primitive(PrimitiveValue.Int(25)) + ) + val input = DynamicValue.Record(Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + val expected = DynamicValue.Record( + Chunk( + ("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(25))) + ) + ) + assert(action(input))(isRight(equalTo(expected))) + }, + test("fails on non-record") { + val action = MigrationAction.AddField( + DynamicOptic.root, + "field", + DynamicValue.Null + ) + val input = DynamicValue.Primitive(PrimitiveValue.String("not a record")) + assert(action(input).isLeft)(equalTo(true)) + }, + test("reverse is DropField") { + val action = MigrationAction.AddField(DynamicOptic.root, "age", DynamicValue.Primitive(PrimitiveValue.Int(0))) + val reverse = action.reverse + assert(reverse.isInstanceOf[MigrationAction.DropField])(equalTo(true)) + } + ), + suite("DropField")( + test("removes a field from root record") { + val action = MigrationAction.DropField( + DynamicOptic.root, + "age", + DynamicValue.Primitive(PrimitiveValue.Int(0)) + ) + val input = DynamicValue.Record( + Chunk( + ("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))), + ("age", DynamicValue.Primitive(PrimitiveValue.Int(30))) + ) + ) + val expected = DynamicValue.Record(Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + assert(action(input))(isRight(equalTo(expected))) + }, + test("fails when field does not exist") { + val action = MigrationAction.DropField(DynamicOptic.root, "nonexistent", DynamicValue.Null) + val input = DynamicValue.Record(Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + assert(action(input).isLeft)(equalTo(true)) + } + ), + suite("Rename")( + test("renames a field") { + val action = MigrationAction.Rename(DynamicOptic.root, "name", "fullName") + val input = DynamicValue.Record(Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + val expected = DynamicValue.Record(Chunk(("fullName", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + assert(action(input))(isRight(equalTo(expected))) + }, + test("fails when field doesn't exist") { + val action = MigrationAction.Rename(DynamicOptic.root, "missing", "newName") + val input = DynamicValue.Record(Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + assert(action(input).isLeft)(equalTo(true)) + }, + test("fails on non-record") { + val action = MigrationAction.Rename(DynamicOptic.root, "name", "fullName") + val input = DynamicValue.Primitive(PrimitiveValue.String("not a record")) + assert(action(input).isLeft)(equalTo(true)) + }, + test("reverse swaps from and to") { + val action = MigrationAction.Rename(DynamicOptic.root, "a", "b") + val reverse = action.reverse.asInstanceOf[MigrationAction.Rename] + assert(reverse.from)(equalTo("b")) && assert(reverse.to)(equalTo("a")) + } + ), + suite("TransformValue")( + test("sets a value at a path") { + val action = MigrationAction.TransformValue( + DynamicOptic.root.field("age"), + DynamicValue.Primitive(PrimitiveValue.Int(99)), + DynamicValue.Primitive(PrimitiveValue.Int(0)) + ) + val input = DynamicValue.Record(Chunk(("age", DynamicValue.Primitive(PrimitiveValue.Int(30))))) + val result = action(input) + assert(result)(isRight) && + assert(result.toOption.get.fields.head._2)(equalTo(DynamicValue.Primitive(PrimitiveValue.Int(99)))) + }, + test("reverse swaps transform and reverseTransform") { + val action = MigrationAction.TransformValue( + DynamicOptic.root.field("x"), + DynamicValue.Primitive(PrimitiveValue.Int(1)), + DynamicValue.Primitive(PrimitiveValue.Int(2)) + ) + val reverse = action.reverse.asInstanceOf[MigrationAction.TransformValue] + assert(reverse.transform)(equalTo(DynamicValue.Primitive(PrimitiveValue.Int(2)))) && + assert(reverse.reverseTransform)(equalTo(DynamicValue.Primitive(PrimitiveValue.Int(1)))) + } + ), + suite("Mandate")( + test("converts None variant to default") { + val action = MigrationAction.Mandate( + DynamicOptic.root.field("opt"), + DynamicValue.Primitive(PrimitiveValue.String("default")) + ) + val input = DynamicValue.Record( + Chunk(("opt", DynamicValue.Variant("None", DynamicValue.Record.empty))) + ) + val result = action(input) + assert(result)(isRight) && + assert(result.toOption.get.fields.head._2)( + equalTo(DynamicValue.Primitive(PrimitiveValue.String("default"))) + ) + }, + test("unwraps Some variant") { + val action = MigrationAction.Mandate( + DynamicOptic.root.field("opt"), + DynamicValue.Primitive(PrimitiveValue.String("default")) + ) + val inner = DynamicValue.Primitive(PrimitiveValue.String("value")) + val input = DynamicValue.Record(Chunk(("opt", DynamicValue.Variant("Some", inner)))) + val result = action(input) + assert(result)(isRight) && + assert(result.toOption.get.fields.head._2)(equalTo(inner)) + }, + test("reverse is Optionalize") { + val action = MigrationAction.Mandate(DynamicOptic.root.field("x"), DynamicValue.Null) + assert(action.reverse.isInstanceOf[MigrationAction.Optionalize])(equalTo(true)) + } + ), + suite("Optionalize")( + test("wraps value in Some variant") { + val action = MigrationAction.Optionalize(DynamicOptic.root.field("x"), DynamicValue.Null) + val inner = DynamicValue.Primitive(PrimitiveValue.Int(42)) + val input = DynamicValue.Record(Chunk(("x", inner))) + val result = action(input) + assert(result)(isRight) && + assert(result.toOption.get.fields.head._2)(equalTo(DynamicValue.Variant("Some", inner))) + }, + test("leaves Null as Null") { + val action = MigrationAction.Optionalize(DynamicOptic.root.field("x"), DynamicValue.Null) + val input = DynamicValue.Record(Chunk(("x", DynamicValue.Null))) + val result = action(input) + assert(result)(isRight) && + assert(result.toOption.get.fields.head._2)(equalTo(DynamicValue.Null)) + }, + test("reverse is Mandate with stored default") { + val dflt = DynamicValue.Primitive(PrimitiveValue.Int(0)) + val action = MigrationAction.Optionalize(DynamicOptic.root.field("x"), dflt) + val reverse = action.reverse.asInstanceOf[MigrationAction.Mandate] + assert(reverse.default)(equalTo(dflt)) + } + ), + suite("ChangeType")( + test("replaces value at path") { + val action = MigrationAction.ChangeType( + DynamicOptic.root.field("val"), + DynamicValue.Primitive(PrimitiveValue.String("42")), + DynamicValue.Primitive(PrimitiveValue.Int(42)) + ) + val input = DynamicValue.Record(Chunk(("val", DynamicValue.Primitive(PrimitiveValue.Int(42))))) + val result = action(input) + assert(result)(isRight) && + assert(result.toOption.get.fields.head._2)( + equalTo(DynamicValue.Primitive(PrimitiveValue.String("42"))) + ) + }, + test("reverse swaps converter and reverseConverter") { + val action = MigrationAction.ChangeType( + DynamicOptic.root.field("x"), + DynamicValue.Primitive(PrimitiveValue.String("1")), + DynamicValue.Primitive(PrimitiveValue.Int(1)) + ) + val reverse = action.reverse.asInstanceOf[MigrationAction.ChangeType] + assert(reverse.converter)(equalTo(DynamicValue.Primitive(PrimitiveValue.Int(1)))) + } + ), + suite("RenameCase")( + test("renames a variant case") { + val action = MigrationAction.RenameCase(DynamicOptic.root, "Old", "New") + val input = DynamicValue.Variant("Old", DynamicValue.Primitive(PrimitiveValue.Int(1))) + val expected = DynamicValue.Variant("New", DynamicValue.Primitive(PrimitiveValue.Int(1))) + assert(action(input))(isRight(equalTo(expected))) + }, + test("fails when case doesn't match") { + val action = MigrationAction.RenameCase(DynamicOptic.root, "Nonexistent", "New") + val input = DynamicValue.Variant("Other", DynamicValue.Primitive(PrimitiveValue.Int(1))) + assert(action(input).isLeft)(equalTo(true)) + }, + test("reverse swaps from and to") { + val action = MigrationAction.RenameCase(DynamicOptic.root, "Old", "New") + val reverse = action.reverse.asInstanceOf[MigrationAction.RenameCase] + assert(reverse.from)(equalTo("New")) && assert(reverse.to)(equalTo("Old")) + } + ), + suite("TransformCase")( + test("transforms case value with nested actions") { + val action = MigrationAction.TransformCase( + DynamicOptic.root, + "Person", + Vector(MigrationAction.Rename(DynamicOptic.root, "name", "fullName")) + ) + val input = DynamicValue.Variant( + "Person", + DynamicValue.Record(Chunk(("name", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + ) + val expected = DynamicValue.Variant( + "Person", + DynamicValue.Record(Chunk(("fullName", DynamicValue.Primitive(PrimitiveValue.String("Alice"))))) + ) + assert(action(input))(isRight(equalTo(expected))) + }, + test("skips non-matching cases") { + val action = MigrationAction.TransformCase( + DynamicOptic.root, + "Person", + Vector(MigrationAction.Rename(DynamicOptic.root, "name", "fullName")) + ) + val input = DynamicValue.Variant( + "Animal", + DynamicValue.Record(Chunk(("species", DynamicValue.Primitive(PrimitiveValue.String("Dog"))))) + ) + assert(action(input))(isRight(equalTo(input))) + }, + test("reverse reverses nested actions") { + val action = MigrationAction.TransformCase( + DynamicOptic.root, + "X", + Vector( + MigrationAction.Rename(DynamicOptic.root, "a", "b"), + MigrationAction.AddField(DynamicOptic.root, "c", DynamicValue.Null) + ) + ) + val reverse = action.reverse.asInstanceOf[MigrationAction.TransformCase] + assert(reverse.actions.length)(equalTo(2)) && + assert(reverse.actions.head.isInstanceOf[MigrationAction.DropField])(equalTo(true)) && + assert(reverse.actions(1).isInstanceOf[MigrationAction.Rename])(equalTo(true)) + } + ), + suite("TransformElements")( + test("transforms each element in a sequence") { + val action = MigrationAction.TransformElements( + DynamicOptic.root, + Vector(MigrationAction.Rename(DynamicOptic.root, "old", "new")) + ) + val input = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk(("old", DynamicValue.Primitive(PrimitiveValue.Int(1))))), + DynamicValue.Record(Chunk(("old", DynamicValue.Primitive(PrimitiveValue.Int(2))))) + ) + ) + val expected = DynamicValue.Sequence( + Chunk( + DynamicValue.Record(Chunk(("new", DynamicValue.Primitive(PrimitiveValue.Int(1))))), + DynamicValue.Record(Chunk(("new", DynamicValue.Primitive(PrimitiveValue.Int(2))))) + ) + ) + assert(action(input))(isRight(equalTo(expected))) + }, + test("fails on non-sequence") { + val action = MigrationAction.TransformElements(DynamicOptic.root, Vector.empty) + val input = DynamicValue.Primitive(PrimitiveValue.Int(42)) + assert(action(input).isLeft)(equalTo(true)) + } + ), + suite("TransformKeys")( + test("transforms each key in a map") { + val action = MigrationAction.TransformKeys( + DynamicOptic.root, + Vector( + MigrationAction.AddField(DynamicOptic.root, "extra", DynamicValue.Null) + ) + ) + val key1 = DynamicValue.Record(Chunk(("id", DynamicValue.Primitive(PrimitiveValue.Int(1))))) + val val1 = DynamicValue.Primitive(PrimitiveValue.String("a")) + val input = DynamicValue.Map(Chunk((key1, val1))) + + val result = action(input) + assert(result.isRight)(equalTo(true)) + }, + test("fails on non-map") { + val action = MigrationAction.TransformKeys(DynamicOptic.root, Vector.empty) + val input = DynamicValue.Primitive(PrimitiveValue.Int(42)) + assert(action(input).isLeft)(equalTo(true)) + } + ), + suite("TransformValues")( + test("transforms each value in a map") { + val action = MigrationAction.TransformValues( + DynamicOptic.root, + Vector( + MigrationAction.AddField(DynamicOptic.root, "extra", DynamicValue.Null) + ) + ) + val key1 = DynamicValue.Primitive(PrimitiveValue.String("key")) + val val1 = DynamicValue.Record(Chunk(("data", DynamicValue.Primitive(PrimitiveValue.Int(42))))) + val input = DynamicValue.Map(Chunk((key1, val1))) + + val result = action(input) + assert(result.isRight)(equalTo(true)) + }, + test("fails on non-map") { + val action = MigrationAction.TransformValues(DynamicOptic.root, Vector.empty) + val input = DynamicValue.Primitive(PrimitiveValue.Int(42)) + assert(action(input).isLeft)(equalTo(true)) + } + ), + suite("Join")( + test("sets combiner value at target path") { + val action = MigrationAction.Join( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("first"), DynamicOptic.root.field("last")), + DynamicValue.Primitive(PrimitiveValue.String("John Doe")) + ) + val input = DynamicValue.Record( + Chunk( + ("first", DynamicValue.Primitive(PrimitiveValue.String("John"))), + ("last", DynamicValue.Primitive(PrimitiveValue.String("Doe"))), + ("fullName", DynamicValue.Primitive(PrimitiveValue.String(""))) + ) + ) + val result = action(input) + assert(result.isRight)(equalTo(true)) + }, + test("reverse is Split") { + val action = MigrationAction.Join( + DynamicOptic.root.field("x"), + Vector(DynamicOptic.root.field("a")), + DynamicValue.Null + ) + assert(action.reverse.isInstanceOf[MigrationAction.Split])(equalTo(true)) + } + ), + suite("Split")( + test("sets splitter value at source path") { + val action = MigrationAction.Split( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("first"), DynamicOptic.root.field("last")), + DynamicValue.Primitive(PrimitiveValue.String("")) + ) + val input = DynamicValue.Record( + Chunk( + ("fullName", DynamicValue.Primitive(PrimitiveValue.String("John Doe"))) + ) + ) + val result = action(input) + assert(result.isRight)(equalTo(true)) + }, + test("reverse is Join") { + val action = MigrationAction.Split( + DynamicOptic.root.field("x"), + Vector(DynamicOptic.root.field("a")), + DynamicValue.Null + ) + assert(action.reverse.isInstanceOf[MigrationAction.Join])(equalTo(true)) + } + ) + ) +} 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..0d1085aaa5 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/migration/MigrationSpec.scala @@ -0,0 +1,188 @@ +package zio.blocks.schema.migration + +import zio.blocks.schema._ +import zio.test.Assertion._ +import zio.test.{Spec, TestEnvironment, assert} + +object MigrationSpec extends SchemaBaseSpec { + + 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 + } + + def spec: Spec[TestEnvironment, Any] = suite("MigrationSpec")( + suite("Identity")( + test("identity migration returns the input value unchanged") { + val person = PersonV1("Alice", 30) + val m = Migration.identity[PersonV1] + assert(m(person))(isRight(equalTo(person))) + } + ), + suite("Typed Migration")( + test("migrates PersonV1 to PersonV2 with rename and add field") { + val migration = Migration + .newBuilder[PersonV1, PersonV2] + .renameField(DynamicOptic.root, "name", "fullName") + .addField(DynamicOptic.root, "country", DynamicValue.Primitive(PrimitiveValue.String("US"))) + .build + + val old = PersonV1("Alice", 30) + assert(migration(old))(isRight(equalTo(PersonV2("Alice", 30, "US")))) + }, + test("reverse migration from PersonV2 back to PersonV1") { + val migration = Migration + .newBuilder[PersonV1, PersonV2] + .renameField(DynamicOptic.root, "name", "fullName") + .addField(DynamicOptic.root, "country", DynamicValue.Primitive(PrimitiveValue.String("US"))) + .build + + val v2 = PersonV2("Alice", 30, "US") + val reversed = migration.reverse + assert(reversed(v2))(isRight(equalTo(PersonV1("Alice", 30)))) + }, + test("full round-trip: apply then reverse") { + val migration = Migration + .newBuilder[PersonV1, PersonV2] + .renameField(DynamicOptic.root, "name", "fullName") + .addField(DynamicOptic.root, "country", DynamicValue.Primitive(PrimitiveValue.String("US"))) + .build + + val original = PersonV1("Bob", 25) + val result = for { + migrated <- migration(original) + restored <- migration.reverse(migrated) + } yield restored + assert(result)(isRight(equalTo(original))) + } + ), + suite("Composition")( + test("composing two typed migrations works end-to-end") { + val m1 = Migration + .newBuilder[PersonV1, PersonV2] + .renameField(DynamicOptic.root, "name", "fullName") + .addField(DynamicOptic.root, "country", DynamicValue.Primitive(PrimitiveValue.String("US"))) + .build + + val m2 = m1.reverse + + val composed = m1 ++ m2 + val original = PersonV1("Bob", 25) + + assert(composed(original))(isRight(equalTo(original))) + }, + test("andThen is alias for ++") { + val m1 = Migration + .newBuilder[PersonV1, PersonV2] + .renameField(DynamicOptic.root, "name", "fullName") + .addField(DynamicOptic.root, "country", DynamicValue.Primitive(PrimitiveValue.String("US"))) + .build + val m2 = m1.reverse + val original = PersonV1("Charlie", 35) + + val result1 = (m1 ++ m2)(original) + val result2 = m1.andThen(m2)(original) + + assert(result1)(equalTo(result2)) + } + ), + suite("Error Handling")( + test("migration error includes readable message") { + val err = MigrationError.ActionFailed("AddField", DynamicOptic.root.field("address"), "Expected Record") + assert(err.message.contains("AddField"))(equalTo(true)) && + assert(err.message.contains("address"))(equalTo(true)) + }, + test("multiple errors can be combined") { + val e1 = MigrationError.PathNotFound(DynamicOptic.root.field("x")) + val e2 = MigrationError.PathNotFound(DynamicOptic.root.field("y")) + val combined = e1 ++ e2 + combined match { + case MigrationError.Multiple(errors) => + assert(errors.length)(equalTo(2)) + case _ => + assert(false)(equalTo(true)) + } + }, + test("TypeConversionFailed includes from/to types") { + val err = MigrationError.TypeConversionFailed(DynamicOptic.root.field("x"), "Int", "String") + assert(err.message.contains("Int"))(equalTo(true)) && + assert(err.message.contains("String"))(equalTo(true)) + } + ), + suite("MigrationBuilder")( + test("builder accumulates actions in order") { + val builder = Migration + .newBuilder[PersonV1, PersonV2] + .addField(DynamicOptic.root, "country", DynamicValue.Primitive(PrimitiveValue.String("US"))) + .renameField(DynamicOptic.root, "name", "fullName") + + assert(builder.actions.length)(equalTo(2)) && + assert(builder.actions.head.isInstanceOf[MigrationAction.AddField])(equalTo(true)) && + assert(builder.actions(1).isInstanceOf[MigrationAction.Rename])(equalTo(true)) + }, + test("buildPartial creates migration without validation") { + val migration = Migration + .newBuilder[PersonV1, PersonV2] + .renameField(DynamicOptic.root, "name", "fullName") + .addField(DynamicOptic.root, "country", DynamicValue.Primitive(PrimitiveValue.String("US"))) + .buildPartial + + val old = PersonV1("Charlie", 40) + assert(migration(old))(isRight(equalTo(PersonV2("Charlie", 40, "US")))) + }, + test("builder supports all record operations") { + val builder = Migration + .newBuilder[PersonV1, PersonV2] + .addField(DynamicOptic.root, "f1", DynamicValue.Null) + .dropField(DynamicOptic.root, "f2") + .renameField(DynamicOptic.root, "f3", "f4") + .transformValue(DynamicOptic.root.field("f5"), DynamicValue.Null, DynamicValue.Null) + .mandateField(DynamicOptic.root.field("f6"), DynamicValue.Null) + .optionalizeField(DynamicOptic.root.field("f7")) + .changeFieldType(DynamicOptic.root.field("f8"), DynamicValue.Null, DynamicValue.Null) + + assert(builder.actions.length)(equalTo(7)) + }, + test("builder supports enum operations") { + val builder = Migration + .newBuilder[PersonV1, PersonV2] + .renameCase(DynamicOptic.root, "Old", "New") + .transformCase(DynamicOptic.root, "Case1", Vector.empty) + + assert(builder.actions.length)(equalTo(2)) + }, + test("builder supports collection operations") { + val builder = Migration + .newBuilder[PersonV1, PersonV2] + .transformElements(DynamicOptic.root.field("items"), Vector.empty) + .transformKeys(DynamicOptic.root.field("map"), Vector.empty) + .transformValues(DynamicOptic.root.field("map"), Vector.empty) + + assert(builder.actions.length)(equalTo(3)) + }, + test("builder supports join and split") { + val builder = Migration + .newBuilder[PersonV1, PersonV2] + .joinFields( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("first"), DynamicOptic.root.field("last")), + DynamicValue.Null + ) + .splitField( + DynamicOptic.root.field("fullName"), + Vector(DynamicOptic.root.field("first"), DynamicOptic.root.field("last")), + DynamicValue.Null + ) + + assert(builder.actions.length)(equalTo(2)) && + assert(builder.actions.head.isInstanceOf[MigrationAction.Join])(equalTo(true)) && + assert(builder.actions(1).isInstanceOf[MigrationAction.Split])(equalTo(true)) + } + ) + ) +}