diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/MigrationBuilderVersionSpecific.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/MigrationBuilderVersionSpecific.scala new file mode 100644 index 0000000000..f5b48a1430 --- /dev/null +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/MigrationBuilderVersionSpecific.scala @@ -0,0 +1,60 @@ +/* + * 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 + +import scala.language.experimental.macros + +/** + * Scala 2 selector-based API for MigrationBuilder. Mixes in methods that accept + * selector lambdas (e.g. `_.name`) and convert them to DynamicOptic at compile + * time via blackbox macros. + * + * Each method here overloads the corresponding DynamicOptic-based method + * defined in MigrationBuilder itself. Overload resolution picks the selector + * variant when the argument is a lambda and the optic variant when a + * DynamicOptic is supplied directly. + */ +trait MigrationBuilderVersionSpecific[A, B, State <: BuilderState] { + this: MigrationBuilder[A, B, State] => + + def renameField[C, D](from: A => C, to: B => D): MigrationBuilder[A, B, State] = + macro MigrationMacros.renameFieldImpl + + def addField[C](at: B => C, value: MigrationExpr): MigrationBuilder[A, B, State] = + macro MigrationMacros.addFieldImpl + + def dropField[C](at: A => C, defaultForReverse: MigrationExpr): MigrationBuilder[A, B, State] = + macro MigrationMacros.dropFieldImpl + + def transformValue[C]( + at: A => C, + transform: MigrationExpr, + inverseTransform: MigrationExpr + ): MigrationBuilder[A, B, State] = macro MigrationMacros.transformValueImpl + + def optionalize[C](at: A => C, defaultForReverse: MigrationExpr): MigrationBuilder[A, B, State] = + macro MigrationMacros.optionalizeImpl + + def mandate[C](at: A => C): MigrationBuilder[A, B, State] = + macro MigrationMacros.mandateImpl + + def changeFieldType[C]( + at: A => C, + converter: MigrationExpr, + inverseConverter: MigrationExpr + ): MigrationBuilder[A, B, State] = macro MigrationMacros.changeFieldTypeImpl +} diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/MigrationMacros.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/MigrationMacros.scala new file mode 100644 index 0000000000..ee1be4d9a7 --- /dev/null +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/MigrationMacros.scala @@ -0,0 +1,98 @@ +/* + * 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 + +import scala.reflect.NameTransformer +import scala.reflect.macros.blackbox + +object MigrationMacros { + + /** + * Converts a selector lambda like `_.field` or `_.a.b` into a DynamicOptic + * tree by walking the lambda body and collecting each field-select step. + */ + def selectorBodyToOptic(c: blackbox.Context)(selectorTree: c.Tree): c.Tree = { + import c.universe._ + + def extractFields(tree: c.Tree): List[String] = tree match { + case q"$parent.$field" => extractFields(parent) :+ NameTransformer.decode(field.toString) + case _: Ident => Nil + case _ => + c.abort( + c.enclosingPosition, + s"Migration selector must be a chain of field accesses (e.g. _.field or _.a.b), got '$tree'" + ) + } + + val body = selectorTree match { + case Function(_, body) => body + case _ => c.abort(c.enclosingPosition, s"Expected a lambda expression, got '$selectorTree'") + } + + val fields = extractFields(body) + fields.foldLeft[c.Tree](q"_root_.zio.blocks.schema.DynamicOptic.root") { (acc, name) => + q"$acc.field($name)" + } + } + + def renameFieldImpl(c: blackbox.Context)(from: c.Expr[Any], to: c.Expr[Any]): c.Tree = { + import c.universe._ + val fromOptic = selectorBodyToOptic(c)(from.tree) + val toOptic = selectorBodyToOptic(c)(to.tree) + q"${c.prefix}.renameField($fromOptic, $toOptic)" + } + + def addFieldImpl(c: blackbox.Context)(at: c.Expr[Any], value: c.Expr[Any]): c.Tree = { + import c.universe._ + val atOptic = selectorBodyToOptic(c)(at.tree) + q"${c.prefix}.addField($atOptic, $value)" + } + + def dropFieldImpl(c: blackbox.Context)(at: c.Expr[Any], defaultForReverse: c.Expr[Any]): c.Tree = { + import c.universe._ + val atOptic = selectorBodyToOptic(c)(at.tree) + q"${c.prefix}.dropField($atOptic, $defaultForReverse)" + } + + def transformValueImpl( + c: blackbox.Context + )(at: c.Expr[Any], transform: c.Expr[Any], inverseTransform: c.Expr[Any]): c.Tree = { + import c.universe._ + val atOptic = selectorBodyToOptic(c)(at.tree) + q"${c.prefix}.transformValue($atOptic, $transform, $inverseTransform)" + } + + def optionalizeImpl(c: blackbox.Context)(at: c.Expr[Any], defaultForReverse: c.Expr[Any]): c.Tree = { + import c.universe._ + val atOptic = selectorBodyToOptic(c)(at.tree) + q"${c.prefix}.optionalize($atOptic, $defaultForReverse)" + } + + def mandateImpl(c: blackbox.Context)(at: c.Expr[Any]): c.Tree = { + import c.universe._ + val atOptic = selectorBodyToOptic(c)(at.tree) + q"${c.prefix}.mandate($atOptic)" + } + + def changeFieldTypeImpl( + c: blackbox.Context + )(at: c.Expr[Any], converter: c.Expr[Any], inverseConverter: c.Expr[Any]): c.Tree = { + import c.universe._ + val atOptic = selectorBodyToOptic(c)(at.tree) + q"${c.prefix}.changeFieldType($atOptic, $converter, $inverseConverter)" + } +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/MigrationBuilderVersionSpecific.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/MigrationBuilderVersionSpecific.scala new file mode 100644 index 0000000000..515615ed7d --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/MigrationBuilderVersionSpecific.scala @@ -0,0 +1,71 @@ +/* + * 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 + +import scala.quoted.* + +/** + * Scala 3 selector-based API for MigrationBuilder. Mixes in inline methods that + * accept selector lambdas (e.g. `_.name`) and convert them to DynamicOptic at + * compile time via MigrationMacros.selectorToOpticImpl. + * + * `opticOf` is the single-splice macro helper; all other methods are plain + * inline methods that delegate to the DynamicOptic-based overloads defined in + * MigrationBuilder once the selector has been materialised. + */ +trait MigrationBuilderVersionSpecific[A, B, State <: BuilderState] { + this: MigrationBuilder[A, B, State] => + + /** Materialises a selector lambda into a DynamicOptic at compile time. */ + inline def opticOf[X](inline path: X => Any): DynamicOptic = + ${ MigrationMacros.selectorToOpticImpl('path) } + + inline def renameField[C, D](inline from: A => C, inline to: B => D): MigrationBuilder[A, B, State] = + renameField(opticOf(from), opticOf(to)) + + inline def addField[C](inline at: B => C, value: MigrationExpr): MigrationBuilder[A, B, State] = + addField(opticOf(at), value) + + inline def dropField[C]( + inline at: A => C, + defaultForReverse: MigrationExpr + ): MigrationBuilder[A, B, State] = + dropField(opticOf(at), defaultForReverse) + + inline def transformValue[C]( + inline at: A => C, + transform: MigrationExpr, + inverseTransform: MigrationExpr + ): MigrationBuilder[A, B, State] = + transformValue(opticOf(at), transform, inverseTransform) + + inline def optionalize[C]( + inline at: A => C, + defaultForReverse: MigrationExpr + ): MigrationBuilder[A, B, State] = + optionalize(opticOf(at), defaultForReverse) + + inline def mandate[C](inline at: A => C): MigrationBuilder[A, B, State] = + mandate(opticOf(at)) + + inline def changeFieldType[C]( + inline at: A => C, + converter: MigrationExpr, + inverseConverter: MigrationExpr + ): MigrationBuilder[A, B, State] = + changeFieldType(opticOf(at), converter, inverseConverter) +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/MigrationMacros.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/MigrationMacros.scala new file mode 100644 index 0000000000..ebbd01ac9c --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/MigrationMacros.scala @@ -0,0 +1,57 @@ +/* + * 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 + +import scala.annotation.tailrec +import scala.quoted.* + +object MigrationMacros { + + /** + * Converts a selector lambda like `_.field` or `_.a.b` into a `DynamicOptic` + * at compile time by walking the lambda body and collecting each field-select + * step. + */ + def selectorToOpticImpl[A: Type](path: Expr[A => Any])(using Quotes): Expr[DynamicOptic] = { + import quotes.reflect.* + + @tailrec + def toLambdaBody(term: Term): Term = term match { + case Inlined(_, _, inner) => toLambdaBody(inner) + case Block(List(DefDef(_, _, _, Some(body))), _) => body + case _ => + report.errorAndAbort(s"Expected a lambda expression, got '${term.show}'") + } + + def extractFields(term: Term): List[String] = term match { + case Select(parent, fieldName) => extractFields(parent) :+ fieldName + case _: Ident => Nil + case Inlined(_, _, inner) => extractFields(inner) + case _ => + report.errorAndAbort( + s"Migration selector must be a chain of field accesses (e.g. _.field or _.a.b), got '${term.show}'" + ) + } + + val body = toLambdaBody(path.asTerm) + val fields = extractFields(body) + fields.foldLeft('{ DynamicOptic.root }) { (acc, name) => + val nameExpr = Expr(name) + '{ $acc.field($nameExpr) } + } + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/DynamicMigration.scala b/schema/shared/src/main/scala/zio/blocks/schema/DynamicMigration.scala new file mode 100644 index 0000000000..3f7fdbf6f6 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/DynamicMigration.scala @@ -0,0 +1,213 @@ +/* + * 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 + +import zio.blocks.chunk.Chunk + +/** + * Untyped, serializable migration core. A list of MigrationAction values that + * operate on DynamicValue. Fully serializable; no reflection or closures. + */ +final case class DynamicMigration(actions: Chunk[MigrationAction]) { + + def ++(other: DynamicMigration): DynamicMigration = + DynamicMigration(actions ++ other.actions) + + def reverse: DynamicMigration = + DynamicMigration(actions.reverse.map(MigrationAction.invert)) + + def apply(value: DynamicValue): Either[MigrationError, DynamicValue] = + actions.foldLeft[Either[MigrationError, DynamicValue]](Right(value)) { (acc, action) => + acc.flatMap(DynamicMigration.applyAction(action, _)) + } +} + +object DynamicMigration { + + val empty: DynamicMigration = DynamicMigration(Chunk.empty) + + def applyAction(action: MigrationAction, value: DynamicValue): Either[MigrationError, DynamicValue] = { + import MigrationAction._ + import DynamicValue.Variant + + def getOne(path: DynamicOptic): Either[MigrationError, DynamicValue] = + value.get(path).one.left.map(se => MigrationError(se.message, path)) + + def set(path: DynamicOptic, v: DynamicValue): Either[MigrationError, DynamicValue] = + value.setOrFail(path, v).left.map(se => MigrationError(se.message, path)) + + def insert(path: DynamicOptic, v: DynamicValue): Either[MigrationError, DynamicValue] = + value.insertOrFail(path, v).left.map(se => MigrationError(se.message, path)) + + def delete(path: DynamicOptic): Either[MigrationError, DynamicValue] = + value.deleteOrFail(path).left.map(se => MigrationError(se.message, path)) + + action match { + case AddField(at, expr) => + expr.eval(value).flatMap(newVal => insert(at, newVal)) + + case DropField(at, _) => + delete(at) + + case RenameField(from, to) => + for { + fieldVal <- getOne(from) + withoutField <- delete(from) + result <- withoutField.insertOrFail(to, fieldVal).left.map(se => MigrationError(se.message, to)) + } yield result + + case TransformValue(at, transform, _) => + for { + current <- getOne(at) + transformed <- transform.eval(current).left.map(e => e.copy(at = at)) + result <- set(at, transformed) + } yield result + + case Optionalize(at, _) => + for { + current <- getOne(at) + result <- set(at, Variant("Some", current)) + } yield result + + case Mandate(at) => + getOne(at).flatMap { + case DynamicValue.Variant("Some", inner) => + set(at, inner) + case DynamicValue.Variant("None", _) => + Left(MigrationError("Mandate: field is None", at)) + case other => + Left(MigrationError(s"Mandate: expected Option variant (Some/None), got ${other.valueType}", at)) + } + + case Join(left, right, into, transform, _, _) => + for { + lv <- getOne(left) + rv <- getOne(right) + combined = DynamicValue.Record(("_left", lv), ("_right", rv)) + joined <- transform.eval(combined).left.map(e => e.copy(at = into)) + step1 <- delete(left) + step2 <- step1.deleteOrFail(right).left.map(se => MigrationError(se.message, right)) + result <- step2.insertOrFail(into, joined).left.map(se => MigrationError(se.message, into)) + } yield result + + case Split(from, intoLeft, intoRight, leftExpr, rightExpr, _) => + for { + src <- getOne(from) + lv <- leftExpr.eval(src).left.map(e => e.copy(at = intoLeft)) + rv <- rightExpr.eval(src).left.map(e => e.copy(at = intoRight)) + withoutSrc <- delete(from) + withLeft <- withoutSrc.insertOrFail(intoLeft, lv).left.map(se => MigrationError(se.message, intoLeft)) + result <- withLeft.insertOrFail(intoRight, rv).left.map(se => MigrationError(se.message, intoRight)) + } yield result + + case RenameCase(at, from, to) => + getOne(at).flatMap { + case Variant(caseName, payload) if caseName == from => + set(at, Variant(to, payload)) + case _: Variant => + Right(value) + case other => + Left(MigrationError(s"RenameCase: expected Variant, got ${other.valueType}", at)) + } + + case TransformElements(at, _) => + Left(MigrationError("TransformElements: not yet implemented", at)) + + case TransformKeys(at, _) => + Left(MigrationError("TransformKeys: not yet implemented", at)) + + case TransformValues(at, _) => + Left(MigrationError("TransformValues: not yet implemented", at)) + + case ChangeFieldType(at, converter, _) => + getOne(at).flatMap { current => + converter.eval(current).left.map(e => e.copy(at = at)).flatMap(set(at, _)) + } + + case TransformCase(at, _, _, _) => + Left(MigrationError("TransformCase: not yet implemented", at)) + } + } +} + +/** + * A single migration step operating at a path (DynamicOptic). All actions are + * serializable; no user functions. + */ +sealed trait MigrationAction { + + def reverse: MigrationAction = MigrationAction.invert(this) +} + +object MigrationAction { + + final case class AddField(at: DynamicOptic, value: MigrationExpr) extends MigrationAction + final case class DropField(at: DynamicOptic, defaultForReverse: MigrationExpr) extends MigrationAction + final case class RenameField(from: DynamicOptic, to: DynamicOptic) extends MigrationAction + final case class TransformValue(at: DynamicOptic, transform: MigrationExpr, inverseTransform: MigrationExpr) + extends MigrationAction + final case class Optionalize(at: DynamicOptic, defaultForReverse: MigrationExpr) extends MigrationAction + final case class Mandate(at: DynamicOptic) extends MigrationAction + + final case class Join( + left: DynamicOptic, + right: DynamicOptic, + into: DynamicOptic, + transform: MigrationExpr, + inverseLeft: MigrationExpr, + inverseRight: MigrationExpr + ) extends MigrationAction + + final case class Split( + from: DynamicOptic, + intoLeft: DynamicOptic, + intoRight: DynamicOptic, + leftExpr: MigrationExpr, + rightExpr: MigrationExpr, + inverseTransform: MigrationExpr + ) extends MigrationAction + + final case class RenameCase(at: DynamicOptic, from: String, to: String) extends MigrationAction + final case class TransformCase(at: DynamicOptic, from: String, to: String, adapt: Chunk[MigrationAction]) + extends MigrationAction + + final case class TransformElements(at: DynamicOptic, transform: MigrationExpr) extends MigrationAction + final case class TransformKeys(at: DynamicOptic, transform: MigrationExpr) extends MigrationAction + final case class TransformValues(at: DynamicOptic, transform: MigrationExpr) extends MigrationAction + final case class ChangeFieldType(at: DynamicOptic, converter: MigrationExpr, inverseConverter: MigrationExpr) + extends MigrationAction + + def invert(action: MigrationAction): MigrationAction = + action match { + case AddField(at, value) => DropField(at, value) + case DropField(at, default) => AddField(at, default) + case RenameField(from, to) => RenameField(to, from) + case TransformValue(at, t, inv) => TransformValue(at, inv, t) + case Optionalize(at, _) => Mandate(at) + case Mandate(at) => + Optionalize(at, MigrationExpr.Literal(DynamicValue.Variant("None", DynamicValue.Record(Chunk.empty)))) + case Join(left, right, into, t, invL, invR) => Split(into, left, right, invL, invR, t) + case Split(from, intoLeft, intoRight, lE, rE, inv) => Join(intoLeft, intoRight, from, inv, lE, rE) + case RenameCase(at, from, to) => RenameCase(at, to, from) + case TransformCase(at, from, to, adapt) => TransformCase(at, to, from, adapt.map(invert).reverse) + case TransformElements(at, _) => action + case TransformKeys(at, _) => action + case TransformValues(at, _) => action + case ChangeFieldType(at, converter, inverseConverter) => + ChangeFieldType(at, inverseConverter, converter) + } +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/Migration.scala b/schema/shared/src/main/scala/zio/blocks/schema/Migration.scala new file mode 100644 index 0000000000..d46cdc3bc5 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/Migration.scala @@ -0,0 +1,49 @@ +/* + * 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 + +/** + * Typed migration from A to B. Wraps source schema, target schema, and the + * untyped DynamicMigration. User-facing API; DynamicOptic is not exposed. + */ +final case class Migration[A, B]( + sourceSchema: Schema[A], + targetSchema: Schema[B], + underlying: DynamicMigration +) { + + def apply(a: A): Either[MigrationError, B] = + for { + dv <- Right(sourceSchema.toDynamicValue(a)) + dv2 <- underlying.apply(dv) + b <- targetSchema.fromDynamicValue(dv2).left.map(se => MigrationError(se.message, DynamicOptic.root)) + } yield b + + def reverse: Migration[B, A] = + Migration(targetSchema, sourceSchema, underlying.reverse) + + def andThen[C](other: Migration[B, C]): Migration[A, C] = + Migration(sourceSchema, other.targetSchema, underlying ++ other.underlying) + + def ++[C](other: Migration[B, C]): Migration[A, C] = andThen(other) +} + +object Migration { + + def identity[A](implicit schema: Schema[A]): Migration[A, A] = + Migration(schema, schema, DynamicMigration.empty) +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/MigrationBuilder.scala b/schema/shared/src/main/scala/zio/blocks/schema/MigrationBuilder.scala new file mode 100644 index 0000000000..5414501a73 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/MigrationBuilder.scala @@ -0,0 +1,116 @@ +/* + * 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 + +import zio.blocks.chunk.Chunk + +/** + * Phantom type tracking which target fields have been accounted for in this + * builder. Populated via intersection types by selector macros (pending + * implementation). + */ +sealed trait BuilderState + +/** + * Marks that a target field named `Name` has been addressed by a migration + * action. + */ +sealed trait HasField[Name <: String] extends BuilderState + +/** + * Builder for constructing a Migration[A, B] by appending MigrationActions. + * + * Two path-specification styles are available: + * - Selector-based (preferred): `_.fieldName` lambdas converted to + * DynamicOptic at compile time via MigrationBuilderVersionSpecific macros. + * - Optic-based (internal): explicit `DynamicOptic` values for testing and + * low-level use. + * + * The `State` phantom type parameter is intended to track which target fields + * have been addressed at compile time via `HasField` intersection types. + * Population of `State` currently requires macro support (pending); all methods + * preserve `State` unchanged for now. + */ +class MigrationBuilder[A, B, State <: BuilderState] private[schema] (val actions: Chunk[MigrationAction]) + extends MigrationBuilderVersionSpecific[A, B, State] { + + def addField(at: DynamicOptic, value: MigrationExpr): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.AddField(at, value)) + + def dropField(at: DynamicOptic, defaultForReverse: MigrationExpr): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.DropField(at, defaultForReverse)) + + def renameField(from: DynamicOptic, to: DynamicOptic): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.RenameField(from, to)) + + def transformValue( + at: DynamicOptic, + transform: MigrationExpr, + inverseTransform: MigrationExpr + ): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.TransformValue(at, transform, inverseTransform)) + + def optionalize(at: DynamicOptic, defaultForReverse: MigrationExpr): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.Optionalize(at, defaultForReverse)) + + def mandate(at: DynamicOptic): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.Mandate(at)) + + def join( + left: DynamicOptic, + right: DynamicOptic, + into: DynamicOptic, + transform: MigrationExpr, + inverseLeft: MigrationExpr, + inverseRight: MigrationExpr + ): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.Join(left, right, into, transform, inverseLeft, inverseRight)) + + def split( + from: DynamicOptic, + intoLeft: DynamicOptic, + intoRight: DynamicOptic, + leftExpr: MigrationExpr, + rightExpr: MigrationExpr, + inverseTransform: MigrationExpr + ): MigrationBuilder[A, B, State] = + new MigrationBuilder( + actions :+ MigrationAction.Split(from, intoLeft, intoRight, leftExpr, rightExpr, inverseTransform) + ) + + def renameCase(at: DynamicOptic, from: String, to: String): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.RenameCase(at, from, to)) + + def changeFieldType( + at: DynamicOptic, + converter: MigrationExpr, + inverseConverter: MigrationExpr + ): MigrationBuilder[A, B, State] = + new MigrationBuilder(actions :+ MigrationAction.ChangeFieldType(at, converter, inverseConverter)) + + def build(implicit sourceSchema: Schema[A], targetSchema: Schema[B]): Migration[A, B] = + Migration(sourceSchema, targetSchema, DynamicMigration(actions)) + + /** Same as build until macro-based structural validation is implemented. */ + def buildPartial(implicit sourceSchema: Schema[A], targetSchema: Schema[B]): Migration[A, B] = + build +} + +object MigrationBuilder { + + def apply[A, B]: MigrationBuilder[A, B, BuilderState] = new MigrationBuilder(Chunk.empty) +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/MigrationError.scala b/schema/shared/src/main/scala/zio/blocks/schema/MigrationError.scala new file mode 100644 index 0000000000..50ed28d1a7 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/MigrationError.scala @@ -0,0 +1,30 @@ +/* + * 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 + +import scala.util.control.NoStackTrace + +/** + * Represents a failure during migration application. Captures the path + * (DynamicOptic) where the failure occurred for diagnostics. + */ +final case class MigrationError(message: String, at: DynamicOptic) extends Exception with NoStackTrace { + + override def getMessage: String = + if (at.nodes.isEmpty) message + else s"$message at: ${at.toScalaString}" +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/MigrationExpr.scala b/schema/shared/src/main/scala/zio/blocks/schema/MigrationExpr.scala new file mode 100644 index 0000000000..9f16f38489 --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/MigrationExpr.scala @@ -0,0 +1,110 @@ +/* + * 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 + +import java.util.regex.Pattern + +/** + * Serializable AST for primitive-to-primitive value transformations used by + * migrations. No user functions or closures; fully serializable for use in + * registries and offline replay. + */ +sealed trait MigrationExpr { + + /** + * Evaluates this expression with the given input (e.g. the value at a path or + * the root context). Returns the resulting DynamicValue or a MigrationError. + */ + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] +} + +object MigrationExpr { + + final case class Literal(value: DynamicValue) extends MigrationExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = Right(value) + } + + case object IntToString extends MigrationExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + input match { + case DynamicValue.Primitive(PrimitiveValue.Int(n)) => + Right(DynamicValue.Primitive(PrimitiveValue.String(n.toString))) + case other => + Left(MigrationError(s"IntToString: expected Int, got ${other.valueType}", DynamicOptic.root)) + } + } + + case object StringToInt extends MigrationExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + input match { + case DynamicValue.Primitive(PrimitiveValue.String(s)) => + scala.util.Try(s.toInt).toOption match { + case Some(n) => Right(DynamicValue.Primitive(PrimitiveValue.Int(n))) + case None => Left(MigrationError(s"StringToInt: '$s' is not a valid integer", DynamicOptic.root)) + } + case other => + Left(MigrationError(s"StringToInt: expected String, got ${other.valueType}", DynamicOptic.root)) + } + } + + final case class StringConcat(separator: String) extends MigrationExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + input match { + case DynamicValue.Record(fields) => + val fieldMap = fields.iterator.toMap + (fieldMap.get("_left"), fieldMap.get("_right")) match { + case (Some(l), Some(r)) => + (l, r) match { + case ( + DynamicValue.Primitive(PrimitiveValue.String(a)), + DynamicValue.Primitive(PrimitiveValue.String(b)) + ) => + Right(DynamicValue.Primitive(PrimitiveValue.String(a + separator + b))) + case _ => + Left(MigrationError("StringConcat: expected (String, String)", DynamicOptic.root)) + } + case _ => + Left(MigrationError("StringConcat: expected Record(_left, _right)", DynamicOptic.root)) + } + case other => + Left(MigrationError(s"StringConcat: expected Record, got ${other.valueType}", DynamicOptic.root)) + } + } + + final case class StringSplitLeft(separator: String) extends MigrationExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + input match { + case DynamicValue.Primitive(PrimitiveValue.String(s)) => + val part = s.split(Pattern.quote(separator), 2).head + Right(DynamicValue.Primitive(PrimitiveValue.String(part))) + case other => + Left(MigrationError(s"StringSplitLeft: expected String, got ${other.valueType}", DynamicOptic.root)) + } + } + + final case class StringSplitRight(separator: String) extends MigrationExpr { + def eval(input: DynamicValue): Either[MigrationError, DynamicValue] = + input match { + case DynamicValue.Primitive(PrimitiveValue.String(s)) => + val parts = s.split(Pattern.quote(separator), 2) + val part = if (parts.length > 1) parts(1) else "" + Right(DynamicValue.Primitive(PrimitiveValue.String(part))) + case other => + Left(MigrationError(s"StringSplitRight: expected String, got ${other.valueType}", DynamicOptic.root)) + } + } +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/MigrationLawsSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/MigrationLawsSpec.scala new file mode 100644 index 0000000000..efbd5bb6a9 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/MigrationLawsSpec.scala @@ -0,0 +1,449 @@ +/* + * 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 + +import zio.blocks.chunk.Chunk +import zio.test._ + +object MigrationLawsSpec extends SchemaBaseSpec { + + private val root = DynamicOptic.root + private def field(name: String): DynamicOptic = root.field(name) + + private val noneValue: DynamicValue = + DynamicValue.Variant("None", DynamicValue.Record(Chunk.empty)) + + private val defaultForOptionalizeReverse: MigrationExpr = + MigrationExpr.Literal(noneValue) + + // ── Generators ──────────────────────────────────────────────────────────── + + /** Alphanumeric field name, 1–8 chars. */ + private val genName: Gen[Any, String] = + Gen.alphaNumericStringBounded(1, 8) + + /** A primitive DynamicValue. */ + private val genPrimitive: Gen[Any, DynamicValue] = + Gen.oneOf( + Gen.int(-999, 999).map(DynamicValue.int), + Gen.alphaNumericStringBounded(1, 8).map(DynamicValue.string), + Gen.boolean.map(DynamicValue.boolean) + ) + + /** + * A flat record with 1–5 fields, all keys distinct. Fields are always + * primitive so TransformValue / StringConcat tests work cleanly. + */ + private val genFlatRecord: Gen[Any, DynamicValue.Record] = + for { + n <- Gen.int(1, 5) + pairs <- Gen.listOfBounded(n, n)(genName.zip(genPrimitive)) + unique = pairs.distinctBy(_._1) + chunk = Chunk.from(unique) + } yield DynamicValue.Record(chunk) + + /** Picks an existing key from the record. */ + private def genRecordAndKey(record: DynamicValue.Record): Gen[Any, String] = + Gen.int(0, record.fields.length - 1).map(i => record.fields(i)._1) + + /** + * A flat record where at least two fields hold string values with + * alphanumeric-only content and distinct keys. Used for Join/Split tests. + */ + private val genRecordWithTwoStrings: Gen[Any, (DynamicValue.Record, String, String)] = + for { + k1 <- genName + k2 <- genName.filter(_ != k1) + v1 <- Gen.alphaNumericStringBounded(1, 8).map(DynamicValue.string) + v2 <- Gen.alphaNumericStringBounded(1, 8).map(DynamicValue.string) + extras <- Gen.listOfBounded(0, 3)(genName.filter(k => k != k1 && k != k2).zip(genPrimitive)) + unique = ((k1, v1) :: (k2, v2) :: extras).distinctBy(_._1) + record = DynamicValue.Record(Chunk.from(unique)) + } yield (record, k1, k2) + + // ── Suite 1: Identity ───────────────────────────────────────────────────── + + private val identitySuite = suite("Law 1: Identity")( + test("empty.apply(v) == Right(v) for any Record") { + check(genFlatRecord) { record => + assertTrue(DynamicMigration.empty(record) == Right(record)) + } + }, + test("empty.apply(v) == Right(v) for Variant") { + check(genName, genFlatRecord) { (caseName, payload) => + val v = DynamicValue.Variant(caseName, payload) + assertTrue(DynamicMigration.empty(v) == Right(v)) + } + }, + test("empty ++ m has same actions as m") { + check(genName, genName.filter(_ != "x")) { (a, b) => + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(a), field(b)))) + assertTrue((DynamicMigration.empty ++ m).actions == m.actions) + } + }, + test("m ++ empty has same actions as m") { + check(genName, genName.filter(_ != "x")) { (a, b) => + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(a), field(b)))) + assertTrue((m ++ DynamicMigration.empty).actions == m.actions) + } + } + ) + + // ── Suite 2: Monoid Associativity ───────────────────────────────────────── + + private val associativitySuite = suite("Law 2: Monoid Associativity")( + test("(m1 ++ m2) ++ m3 actions equal m1 ++ (m2 ++ m3) actions") { + check(genName, genName.filter(_ != "x"), genName.filter(k => k != "x" && k != "y")) { (a, b, c) => + val m1 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(a), field(b)))) + val m2 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(b), field(c)))) + val m3 = DynamicMigration( + Chunk.single(MigrationAction.AddField(field("z"), MigrationExpr.Literal(DynamicValue.int(0)))) + ) + assertTrue(((m1 ++ m2) ++ m3).actions == (m1 ++ (m2 ++ m3)).actions) + } + }, + test("independent AddField actions produce same result regardless of grouping") { + check(genFlatRecord.filter(_.fields.length >= 1)) { record => + val m1 = DynamicMigration( + Chunk.single(MigrationAction.AddField(field("_extra1"), MigrationExpr.Literal(DynamicValue.int(1)))) + ) + val m2 = DynamicMigration( + Chunk.single(MigrationAction.AddField(field("_extra2"), MigrationExpr.Literal(DynamicValue.int(2)))) + ) + val m3 = DynamicMigration( + Chunk.single(MigrationAction.AddField(field("_extra3"), MigrationExpr.Literal(DynamicValue.int(3)))) + ) + val r1 = ((m1 ++ m2) ++ m3)(record) + val r2 = (m1 ++ (m2 ++ m3))(record) + assertTrue(r1 == r2) + } + }, + test("sequential == composed: two renames applied sequentially equal one composed migration") { + check(genFlatRecord.filter(_.fields.length >= 1)) { record => + val k = record.fields(0)._1 + val m1 = DynamicMigration( + Chunk.single( + MigrationAction.TransformValue(field(k), MigrationExpr.IntToString, MigrationExpr.StringToInt) + ) + ) + val m2 = DynamicMigration( + Chunk.single( + MigrationAction.TransformValue(field(k), MigrationExpr.StringToInt, MigrationExpr.IntToString) + ) + ) + // Filter to records where the first field is an Int + val intRecord = DynamicValue.Record( + (k, DynamicValue.int(42)) +: record.fields.filter(_._1 != k) + ) + val r1 = m1(intRecord).flatMap(m2(_)) + val r2 = (m1 ++ m2)(intRecord) + assertTrue(r1 == r2) + } + } + ) + + // ── Suite 3: Structural Reverse ──────────────────────────────────────────── + + private val structuralReverseSuite = suite("Law 3: Structural Reverse")( + test("m.reverse.reverse.actions.length == m.actions.length for rename") { + check(genName, genName) { (a, b) => + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(a), field(b)))) + assertTrue(m.reverse.reverse.actions.length == m.actions.length) + } + }, + test("m.reverse.reverse.actions.length == m.actions.length for compound") { + check(genFlatRecord.filter(_.fields.length >= 1)) { record => + val k = record.fields(0)._1 + val m = DynamicMigration( + Chunk( + MigrationAction.RenameField(field(k), field(k + "2")), + MigrationAction.AddField(field("_new"), MigrationExpr.Literal(DynamicValue.int(0))) + ) + ) + assertTrue(m.reverse.reverse.actions.length == m.actions.length) + } + }, + test("invert is an involution for RenameField") { + check(genName, genName) { (a, b) => + val action = MigrationAction.RenameField(field(a), field(b)) + assertTrue(MigrationAction.invert(MigrationAction.invert(action)) == action) + } + }, + test("invert is an involution for AddField/DropField pair") { + check(genName, genPrimitive) { (k, v) => + val add = MigrationAction.AddField(field(k), MigrationExpr.Literal(v)) + val drop = MigrationAction.DropField(field(k), MigrationExpr.Literal(v)) + assertTrue( + MigrationAction.invert(MigrationAction.invert(add)) == add && + MigrationAction.invert(MigrationAction.invert(drop)) == drop + ) + } + }, + test("invert is an involution for TransformValue") { + check(genName) { k => + val action = MigrationAction.TransformValue(field(k), MigrationExpr.IntToString, MigrationExpr.StringToInt) + assertTrue(MigrationAction.invert(MigrationAction.invert(action)) == action) + } + }, + test("invert is an involution for RenameCase") { + check(genName, genName, genName) { (path, from, to) => + val action = MigrationAction.RenameCase(field(path), from, to) + assertTrue(MigrationAction.invert(MigrationAction.invert(action)) == action) + } + }, + test("reverse reverses action order") { + check(genName, genName, genName) { (a, b, c) => + val action1 = MigrationAction.RenameField(field(a), field(b)) + val action2 = MigrationAction.AddField(field(c), MigrationExpr.Literal(DynamicValue.int(0))) + val m = DynamicMigration(Chunk(action1, action2)) + val reversed = m.reverse + assertTrue(reversed.actions.length == 2) + } + } + ) + + // ── Suite 4: Semantic Invertibility ─────────────────────────────────────── + + private val semanticInvertibilitySuite = suite("Law 4: Semantic Invertibility")( + test("RenameField round-trip recovers original (sortFields)") { + check(genFlatRecord.filter(_.fields.length >= 1)) { record => + val k = record.fields(0)._1 + val k2 = k + "_renamed" + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(k), field(k2)))) + val result = m(record).flatMap(m.reverse(_)) + assertTrue(result.map(_.sortFields) == Right(record.sortFields)) + } + }, + test("AddField then reverse (DropField) recovers original") { + check(genFlatRecord, genPrimitive) { (record, v) => + val newKey = "_added" + val m = DynamicMigration( + Chunk.single( + MigrationAction.AddField(field(newKey), MigrationExpr.Literal(v)) + ) + ) + val result = m(record).flatMap(m.reverse(_)) + assertTrue(result == Right(record)) + } + }, + test("TransformValue IntToString/StringToInt round-trip recovers original") { + check(genName, Gen.int(-999, 999)) { (k, n) => + val record = DynamicValue.Record(Chunk.single((k, DynamicValue.int(n)))) + val m = DynamicMigration( + Chunk.single( + MigrationAction.TransformValue(field(k), MigrationExpr.IntToString, MigrationExpr.StringToInt) + ) + ) + val result = m(record).flatMap(m.reverse(_)) + assertTrue(result == Right(record)) + } + }, + test("Optionalize then Mandate round-trip recovers original") { + check(genName, genPrimitive) { (k, v) => + val record = DynamicValue.Record(Chunk.single((k, v))) + val m = DynamicMigration( + Chunk.single( + MigrationAction.Optionalize(field(k), defaultForOptionalizeReverse) + ) + ) + val result = m(record).flatMap(m.reverse(_)) + assertTrue(result == Right(record)) + } + }, + test("Join then Split (via reverse) round-trip recovers original") { + check(genRecordWithTwoStrings) { case (record, k1, k2) => + val sep = "|" + val joinKey = "_joined" + val m = DynamicMigration( + Chunk.single( + MigrationAction.Join( + field(k1), + field(k2), + field(joinKey), + MigrationExpr.StringConcat(sep), + MigrationExpr.StringSplitLeft(sep), + MigrationExpr.StringSplitRight(sep) + ) + ) + ) + val result = m(record).flatMap(m.reverse(_)) + assertTrue(result.map(_.sortFields) == Right(record.sortFields)) + } + }, + test("RenameCase round-trip recovers original") { + check(genName, genName, genFlatRecord) { (fromCase, toCase, payload) => + val v = DynamicValue.Variant(fromCase, payload) + val m = DynamicMigration(Chunk.single(MigrationAction.RenameCase(root, fromCase, toCase))) + val result = m(v).flatMap(m.reverse(_)) + assertTrue(result == Right(v)) + } + }, + test("RenameCase pass-through: non-matching case is unchanged") { + check(genName, genName, genFlatRecord) { (fromCase, toCase, payload) => + val otherCase = fromCase + "_other" + val v = DynamicValue.Variant(otherCase, payload) + val m = DynamicMigration(Chunk.single(MigrationAction.RenameCase(root, fromCase, toCase))) + val result = m(v) + assertTrue(result == Right(v)) + } + }, + test("compound lossless: rename + addField round-trip") { + check(genFlatRecord.filter(_.fields.length >= 1)) { record => + val k = record.fields(0)._1 + val k2 = k + "_v2" + val newKey = "_ts" + val m = DynamicMigration( + Chunk( + MigrationAction.RenameField(field(k), field(k2)), + MigrationAction.AddField(field(newKey), MigrationExpr.Literal(DynamicValue.int(0))) + ) + ) + val result = m(record).flatMap(m.reverse(_)) + assertTrue(result.map(_.sortFields) == Right(record.sortFields)) + } + } + ) + + // ── Suite 5: Compositionality ───────────────────────────────────────────── + + private val compositionalitySuite = suite("Law 5: Compositionality")( + test("(m1 ++ m2).reverse.actions.length == (m2.reverse ++ m1.reverse).actions.length") { + check(genName, genName, genName) { (a, b, c) => + val m1 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(a), field(b)))) + val m2 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(b), field(c)))) + val lhs = (m1 ++ m2).reverse + val rhs = m2.reverse ++ m1.reverse + assertTrue(lhs.actions.length == rhs.actions.length) + } + }, + test("m ++ m.reverse applied to record is idempotent for RenameField") { + check(genFlatRecord.filter(_.fields.length >= 1)) { record => + val k = record.fields(0)._1 + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(k), field(k + "_tmp")))) + val result = (m ++ m.reverse)(record) + assertTrue(result.map(_.sortFields) == Right(record.sortFields)) + } + }, + test("three-way composition: (m1 ++ m2 ++ m3).reverse == m3.r ++ m2.r ++ m1.r (action count)") { + check(genName, genName, genName, genName) { (a, b, c, d) => + val m1 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(a), field(b)))) + val m2 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(b), field(c)))) + val m3 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(c), field(d)))) + val lhs = (m1 ++ m2 ++ m3).reverse + val rhs = m3.reverse ++ m2.reverse ++ m1.reverse + assertTrue(lhs.actions.length == rhs.actions.length) + } + }, + test("identity is its own reverse") { + assertTrue(DynamicMigration.empty.reverse.actions == DynamicMigration.empty.actions) + } + ) + + // ── Suite 6: Error path ──────────────────────────────────────────────────── + + private val errorPathSuite = suite("Error path")( + test("RenameField on missing field returns Left") { + check(genFlatRecord) { record => + val missing = "_no_such_field_xyz" + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field(missing), field("_out")))) + val result = m(record) + assertTrue(result.isLeft) + } + }, + test("Mandate on None field returns Left") { + check(genName) { k => + val record = DynamicValue.Record(Chunk.single((k, noneValue))) + val m = DynamicMigration(Chunk.single(MigrationAction.Mandate(field(k)))) + val result = m(record) + assertTrue(result.isLeft) + } + }, + test("StringToInt on non-numeric string returns Left") { + check(genName, Gen.alphaNumericStringBounded(1, 4).filter(s => scala.util.Try(s.toInt).isFailure)) { (k, s) => + val record = DynamicValue.Record(Chunk.single((k, DynamicValue.string(s)))) + val m = DynamicMigration( + Chunk.single( + MigrationAction.TransformValue(field(k), MigrationExpr.StringToInt, MigrationExpr.IntToString) + ) + ) + val result = m(record) + assertTrue(result.isLeft) + } + }, + test("error short-circuits: second action not applied after first fails") { + check(genFlatRecord) { record => + val missing = "_no_such_field_xyz" + val m = DynamicMigration( + Chunk( + MigrationAction.RenameField(field(missing), field("_out")), + MigrationAction.AddField(field("_extra"), MigrationExpr.Literal(DynamicValue.int(0))) + ) + ) + val result = m(record) + assertTrue(result.isLeft) + } + } + ) + + // ── Suite 7: Nested path ─────────────────────────────────────────────────── + + private val nestedPathSuite = suite("Nested path")( + test("RenameField on nested addr.street -> addr.streetName round-trips") { + val addr = DynamicValue.Record( + Chunk.from( + List( + "street" -> DynamicValue.string("123 Main St"), + "city" -> DynamicValue.string("Springfield") + ) + ) + ) + val record = DynamicValue.Record( + Chunk.from( + List( + "name" -> DynamicValue.string("Alice"), + "addr" -> addr + ) + ) + ) + + val streetPath = root.field("addr").field("street") + val streetNamePath = root.field("addr").field("streetName") + + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(streetPath, streetNamePath))) + val forward = m(record) + val round = forward.flatMap(m.reverse(_)) + + assertTrue( + forward.isRight && + forward.toOption.get.sortFields != record.sortFields && + round.map(_.sortFields) == Right(record.sortFields) + ) + } + ) + + // ── Spec ────────────────────────────────────────────────────────────────── + + def spec: Spec[TestEnvironment, Any] = suite("MigrationLawsSpec")( + identitySuite, + associativitySuite, + structuralReverseSuite, + semanticInvertibilitySuite, + compositionalitySuite, + errorPathSuite, + nestedPathSuite + ) +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/MigrationSpec.scala b/schema/shared/src/test/scala/zio/blocks/schema/MigrationSpec.scala new file mode 100644 index 0000000000..05ba296551 --- /dev/null +++ b/schema/shared/src/test/scala/zio/blocks/schema/MigrationSpec.scala @@ -0,0 +1,146 @@ +/* + * 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 + +import zio.blocks.chunk.Chunk +import zio.test._ + +object MigrationSpec extends SchemaBaseSpec { + + private val root = DynamicOptic.root + private def field(name: String): DynamicOptic = root.field(name) + + private case class PersonV1(name: String, age: Int) + private case class PersonV2(fullName: String, age: Int) + + private implicit val schemaPersonV1: Schema[PersonV1] = Schema.derived[PersonV1] + private implicit val schemaPersonV2: Schema[PersonV2] = Schema.derived[PersonV2] + + def spec: Spec[TestEnvironment, Any] = suite("MigrationSpec")( + suite("DynamicMigration")( + suite("Identity")( + test("empty.apply(v) == Right(v) for Record") { + val v = DynamicValue.Record(("a", DynamicValue.int(1)), ("b", DynamicValue.string("x"))) + assertTrue(DynamicMigration.empty(v) == Right(v)) + }, + test("empty ++ m has same actions as m") { + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field("x"), field("y")))) + assertTrue((DynamicMigration.empty ++ m).actions == m.actions) + }, + test("m ++ empty has same actions as m") { + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field("x"), field("y")))) + assertTrue((m ++ DynamicMigration.empty).actions == m.actions) + } + ), + suite("Associativity")( + test("(m1 ++ m2) ++ m3 and m1 ++ (m2 ++ m3) give same result") { + val v = DynamicValue.Record(("f", DynamicValue.int(42))) + val m1 = DynamicMigration(Chunk.single(MigrationAction.RenameField(field("f"), field("g")))) + val m2 = DynamicMigration( + Chunk.single( + MigrationAction.TransformValue(field("g"), MigrationExpr.IntToString, MigrationExpr.StringToInt) + ) + ) + val m3 = DynamicMigration( + Chunk.single(MigrationAction.AddField(field("h"), MigrationExpr.Literal(DynamicValue.boolean(true)))) + ) + val r1 = ((m1 ++ m2) ++ m3)(v) + val r2 = (m1 ++ (m2 ++ m3))(v) + assertTrue(r1 == r2) + } + ), + suite("Structural reverse")( + test("m.reverse.reverse.actions length equals m.actions length") { + val m = DynamicMigration( + Chunk( + MigrationAction.RenameField(field("a"), field("b")), + MigrationAction.AddField(field("c"), MigrationExpr.Literal(DynamicValue.int(0))) + ) + ) + assertTrue(m.reverse.reverse.actions.length == m.actions.length) + } + ), + suite("RenameField round-trip")( + test("apply then reverse recovers original") { + val v = DynamicValue.Record( + ("firstName", DynamicValue.string("Ada")), + ("lastName", DynamicValue.string("Lovelace")) + ) + val m = DynamicMigration(Chunk.single(MigrationAction.RenameField(field("firstName"), field("first")))) + val applied = m(v) + val round = applied.flatMap(m.reverse(_)) + assertTrue(round.map(_.sortFields) == Right(v.sortFields)) + } + ), + suite("AddField / DropField round-trip")( + test("AddField then reverse (DropField) recovers original") { + val v = DynamicValue.Record(("x", DynamicValue.int(1))) + val m = DynamicMigration( + Chunk.single(MigrationAction.AddField(field("y"), MigrationExpr.Literal(DynamicValue.int(2)))) + ) + val applied = m(v) + val round = applied.flatMap(m.reverse(_)) + assertTrue(round == Right(v)) + } + ), + suite("TransformValue round-trip")( + test("IntToString then StringToInt recovers original") { + val v = DynamicValue.Record(("age", DynamicValue.int(36))) + val m = DynamicMigration( + Chunk.single( + MigrationAction.TransformValue(field("age"), MigrationExpr.IntToString, MigrationExpr.StringToInt) + ) + ) + val applied = m(v) + val round = applied.flatMap(m.reverse(_)) + assertTrue(round == Right(v)) + } + ), + suite("RenameCase round-trip")( + test("RenameCase then reverse recovers original") { + val v = DynamicValue.Variant("Active", DynamicValue.Record(("since", DynamicValue.string("2020-01-01")))) + val m = DynamicMigration(Chunk.single(MigrationAction.RenameCase(root, "Active", "Enabled"))) + val applied = m(v) + val round = applied.flatMap(m.reverse(_)) + assertTrue(round == Right(v)) + } + ) + ), + suite("Migration typed API")( + test("identity.apply(a) == Right(a) for Int") { + assertTrue(Migration.identity[Int].apply(42) == Right(42)) + }, + test("identity.apply(a) == Right(a) for String") { + assertTrue(Migration.identity[String].apply("hello") == Right("hello")) + }, + test("typed round-trip: RenameField via Migration[PersonV1, PersonV2]") { + val migration = MigrationBuilder[PersonV1, PersonV2] + .renameField(field("name"), field("fullName")) + .build + val result = migration.apply(PersonV1("Alice", 30)) + assertTrue(result == Right(PersonV2("Alice", 30))) + }, + test("selector-based renameField: PersonV1 -> PersonV2") { + val migration = MigrationBuilder[PersonV1, PersonV2] + .renameField(_.name, _.fullName) + .build + val result = migration.apply(PersonV1("Bob", 25)) + assertTrue(result == Right(PersonV2("Bob", 25))) + } + ) + ) +}