Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DEMO] Circe Encoder/Decoder derivation issues #86

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions tests/src/main/scala/CirceRecursiveTypeTest.scala
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
51 changes: 51 additions & 0 deletions tests/src/main/scala/IntermediateTraitsTest.scala
Original file line number Diff line number Diff line change
@@ -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()
)

}
}
60 changes: 60 additions & 0 deletions tests/src/main/scala/MagnoliaDecoder.scala
Original file line number Diff line number Diff line change
@@ -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]
}
36 changes: 36 additions & 0 deletions tests/src/main/scala/MagnoliaEncoder.scala
Original file line number Diff line number Diff line change
@@ -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]
}
52 changes: 52 additions & 0 deletions tests/src/main/scala/PriorityIssueTest.scala
Original file line number Diff line number Diff line change
@@ -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()
)
}
}