diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/fallible/FallibleTransformer.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/fallible/FallibleTransformer.scala index aa01f097..1b85b7b5 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/fallible/FallibleTransformer.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/fallible/FallibleTransformer.scala @@ -24,6 +24,18 @@ object FallibleTransformer extends LowPriorityAccumulatingInstances { F: Mode.FailFast[F] ): FallibleTransformer[F, Source, Dest] = DerivedTransformers.failFastProduct[F, Source, Dest] + inline given betweenCoproductsAccumulating[F[+x], Source, Dest](using + Source: Mirror.SumOf[Source], + Dest: Mirror.SumOf[Dest], + F: Mode.Accumulating[F] + ): FallibleTransformer[F, Source, Dest] = DerivedTransformers.fallibleCoproduct[F, Source, Dest] + + inline given betweenCoproductsFailFast[F[+x], Source, Dest](using + Source: Mirror.SumOf[Source], + Dest: Mirror.SumOf[Dest], + F: Mode.FailFast[F] + ): FallibleTransformer[F, Source, Dest] = DerivedTransformers.fallibleCoproduct[F, Source, Dest] + given betweenOptions[F[+x], Source, Dest](using transformer: FallibleTransformer[F, Source, Dest], F: Mode[F] diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformations.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformations.scala index 437e1f90..e951a364 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformations.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/CoproductTransformations.scala @@ -19,8 +19,15 @@ private[ducktape] object CoproductTransformations { given Cases.Source = Cases.Source.fromMirror(Source) given Cases.Dest = Cases.Dest.fromMirror(Dest) - val ifBranches = coproductBranches[Source, Dest](sourceValue, Cases.source.value) - ifStatement(ifBranches).asExprOf[Dest] + val ifNoMatch = '{ throw RuntimeException("Unhandled condition encountered during Coproduct Transformer derivation") } + + ExhaustiveCoproductMatching( + Cases.source, + sourceValue, + ifNoMatch + ) { c => + coproductBranch(c, sourceValue) + } } def transformConfigured[Source: Type, Dest: Type]( @@ -39,85 +46,58 @@ private[ducktape] object CoproductTransformations { .map(c => c.tpe.fullName -> c) .toMap - val (nonConfiguredCases, configuredCases) = - Cases.source.value.partition(c => !materializedConfig.contains(c.tpe.fullName)) - - val nonConfiguredIfBranches = coproductBranches[Source, Dest](sourceValue, nonConfiguredCases) - - val configuredIfBranches = - configuredCases - .map(c => c.tpe.fullName -> c) - .toMap - .map { - case (fullName, source) => - materializedConfig(fullName) match { - case Coproduct.Computed(tpe, function) => - val value = tpe match { - case '[tpe] => - '{ - val casted = $sourceValue.asInstanceOf[tpe] - $function(casted) - } - } - IfBranch(IsInstanceOf(sourceValue, source.tpe), value) - - case Coproduct.Const(tpe, value) => - IfBranch(IsInstanceOf(sourceValue, source.tpe), value) - } - } - - ifStatement(nonConfiguredIfBranches ++ configuredIfBranches).asExprOf[Dest] + val ifNoMatch = '{ throw RuntimeException("Unhandled condition encountered during Coproduct Transformer derivation") } + + ExhaustiveCoproductMatching( + Cases.source, + sourceValue, + ifNoMatch + ) { c => + materializedConfig.get(c.tpe.fullName) match { + case Some(Coproduct.Computed(tpe, function)) => + tpe match { + case '[tpe] => + '{ + val casted = $sourceValue.asInstanceOf[tpe] + $function(casted) + }.asExprOf[Dest] + } + case Some(Coproduct.Const(tpe, value)) => value.asExprOf[Dest] + case None => coproductBranch[Source, Dest](c, sourceValue) + } + } } - private def coproductBranches[Source: Type, Dest: Type]( - sourceValue: Expr[Source], - sourceCases: List[Case] - )(using Quotes, Cases.Dest) = { + private def coproductBranch[Source: Type, Dest: Type]( + source: Case, + sourceValue: Expr[Source] + )(using Quotes, Cases.Dest): Expr[Dest] = { import quotes.reflect.* - sourceCases.map { source => - source -> Cases.dest - .get(source.name) - .getOrElse(Failure.emit(Failure.NoChildMapping(source.name, summon[Type[Dest]]))) - }.map { (source, dest) => - val cond = IsInstanceOf(sourceValue, source.tpe) - - (source.tpe -> dest.tpe) match { - case '[src] -> '[dest] => - val value = - source.transformerTo(dest).map { - case '{ $t: Transformer[src, dest] } => - '{ - val castedSource = $sourceValue.asInstanceOf[src] - ${ LiftTransformation.liftTransformation(t, 'castedSource) } - } - } match { - case Right(value) => value - case Left(explanation) => - dest.materializeSingleton - .getOrElse(Failure.emit(Failure.CannotTransformCoproductCase(source.tpe, dest.tpe, explanation))) - } + val dest = Cases.dest.getOrElse(source.name, Failure.emit(Failure.NoChildMapping(source.name, summon[Type[Dest]]))) - IfBranch(cond, value) + def tryTransformation: Either[String, Expr[Dest]] = + source.transformerTo(dest).map { + case '{ $t: Transformer[src, dest] } => + '{ + val castedSource = $sourceValue.asInstanceOf[src] + ${ LiftTransformation.liftTransformation(t, 'castedSource) } + }.asExprOf[Dest] } - } - } - private def ifStatement(using Quotes)(branches: List[IfBranch]): quotes.reflect.Term = { - import quotes.reflect.* + def trySingletonTransformation: Option[Expr[Dest]] = + dest.materializeSingleton.map { singleton => + '{ $singleton.asInstanceOf[Dest] } + } - branches match { - case IfBranch(cond, value) :: xs => - If(cond.asTerm, value.asTerm, ifStatement(xs)) - case Nil => - '{ throw RuntimeException("Unhandled condition encountered during Coproduct Transformer derivation") }.asTerm + tryTransformation match { + case Right(expr) => expr + case Left(explanation) => + // note: explanation is information from the Transformation implicit search. + trySingletonTransformation + .getOrElse( + Failure.emit(Failure.CannotTransformCoproductCase(source.tpe, dest.tpe, explanation)) + ) } } - - private def IsInstanceOf(value: Expr[Any], tpe: Type[?])(using Quotes) = - tpe match { - case '[tpe] => '{ $value.isInstanceOf[tpe] } - } - - private case class IfBranch(cond: Expr[Boolean], value: Expr[Any]) } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivedTransformers.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivedTransformers.scala index 8215fa3b..02b5fafe 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivedTransformers.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/DerivedTransformers.scala @@ -54,6 +54,20 @@ private[ducktape] object DerivedTransformers { )(using Quotes): Expr[FallibleTransformer[F, Source, Dest]] = '{ source => ${ FailFastProductTransformations.transform[F, Source, Dest](Source, Dest, F, 'source) } } + inline def fallibleCoproduct[F[+x], Source, Dest](using + F: Mode[F], + Source: Mirror.SumOf[Source], + Dest: Mirror.SumOf[Dest] + ): FallibleTransformer[F, Source, Dest] = + ${ deriveFallibleCoproductTransformerMacro[F, Source, Dest]('F, 'Source, 'Dest) } + + def deriveFallibleCoproductTransformerMacro[F[+x]: Type, Source: Type, Dest: Type]( + F: Expr[Mode[F]], + Source: Expr[Mirror.SumOf[Source]], + Dest: Expr[Mirror.SumOf[Dest]] + )(using Quotes): Expr[FallibleTransformer[F, Source, Dest]] = + '{ source => ${ FallibleCoproductTransformations.transform[F, Source, Dest](Source, Dest, F, 'source) } } + inline def accumulatingProduct[F[+x], Source, Dest](using F: Mode.Accumulating[F], Source: Mirror.ProductOf[Source], diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ExhaustiveCoproductMatching.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ExhaustiveCoproductMatching.scala new file mode 100644 index 00000000..206cca59 --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/ExhaustiveCoproductMatching.scala @@ -0,0 +1,54 @@ +package io.github.arainko.ducktape.internal.macros + +import io.github.arainko.ducktape.internal.modules.{ Case, Cases } + +import scala.quoted.* + +private[ducktape] object ExhaustiveCoproductMatching { + + /** + * This utility constructs an if-then-else for the given coproduct. + * + * For a given example coproduct + * + * enum Example: + * case One, Two + * + * it will generate code like this: + * + * if(sourceValue.isInstanceOf[One]) + * f(One) + * else if(sourceValue.isInstanceOf[Two]) + * f(Two) + * else + * ifNoMatch + */ + + def apply[Src: Type, Dest: Type](cases: Cases, sourceValue: Expr[Src], ifNoMatch: Expr[Dest])(f: Case => Expr[Dest])(using + Quotes + ): Expr[Dest] = + ifStatement( + cases.value.map { c => + val cond = IsInstanceOf(sourceValue, c.tpe) + val branchCode = f(c) + IfBranch(cond, branchCode) + }, + ifNoMatch + ).asExprOf[Dest] + + private def ifStatement(using Quotes)(branches: List[IfBranch], ifNoMatch: Expr[_]): quotes.reflect.Term = { + import quotes.reflect.* + + branches + .foldRight(ifNoMatch.asTerm) { (nextBranch, soFar) => + If(nextBranch.cond.asTerm, nextBranch.value.asTerm, soFar) + } + } + + private def IsInstanceOf(value: Expr[Any], tpe: Type[?])(using Quotes): Expr[Boolean] = + tpe match { + case '[tpe] => '{ $value.isInstanceOf[tpe] } + } + + private case class IfBranch(cond: Expr[Boolean], value: Expr[Any]) +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/FallibleCoproductTransformations.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/FallibleCoproductTransformations.scala new file mode 100644 index 00000000..76b46e2c --- /dev/null +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/macros/FallibleCoproductTransformations.scala @@ -0,0 +1,68 @@ +package io.github.arainko.ducktape.internal.macros + +import io.github.arainko.ducktape.Transformer +import io.github.arainko.ducktape.fallible.{ FallibleTransformer, Mode } +import io.github.arainko.ducktape.function.FunctionArguments +import io.github.arainko.ducktape.internal.modules.MaterializedConfiguration.* +import io.github.arainko.ducktape.internal.modules.* + +import scala.deriving.* +import scala.quoted.* + +private[ducktape] object FallibleCoproductTransformations { + + def transform[F[+x]: Type, Source: Type, Dest: Type]( + Source: Expr[Mirror.SumOf[Source]], + Dest: Expr[Mirror.SumOf[Dest]], + F: Expr[Mode[F]], + sourceValue: Expr[Source] + )(using Quotes): Expr[F[Dest]] = { + given Cases.Source = Cases.Source.fromMirror(Source) + given Cases.Dest = Cases.Dest.fromMirror(Dest) + + val ifNoMatch = '{ throw RuntimeException("Unhandled condition encountered during Coproduct Transformer derivation") } + + ExhaustiveCoproductMatching(cases = Cases.source, sourceValue = sourceValue, ifNoMatch = ifNoMatch) { + coproductBranch(sourceValue, _, F) + } + } + + private def coproductBranch[F[+x]: Type, Source: Type, Dest: Type]( + sourceValue: Expr[Source], + source: Case, + F: Expr[Mode[F]] + )(using Quotes, Cases.Dest): Expr[F[Dest]] = { + import quotes.reflect.* + + val dest = Cases.dest.getOrElse(source.name, Failure.emit(Failure.NoChildMapping(source.name, summon[Type[Dest]]))) + + def tryFallibleTransformation: Either[String, Expr[F[Dest]]] = + source.fallibleTransformerTo[F](dest).map { + case '{ FallibleTransformer.fallibleFromTotal[F, src, dest](using $total, $support) } => + '{ + val castedSource = $sourceValue.asInstanceOf[src] + val transformed = ${ LiftTransformation.liftTransformation(total, 'castedSource) } + $F.pure(transformed) + }.asExprOf[F[Dest]] + + case '{ $transformer: FallibleTransformer[F, src, dest] } => + val castedSource: Expr[src] = '{ $sourceValue.asInstanceOf[src] } + '{ $transformer.transform($castedSource) }.asExprOf[F[Dest]] + } + + def trySingletonTransformation: Option[Expr[F[Dest]]] = + dest.materializeSingleton.map { singleton => + '{ $F.pure[Dest]($singleton.asInstanceOf[Dest]) } + } + + tryFallibleTransformation match { + case Right(expr) => expr + case Left(explanation) => + // note: explanation is information from the FallibleTransformation implicit search. + trySingletonTransformation + .getOrElse( + Failure.emit(Failure.CannotTransformCoproductCaseFallible(summon[Type[F]], source.tpe, dest.tpe, explanation)) + ) + } + } +} diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Case.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Case.scala index 6a738714..5ba6abe5 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Case.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Case.scala @@ -1,6 +1,7 @@ package io.github.arainko.ducktape.internal.modules import io.github.arainko.ducktape.Transformer +import io.github.arainko.ducktape.fallible.{ FallibleTransformer, Mode } import scala.quoted.* @@ -21,6 +22,20 @@ private[ducktape] final case class Case( } } + def fallibleTransformerTo[F[+x]]( + that: Case + )(using quotes: Quotes, F: Type[F]): Either[String, Expr[FallibleTransformer[F, ?, ?]]] = { + import quotes.reflect.* + + (tpe -> that.tpe) match { + case '[src] -> '[dest] => + Implicits.search(TypeRepr.of[FallibleTransformer[F, src, dest]]) match { + case success: ImplicitSearchSuccess => Right(success.tree.asExprOf[FallibleTransformer[F, src, dest]]) + case err: ImplicitSearchFailure => Left(err.explanation) + } + } + } + def materializeSingleton(using Quotes): Option[Expr[Any]] = { import quotes.reflect.* diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Cases.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Cases.scala index 94b2cd0a..a29d721e 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Cases.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Cases.scala @@ -4,7 +4,7 @@ import scala.deriving.Mirror import scala.quoted.* private[ducktape] sealed trait Cases { - export byName.get + export byName.{ get, getOrElse } val value: List[Case] diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Failure.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Failure.scala index f7a901ba..da4dd2aa 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Failure.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/modules/Failure.scala @@ -154,6 +154,22 @@ private[ducktape] object Failure { """.stripMargin } + final case class CannotTransformCoproductCaseFallible( + wrapperTpe: Type[?], + source: Type[?], + dest: Type[?], + implicitSearchExplanation: String + ) extends Failure { + override final def render(using Quotes): String = + s""" + |Neither an instance of Transformer.Fallible[${wrapperTpe.show}, ${source.fullName}, ${dest.fullName}] was found + |nor are '${source.show}' '${dest.show}' singletons with the same name. + | + |Compiler supplied explanation for the failed Transformer derivation (may or may not be helpful): + |$implicitSearchExplanation + """.stripMargin + } + final case class FieldSourceMatchesNoneOfDestFields(config: Expr[Any], fieldSourceTpe: Type[?], destTpe: Type[?]) extends Failure { diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/internal/macros/AccumulatingCoproductTransformationsSuite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/internal/macros/AccumulatingCoproductTransformationsSuite.scala new file mode 100644 index 00000000..563b3496 --- /dev/null +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/internal/macros/AccumulatingCoproductTransformationsSuite.scala @@ -0,0 +1,152 @@ +package io.github.arainko.ducktape.internal.macros + +import io.github.arainko.ducktape.fallible.FallibleTransformer +import io.github.arainko.ducktape.{ DucktapeSuite, Transformer } + +import scala.deriving.Mirror + +class AccumulatingCoproductTransformationsSuite extends DucktapeSuite { + type ErrorsOrResult = [X] =>> Either[List[String], X] + + given Transformer.Mode.Accumulating[ErrorsOrResult] = + Transformer.Mode.Accumulating.either[String, List] + + given deriveMandatoryOptionTransformer[A, B](using + transformer: FallibleTransformer[ErrorsOrResult, A, B] + ): FallibleTransformer[ErrorsOrResult, Option[A], B] = { + case Some(a) => transformer.transform(a) + case None => Left(List("Missing required field")) + } + + test("Derive sum of products") { + sealed trait From + object From { + case class Product(field: Option[Int]) extends From + case class Product2(field: Option[Int]) extends From + } + + sealed trait To + object To { + case class Product(field: Int) extends To + case class Product2(field: Int) extends To + } + + val transformer = FallibleTransformer.betweenCoproductsAccumulating[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Product(Some(1))) == Right(To.Product(1))) + assert(transformer.transform(From.Product(None)) == Left(List("Missing required field"))) + assert(transformer.transform(From.Product2(Some(2))) == Right(To.Product2(2))) + } + + test("Derive sum of singletons") { + sealed trait From + object From { + case object Singleton extends From + case object Singleton2 extends From + } + + sealed trait To + object To { + case object Singleton extends To + case object Singleton2 extends To + } + + val transformer = FallibleTransformer.betweenCoproductsAccumulating[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Singleton) == Right(To.Singleton)) + assert(transformer.transform(From.Singleton2) == Right(To.Singleton2)) + } + + test("Derive sum of singleton and product") { + sealed trait From + object From { + case class Product(field: Option[Int]) extends From + case object Singleton extends From + } + + sealed trait To + object To { + case class Product(field: Int) extends To + case object Singleton extends To + } + + val transformer = FallibleTransformer.betweenCoproductsAccumulating[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Singleton) == Right(To.Singleton)) + assert(transformer.transform(From.Product(Some(5))) == Right(To.Product(5))) + assert(transformer.transform(From.Product(None)) == Left(List("Missing required field"))) + } + + test("Use total transformers when possible") { + type Field = Int + + given Transformer[Field, Field] = n => n + 5 + + sealed trait From + object From { + case class Product(field: Field) extends From + } + + sealed trait To + object To { + case class Product(field: Field) extends To + } + + val transformer = FallibleTransformer.betweenCoproductsAccumulating[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Product(5)) == Right(To.Product(10))) + } + + test("Accumulate problems") { + sealed trait From + object From { + case class Product(field1: Option[Int], field2: Option[String]) extends From + } + + sealed trait To + object To { + case class Product(field1: Int, field2: String) extends To + } + + val transformer = FallibleTransformer.betweenCoproductsAccumulating[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Product(None, None)) == Left(List("Missing required field", "Missing required field"))) + assert(transformer.transform(From.Product(Some(1), None)) == Left(List("Missing required field"))) + assert(transformer.transform(From.Product(None, Some("s"))) == Left(List("Missing required field"))) + assert(transformer.transform(From.Product(Some(1), Some("s"))) == Right(To.Product(1, "s"))) + } + + test("Derivation fails if the case names don't align") { + sealed trait From + object From { + case class Product() extends From + } + + sealed trait To + object To { + case class ProductWrongName() extends To + } + + assertFailsToCompileWith { + "FallibleTransformer.betweenCoproductsAccumulating[ErrorsOrResult, From, To]" + }("No child named 'Product' found in To") + } + + test("Derivation fails if a case can't be transformed") { + sealed trait From + object From { + case class Product(s: String) extends From + } + + sealed trait To + object To { + case class Product(y: String) extends To + } + + assertFailsToCompileWith { + "FallibleTransformer.betweenCoproductsAccumulating[ErrorsOrResult, From, To]" + }( + "Neither an instance of Transformer.Fallible[ErrorsOrResult, From.Product, To.Product] was found\nnor are 'Product' 'Product' singletons with the same name" + ) + } +} diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/internal/macros/FastFailCoproductTransformationsSuite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/internal/macros/FastFailCoproductTransformationsSuite.scala new file mode 100644 index 00000000..e0e7ee06 --- /dev/null +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/internal/macros/FastFailCoproductTransformationsSuite.scala @@ -0,0 +1,133 @@ +package io.github.arainko.ducktape.internal.macros + +import io.github.arainko.ducktape.fallible.FallibleTransformer +import io.github.arainko.ducktape.{ DucktapeSuite, Transformer } + +import scala.deriving.Mirror + +class FastFailCoproductTransformationsSuite extends DucktapeSuite { + type ErrorsOrResult = [X] =>> Either[String, X] + + given Transformer.Mode.FailFast[ErrorsOrResult] = + Transformer.Mode.FailFast.either[String] + + given deriveMandatoryOptionTransformer[A, B](using + transformer: FallibleTransformer[ErrorsOrResult, A, B] + ): FallibleTransformer[ErrorsOrResult, Option[A], B] = { + case Some(a) => transformer.transform(a) + case None => Left("Missing required field") + } + + test("Derive sum of products") { + sealed trait From + object From { + case class Product(field: Option[Int]) extends From + case class Product2(field: Option[Int]) extends From + } + + sealed trait To + object To { + case class Product(field: Int) extends To + case class Product2(field: Int) extends To + } + + val transformer = FallibleTransformer.betweenCoproductsFailFast[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Product(Some(1))) == Right(To.Product(1))) + assert(transformer.transform(From.Product(None)) == Left("Missing required field")) + assert(transformer.transform(From.Product2(Some(2))) == Right(To.Product2(2))) + } + + test("Derive sum of singletons") { + sealed trait From + object From { + case object Singleton extends From + case object Singleton2 extends From + } + + sealed trait To + object To { + case object Singleton extends To + case object Singleton2 extends To + } + + val transformer = FallibleTransformer.betweenCoproductsFailFast[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Singleton) == Right(To.Singleton)) + assert(transformer.transform(From.Singleton2) == Right(To.Singleton2)) + } + + test("Derive sum of singleton and product") { + sealed trait From + object From { + case class Product(field: Option[Int]) extends From + + case object Singleton extends From + } + + sealed trait To + object To { + case class Product(field: Int) extends To + case object Singleton extends To + } + + val transformer = FallibleTransformer.betweenCoproductsFailFast[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Singleton) == Right(To.Singleton)) + assert(transformer.transform(From.Product(Some(5))) == Right(To.Product(5))) + assert(transformer.transform(From.Product(None)) == Left("Missing required field")) + } + + test("Fail with the first problems") { + sealed trait From + object From { + case class Product(field1: Option[Int], field2: Option[String]) extends From + } + + sealed trait To + object To { + case class Product(field1: Int, field2: String) extends To + } + + val transformer = FallibleTransformer.betweenCoproductsFailFast[ErrorsOrResult, From, To] + + assert(transformer.transform(From.Product(None, None)) == Left("Missing required field")) + assert(transformer.transform(From.Product(Some(1), None)) == Left("Missing required field")) + assert(transformer.transform(From.Product(None, Some("s"))) == Left("Missing required field")) + assert(transformer.transform(From.Product(Some(1), Some("s"))) == Right(To.Product(1, "s"))) + } + + test("Derivation fails if the case names don't align") { + sealed trait From + object From { + case class Product() extends From + } + + sealed trait To + object To { + case class ProductWrongName() extends To + } + + assertFailsToCompileWith { + "FallibleTransformer.betweenCoproductsFailFast[ErrorsOrResult, From, To]" + }("No child named 'Product' found in To") + } + + test("Derivation fails if a case can't be transformed") { + sealed trait From + object From { + case class Product(s: String) extends From + } + + sealed trait To + object To { + case class Product(y: String) extends To + } + + assertFailsToCompileWith { + "FallibleTransformer.betweenCoproductsFailFast[ErrorsOrResult, From, To]" + }( + "Neither an instance of Transformer.Fallible[ErrorsOrResult, From.Product, To.Product] was found\nnor are 'Product' 'Product' singletons with the same name" + ) + } +}