diff --git a/build.sbt b/build.sbt index 8a2a8c33..bd999751 100644 --- a/build.sbt +++ b/build.sbt @@ -60,7 +60,8 @@ lazy val tests = project // These two to allow compilation under Java 9... // Specifically to import XML stuff that got modularised "javax.xml.bind" % "jaxb-api" % "2.3.0" % "compile", - "com.sun.xml.bind" % "jaxb-impl" % "2.3.0" % "compile" + "com.sun.xml.bind" % "jaxb-impl" % "2.3.0" % "compile", + "io.circe" %% "circe-core" % "0.9.3" ) ) .dependsOn(examplesJVM) diff --git a/tests/src/main/scala/CirceRecursiveTypeTest.scala b/tests/src/main/scala/CirceRecursiveTypeTest.scala new file mode 100644 index 00000000..c38bac68 --- /dev/null +++ b/tests/src/main/scala/CirceRecursiveTypeTest.scala @@ -0,0 +1,44 @@ +import estrapade.{TestApp, test} + +import language.experimental.macros +import MagnoliaEncoder.genEncoder +import io.circe.Encoder +import io.circe.Encoder._ + +case class Recursive(field: Int, recursion: List[Recursive]) + +/** + * Problem here is that we expect the same behaviour as shapeless Lazy provides - + * the derived codec itself would be visible and used to construct the codec for List using circe std one. + * Magnolia doesn't see it and derives coproduct codec for List instead, which doesn't look nice for json API: + * {{{ + * { + * "::" : { + * "head" : { + * "field" : 2, + * "recursion" : { + * "Nil" : "Nil" + * } + * }, + * "tl$access$1" : { + * "Nil" : "Nil" + * } + * } + * } + * } + * }}} + */ +object CirceRecursiveTypeTest extends TestApp { + + def tests(): Unit = { + + val encoder = Encoder[Recursive] + test("Use available encoders while descending into a recursive type") { + encoder(Recursive(1, List(Recursive(2, Nil), Recursive(3, Nil)))) + } + .assert( + j => j.asObject.flatMap(_ ("recursion")).exists(_.isArray), + _.toString() + ) + } +} diff --git a/tests/src/main/scala/IntermediateTraitsTest.scala b/tests/src/main/scala/IntermediateTraitsTest.scala new file mode 100644 index 00000000..cebe287a --- /dev/null +++ b/tests/src/main/scala/IntermediateTraitsTest.scala @@ -0,0 +1,51 @@ +import estrapade.{TestApp, test} + +import language.experimental.macros +import MagnoliaEncoder.genEncoder +import MagnoliaDecoder.genDecoder +import io.circe._ + +/** + * This is another semantical difference from shapeless-based circe derivation. + * Shapeless generic skips intermediate traits/abstract classes and forms a coproduct of only leaf types. + * This makes perfect sense for many scenarios, JSON included. + * + * Magnolia, on the other hand, dispatches through all intermediate types. + * For encoding it's not that bad, but for decoding it's a showstopper. See tests. + */ +object IntermediateTraitsTest extends TestApp { + + sealed trait T + case class A(a: Int) extends T + case class B(b: String) extends T + sealed trait C extends T + case class C1(c1: Int) extends C + case class C2(c2: String) extends C + + def tests(): Unit = { + + val encoder = Encoder[T] + val decoder = Decoder[T] + + // here JSON is deeper nested than when using circe-generic. + // it's not that huge problem, until you try to decode a leaf, that is under an intermediate trait (next test) + test("Skip intermediate traits on encoding") { + encoder(C1(5)).hcursor.get[JsonObject]("C1") + } + .assert( + _.isRight, + _.toString() + ) + + // when sending a message to JSON API we don't usually specify intermediate traits - we just put the leaf type into the key. + // Magnolia can't see the C1, because on the first dispatch it faces only A, B and C. + test("Skip intermediate traits on decoding") { + decoder(HCursor.fromJson(Json.obj("C1" -> Json.obj("c1" -> Json.fromInt(2))))) + } + .assert( + _.isRight, + _.toString() + ) + + } +} diff --git a/tests/src/main/scala/MagnoliaDecoder.scala b/tests/src/main/scala/MagnoliaDecoder.scala new file mode 100644 index 00000000..7f41fd0f --- /dev/null +++ b/tests/src/main/scala/MagnoliaDecoder.scala @@ -0,0 +1,60 @@ +import io.circe.Decoder.Result +import io.circe.{Decoder, DecodingFailure, HCursor} +import magnolia._ +import cats.syntax.either._ + +import scala.language.experimental.macros + +object MagnoliaDecoder { + + type Typeclass[T] = Decoder[T] + + def combine[T](caseClass: CaseClass[Typeclass, T]): Decoder[T] = new Decoder[T] { + def apply(c: HCursor): Result[T] = + if (caseClass.isValueClass) + caseClass.parameters.head.typeclass(c) + .flatMap(singlePar => + Either.catchNonFatal(caseClass.rawConstruct(Seq(singlePar))) + .leftMap(DecodingFailure.fromThrowable(_, c.history)) + ) + else if (caseClass.isObject) + Right(caseClass.construct(_ => ())) + else + caseClass.parameters.map(p => c.downField(p.label).as[p.PType](p.typeclass)) + .foldLeft(Right(List.empty): Decoder.Result[List[Any]]) { + case (l, r) => l.flatMap(ll => r.map(rr => ll ++ List(rr))) + } + .flatMap(v => Either.catchNonFatal(caseClass.rawConstruct(v)) + .leftMap(DecodingFailure.fromThrowable(_, c.history))) + } + + def dispatch[T](sealedTrait: SealedTrait[Typeclass, T]): Decoder[T] = new Decoder[T] { + def apply(c: HCursor): Result[T] = c.keys match { + case Some(keys) if keys.size == 1 => + val key = keys.head + for { + theSubtype <- Either.fromOption( + sealedTrait.subtypes.find(_.typeName.short == key), + DecodingFailure( + s"""Can't decode coproduct type: couldn't find matching subtype. + |JSON: ${c.value}, + |Key: $key + |Known subtypes: ${sealedTrait.subtypes.map(_.typeName.short).mkString(",")}\n""".stripMargin, + c.history + )) + + result <- c.get(key)(theSubtype.typeclass) + } yield result + case _ => + Left(DecodingFailure( + s"""Can't decode coproduct type: zero or several keys were found, while coproduct type requires exactly one key. + |JSON: ${c.value}, + |Keys: ${c.keys.map(_.mkString(","))} + |Known subtypes: ${sealedTrait.subtypes.map(_.typeName.short).mkString(",")}\n""".stripMargin, + c.history + )) + } + } + + implicit def genDecoder[T]: Typeclass[T] = macro Magnolia.gen[T] +} diff --git a/tests/src/main/scala/MagnoliaEncoder.scala b/tests/src/main/scala/MagnoliaEncoder.scala new file mode 100644 index 00000000..8c9bfb6e --- /dev/null +++ b/tests/src/main/scala/MagnoliaEncoder.scala @@ -0,0 +1,36 @@ +import io.circe.{Encoder, Json} +import magnolia._ + +import scala.language.experimental.macros + +object MagnoliaEncoder { + + type Typeclass[T] = Encoder[T] + + def combine[T](caseClass: CaseClass[Typeclass, T]): Typeclass[T] = new Encoder[T] { + def apply(a: T): Json = { + if (caseClass.isValueClass) { + val p = caseClass.parameters.head + p.typeclass(p.dereference(a)) + } + else if (caseClass.isObject) + Json.fromString(caseClass.typeName.short) + else Json.obj( + caseClass.parameters.map(p => + p.label -> p.typeclass(p.dereference(a)) + ): _* + ) + } + } + + def dispatch[T](sealedTrait: SealedTrait[Typeclass, T]): Typeclass[T] = new Encoder[T] { + def apply(a: T): Json = + sealedTrait.dispatch(a) { subtype => + Json.obj( + subtype.typeName.short -> subtype.typeclass(subtype.cast(a)) + ) + } + } + + implicit def genEncoder[T]: Typeclass[T] = macro Magnolia.gen[T] +} diff --git a/tests/src/main/scala/PriorityIssueTest.scala b/tests/src/main/scala/PriorityIssueTest.scala new file mode 100644 index 00000000..d9769644 --- /dev/null +++ b/tests/src/main/scala/PriorityIssueTest.scala @@ -0,0 +1,52 @@ +import estrapade.{TestApp, test} + +import language.experimental.macros +import MagnoliaEncoder.genEncoder +import io.circe.Encoder + + +case class MapContainer(theMap: Map[String, List[Int]]) + +/** + * Something weird is going on here. + * During derivation for the right side of the map, + * Magnolia notices default list codec only after it partially derives it as coproduct. Ending JSON is very bizarre: + * {{{ + * { + * "theMap" : { + * "f" : { + * "::" : [1 , 2, 3] + * } + * } + * } + * }}} + * This can be fixed by explicitly importing Encoder companion into scope (see 2nd test). + * + * Seems like magnolia macro in some cases has higher priority than companion provided implicits, + * that are not directly imported in scope + */ +object PriorityIssueTest extends TestApp { + + def tests(): Unit = { + + + test("Use instances from companion even if they are not imported") { + val encoder = Encoder[MapContainer] + encoder(MapContainer(Map("f" -> List(1, 2, 3)))) + } + .assert( + j => j.hcursor.downField("theMap").downField("f").focus.exists(_.isArray), + _.toString() + ) + + test("Use instances from companion when they are explicitly imported") { + import Encoder._ + val encoder = Encoder[MapContainer] + encoder(MapContainer(Map("f" -> List(1, 2, 3)))) + } + .assert( + j => j.hcursor.downField("theMap").downField("f").focus.exists(_.isArray), + _.toString() + ) + } +}