Skip to content

Commit

Permalink
Support fully-qualified classnames in Scala 3 macros
Browse files Browse the repository at this point in the history
  • Loading branch information
dwijnand committed Feb 18, 2021
1 parent 64c78f3 commit be7195e
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 114 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Case classes automatically meet these requirements. For custom classes or traits

A trait can also supported, if and only if it's a sealed one and if the sub-types comply with the previous requirements:

@[model1](code-2/Scala2JsonAutomatedSpec.scala)
@[model3](code/ScalaJsonAutomatedSpec.scala)

The JSON representation for instances of a sealed family includes a discriminator field, which specify the effective sub-type (a text field, with default name `_type`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,76 +11,8 @@ import org.specs2.mutable.Specification
final class IdText(val value: String) extends AnyVal
//#valueClass

object Scala2JsonAutomatedSpec {
//#model1
sealed trait Role
case object Admin extends Role
class Contributor(val organization: String) extends Role {
override def equals(obj: Any): Boolean = obj match {
case other: Contributor if obj != null => this.organization == other.organization
case _ => false
}
}
object Contributor {
def apply(organization: String): Contributor = new Contributor(organization)
def unapply(contributor: Contributor): Option[(String)] = Some(contributor.organization)
}
//#model1
}

class Scala2JsonAutomatedSpec extends Specification {
import Scala2JsonAutomatedSpec._

"Scala 2 JSON automated" should {
"automatically convert JSON for a sealed family" in {
//#trait-representation
val adminJson = Json.parse(s"""
{ "_type": "scalaguide.json.Scala2JsonAutomatedSpec.Admin" }
""")

val contributorJson = Json.parse(s"""
{
"_type":"scalaguide.json.Scala2JsonAutomatedSpec.Contributor",
"organization":"Foo"
}
""")

// Each JSON objects is marked with the _type,
// indicating the fully-qualified name of sub-type
//#trait-representation

//#auto-JSON-sealed-trait
import play.api.libs.json._

// First provide instance for each sub-types 'Admin' and 'Contributor':
implicit val adminFormat = OFormat[Admin.type](Reads[Admin.type] {
case JsObject(_) => JsSuccess(Admin)
case _ => JsError("Empty object expected")
}, OWrites[Admin.type] { _ =>
Json.obj()
})

implicit val contributorFormat: OFormat[Contributor] = Json.format[Contributor]

// Finally able to generate format for the sealed family 'Role'
implicit val roleFormat: OFormat[Role] = Json.format[Role]
//#auto-JSON-sealed-trait

def writeAnyRole(role: Role) = Json.toJson(role)

def readAnyRole(input: JsValue): JsResult[Role] = input.validate[Role]

val sampleContributor = Contributor("Foo")

writeAnyRole(Admin).must_===(adminJson) and {
writeAnyRole(sampleContributor).must_===(contributorJson)
} and {
readAnyRole(adminJson).must_===(JsSuccess(Admin))
} and {
readAnyRole(contributorJson).must_===(JsSuccess(sampleContributor))
}
}

// lampepfl/dotty#7000 No Mirrors for value classes
"for value class" >> {
"produce a Reads" in {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ object ScalaJsonAutomatedSpec {
case class PlayUser(name: String, firstName: String, userAge: Int)
//#model2

//#model3
sealed trait Role
case object Admin extends Role
case class Contributor(organization: String) extends Role
//#model3

val sampleJson = Json.parse(
"""{
Expand Down Expand Up @@ -177,6 +179,8 @@ class ScalaJsonAutomatedSpec extends Specification {
}

"automatically convert JSON to a case class" in {
def println(str: String) = str // avoid the println below side-effecting during the test

//#auto-JSON-to-case-class
import play.api.libs.json._

Expand Down Expand Up @@ -207,6 +211,55 @@ class ScalaJsonAutomatedSpec extends Specification {
}
}

"automatically convert JSON for a sealed family" in {
//#trait-representation
val adminJson = Json.parse("""
{ "_type": "scalaguide.json.ScalaJsonAutomatedSpec.Admin" }
""")

val contributorJson = Json.parse("""
{
"_type":"scalaguide.json.ScalaJsonAutomatedSpec.Contributor",
"organization":"Foo"
}
""")

// Each JSON objects is marked with the _type,
// indicating the fully-qualified name of sub-type
//#trait-representation

//#auto-JSON-sealed-trait
import play.api.libs.json._

// First provide instance for each sub-types 'Admin' and 'Contributor':
implicit val adminFormat = OFormat[Admin.type](Reads[Admin.type] {
case JsObject(_) => JsSuccess(Admin)
case _ => JsError("Empty object expected")
}, OWrites[Admin.type] { _ =>
Json.obj()
})

implicit val contributorFormat: OFormat[Contributor] = Json.format[Contributor]

// Finally able to generate format for the sealed family 'Role'
implicit val roleFormat: OFormat[Role] = Json.format[Role]
//#auto-JSON-sealed-trait

def writeAnyRole(role: Role) = Json.toJson(role)

def readAnyRole(input: JsValue): JsResult[Role] = input.validate[Role]

val sampleContributor = Contributor("Foo")

writeAnyRole(Admin).must_===(adminJson) and {
writeAnyRole(sampleContributor).must_===(contributorJson)
} and {
readAnyRole(adminJson).must_===(JsSuccess(Admin))
} and {
readAnyRole(contributorJson).must_===(JsSuccess(sampleContributor))
}
}

"automatically convert custom JSON for a sealed family" in {
//#trait-custom-representation
val adminJson = Json.parse("""
Expand All @@ -228,11 +281,8 @@ class ScalaJsonAutomatedSpec extends Specification {
// Each JSON objects is marked with the admTpe, ...
discriminator = "admTpe",
// ... indicating the lower-cased name of sub-type
typeNaming = JsonNaming {
case "scalaguide.json.ScalaJsonAutomatedSpec.Contributor" => "contributor"
case "scalaguide.json.ScalaJsonAutomatedSpec.Admin" => "admin"
case "Admin" => "admin"
case "Contributor" => "contributor"
typeNaming = JsonNaming { fullName =>
fullName.drop(39 /* remove pkg */ ).toLowerCase
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ object JsMacroImpl {
case Some(tjs) => {
val vjs = obj.value.get("_value").getOrElse(obj)
tjs.validate[String].flatMap { dis =>
// lampepfl/dotty#11048 discriminator may be "play.api.libs.json.MacroSpec.Simple" but in MirroredElemLabels is "Simple"
val disName = dis.drop(dis.lastIndexOf('.') + 1)
readCasesL[A, m.MirroredElemLabels, m.MirroredElemTypes](vjs, disName)
readCasesL[A, m.MirroredElemLabels, m.MirroredElemTypes](vjs, dis)
}
}
}
Expand Down Expand Up @@ -78,7 +76,7 @@ object JsMacroImpl {
inline def readCasesL[A: Reads, L <: Tuple, T <: Tuple](js: JsValue, name: String): JsResult[A] =
inline (erasedValue[L], erasedValue[T]) match {
case _: (l *: ls, t *: ts) =>
if (name == typeName[l]) summonReads[t & A].reads(js)
if (name == typeName[l, A]) summonReads[t & A].reads(js)
else readCasesL[A, ls, ts](js, name)
case _: (EmptyTuple, EmptyTuple) => JsError("error.invalid")
}
Expand All @@ -92,7 +90,7 @@ object JsMacroImpl {
case xo @ JsObject(_) => xo
case jsv => JsObject(Seq("_value" -> jsv))
}
JsObject(Map(config.discriminator -> JsString(typeName[l]))) ++ jso
JsObject(Map(config.discriminator -> JsString(typeName[l, A]))) ++ jso
} else writeCasesL[A, ls, ts](x, ord)(n + 1)
case _: (EmptyTuple, EmptyTuple) => throw new MatchError(x)
}
Expand All @@ -112,19 +110,19 @@ object JsMacroImpl {
val writer = config.optionHandlers.writeHandler(path[L])(summonWrites[a]).asInstanceOf[OWrites[T]]
writer.writes(value).underlying
case _ =>
Map((config.naming(summonLabel[L]), summonWrites[T].writes(value)))
Map((fieldName[L], summonWrites[T].writes(value)))
}
}

inline def path[L]: JsPath = JsPath \ config.naming(summonLabel[L])
inline def typeName[L]: String = config.typeNaming(summonLabel[L])
inline def path[L]: JsPath = JsPath \ fieldName[L]
inline def fieldName[L]: String = config.naming(summonLabel[L].asInstanceOf[String])
inline def typeName[L, A]: String = config.typeNaming(summonFullLabel[L, A](summonLabel[L]))
inline def config: JsonConfiguration = summonInline[JsonConfiguration]
inline def summonReads[A]: Reads[A] = summonInline[Reads[A]]
inline def summonWrites[A]: Writes[A] = summonInline[Writes[A]]
inline def summonLabel[L]: L = inline erasedValue[L] match { case _: String => constValue[L] }

inline def summonLabel[L]: String = inline erasedValue[L] match {
case _: String => constValue[L].asInstanceOf[String]
}
inline def summonFullLabel[L, A](inline label: L): String = ${summonFullLabelImpl[L, A]('{label}) }

final class ArrayProduct[A](elems: Array[A]) extends Product {
def canEqual(that: Any): Boolean = true
Expand All @@ -147,4 +145,15 @@ object JsMacroImpl {
inline val url = "https://github.com/lampepfl/dotty/issues/" + issueNumber
error(msg + ", see " + url)
}

import scala.quoted._
private def summonFullLabelImpl[L, A: Type](labelExpr: Expr[L])(using Quotes): Expr[String] = {
import quotes.reflect._
val label = labelExpr.asInstanceOf[Expr[String]].valueOrError
val fqcn = TypeRepr.of[A].typeSymbol.children.find(_.name == label) match {
case Some(child) => child.owner.fullName.stripSuffix("$") + "." + child.name
case None => label
}
Expr(fqcn)
}
}

This file was deleted.

This file was deleted.

22 changes: 11 additions & 11 deletions play-json/shared/src/test/scala/play/api/libs/json/MacroSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,12 @@ class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalach

"using the _value syntax" in {
val jsSimple = Json.obj(
"_type" -> DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Simple"),
"_type" -> "play.api.libs.json.MacroSpec.Simple",
"_value" -> Json.writes[Simple].writes(simple)
)

val jsOptional = Json.obj(
"_type" -> DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Optional"),
"_type" -> "play.api.libs.json.MacroSpec.Optional",
"_value" -> Json.writes[Optional].writes(optional)
)

Expand All @@ -130,11 +130,11 @@ class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalach

"using the compact syntax" in {
val jsSimple = Json.writes[Simple].writes(simple) + (
"_type" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Simple"))
"_type" -> JsString("play.api.libs.json.MacroSpec.Simple")
)

val jsOptional = Json.writes[Optional].writes(optional) + (
"_type" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Optional"))
"_type" -> JsString("play.api.libs.json.MacroSpec.Optional")
)

jsSimple.validate[Family].mustEqual(JsSuccess(simple))
Expand Down Expand Up @@ -209,11 +209,11 @@ class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalach
val optional = Optional(None)

val jsSimple = simpleWrites.writes(simple) + (
"_type" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Simple"))
"_type" -> JsString("play.api.libs.json.MacroSpec.Simple")
)

val jsOptional = optionalWrites.writes(optional) + (
"_type" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Optional"))
"_type" -> JsString("play.api.libs.json.MacroSpec.Optional")
)

lazy val wsimple = Json.toJson[Family](simple)
Expand All @@ -229,7 +229,7 @@ class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalach
.toJson[Family1](Family1Member("bar"))
.mustEqual(
Json.obj(
"_type" -> DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Family1Member"),
"_type" -> "play.api.libs.json.MacroSpec.Family1Member",
"foo" -> "bar"
)
)
Expand Down Expand Up @@ -384,12 +384,12 @@ class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalach

val simple = Simple("foo")
val jsSimple = simpleWrites.writes(simple) + (
"_type" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Simple"))
"_type" -> JsString("play.api.libs.json.MacroSpec.Simple")
)

val optional = Optional(None)
val jsOptional = optionalFormat.writes(optional) + (
"_type" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Optional"))
"_type" -> JsString("play.api.libs.json.MacroSpec.Optional")
)

Json.toJson[Family](simple).mustEqual(jsSimple)
Expand All @@ -416,12 +416,12 @@ class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalach

val simple = Simple("foo")
val jsSimple = simpleWrites.writes(simple) + (
"_discriminator" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Simple"))
"_discriminator" -> JsString("play.api.libs.json.MacroSpec.Simple")
)

val optional = Optional(None)
val jsOptional = optionalFormat.writes(optional) + (
"_discriminator" -> JsString(DiscriminatorTestUtils.convert("play.api.libs.json.MacroSpec.Optional"))
"_discriminator" -> JsString("play.api.libs.json.MacroSpec.Optional")
)

Json.toJson[Family](simple).mustEqual(jsSimple)
Expand Down

0 comments on commit be7195e

Please # to comment.