From a29f47ad823194227587b71a8a48109875c3bd1e Mon Sep 17 00:00:00 2001 From: Z1kkurat Date: Sun, 7 May 2023 16:27:48 +0200 Subject: [PATCH] Cross-compile generic module to Scala 3 --- .github/workflows/ci.yml | 2 +- .gitignore | 18 +++- build.sbt | 12 ++- .../circe/PlayCirceAstConversions.scala | 3 +- .../playjson/generic/EnumMappings.scala | 2 + .../playjson/generic/Enumeration.scala | 0 .../playjson/generic/FlatTypeFormat.scala | 0 .../playjson/generic/FlatTypeReads.scala | 0 .../playjson/generic/FlatTypeWrites.scala | 0 .../playjson/generic/NameCodingStrategy.scala | 0 .../playjson/generic/NestedTypeFormat.scala | 0 .../playjson/generic/NestedTypeReads.scala | 0 .../playjson/generic/NestedTypeWrites.scala | 0 .../com/evolution/playjson/generic/Util.scala | 0 .../playjson/generic/EnumMappings.scala | 15 +++ .../playjson/generic/Enumeration.scala | 25 +++++ .../playjson/generic/FlatTypeFormat.scala | 16 +++ .../playjson/generic/FlatTypeReads.scala | 99 +++++++++++++++++ .../playjson/generic/FlatTypeWrites.scala | 79 ++++++++++++++ .../playjson/generic/NameCodingStrategy.scala | 28 +++++ .../playjson/generic/NestedTypeFormat.scala | 7 ++ .../playjson/generic/NestedTypeReads.scala | 101 ++++++++++++++++++ .../playjson/generic/NestedTypeWrites.scala | 53 +++++++++ .../com/evolution/playjson/generic/Util.scala | 31 ++++++ .../generic/EnumerationDerivalSpec.scala | 2 +- .../jsoniter_scala/core/Formats.scala | 2 +- .../playjson/tools/PlayJsonHelper.scala | 8 +- .../tools/DiscriminatedEitherFormatSpec.scala | 2 +- .../playjson/tools/FlatFormatSpec.scala | 2 +- 29 files changed, 490 insertions(+), 17 deletions(-) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/EnumMappings.scala (94%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/Enumeration.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/FlatTypeFormat.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/FlatTypeReads.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/FlatTypeWrites.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/NameCodingStrategy.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/NestedTypeFormat.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/NestedTypeReads.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/NestedTypeWrites.scala (100%) rename play-json-generic/src/main/{scala => scala-2}/com/evolution/playjson/generic/Util.scala (100%) create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/EnumMappings.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Enumeration.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeFormat.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeReads.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeWrites.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NameCodingStrategy.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NestedTypeFormat.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NestedTypeReads.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NestedTypeWrites.scala create mode 100644 play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Util.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ec8c13..4f24681 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,4 +39,4 @@ jobs: with: type: ${{ job.status }} job_name: Build - url: ${{ secrets.SLACK_WEBHOOK }} \ No newline at end of file + url: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.gitignore b/.gitignore index 7fc07ac..29b34a6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,17 @@ lib_managed/ src_managed/ project/boot/ project/plugins/project/ -.bsp/ + +# Bloop +.bsp + +# VS Code +.vscode/ + +# Metals +.bloop/ +.metals/ +metals.sbt # Scala-IDE specific .scala_dependencies @@ -30,4 +40,8 @@ ignore scripts/tmp ignored/ -.java-version \ No newline at end of file +.java-version + +.bloop/ +.metals/ +.vscode \ No newline at end of file diff --git a/build.sbt b/build.sbt index d68d97d..57571e3 100644 --- a/build.sbt +++ b/build.sbt @@ -72,13 +72,15 @@ lazy val `play-json-generic` = crossProject(JVMPlatform, JSPlatform) .crossType(CrossType.Pure) .settings( commonSettings, - crossScalaVersions -= Scala3, - scalacOptsFailOnWarn := Some(false), - libraryDependencies ++= Seq( - shapeless, + libraryDependencies ++= (Seq( playJson, scalaTest % Test - ).map(excludeLog4j), + ) ++ (CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, v)) if v >= 12 => + Seq(shapeless) + case _ => + Seq() + })).map(excludeLog4j) ) lazy val `play-json-tools` = project diff --git a/play-json-circe/src/main/scala/com/evolution/playjson/circe/PlayCirceAstConversions.scala b/play-json-circe/src/main/scala/com/evolution/playjson/circe/PlayCirceAstConversions.scala index c646cc6..737b12c 100644 --- a/play-json-circe/src/main/scala/com/evolution/playjson/circe/PlayCirceAstConversions.scala +++ b/play-json-circe/src/main/scala/com/evolution/playjson/circe/PlayCirceAstConversions.scala @@ -3,6 +3,7 @@ package com.evolution.playjson.circe import cats.Eval import io.circe.{Json => CirceJson} import play.api.libs.{json => PlayJson} +import io.circe.JsonObject object PlayCirceAstConversions { private type Field[T] = (String, T) @@ -22,7 +23,7 @@ object PlayCirceAstConversions { as.foldLeft(evalZero[PlayJson.JsValue])((acc, c) => inner(Eval.now(c)).flatMap(p => acc.map(_ :+ p))) } .map(PlayJson.JsArray), - jsonObject = obj => + jsonObject = (obj: JsonObject) => Eval .defer { obj.toIterable.foldLeft(evalZero[Field[PlayJson.JsValue]]) { case (acc, (k, c)) => diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/EnumMappings.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/EnumMappings.scala similarity index 94% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/EnumMappings.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/EnumMappings.scala index d854571..f1f4870 100644 --- a/play-json-generic/src/main/scala/com/evolution/playjson/generic/EnumMappings.scala +++ b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/EnumMappings.scala @@ -2,11 +2,13 @@ package com.evolution.playjson.generic import shapeless.{:+:, CNil, Coproduct, LabelledGeneric, Witness} import shapeless.labelled.FieldType +import scala.annotation.nowarn case class EnumMappings[A](labels: Map[A, String]) object EnumMappings { + @nowarn("cat=unused") implicit def enumMappings[A, Repr <: Coproduct](implicit gen: LabelledGeneric.Aux[A, Repr], // this is USED to generate `Enumeration`, not sure how, though e: MappingsAux[A, Repr] diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/Enumeration.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/Enumeration.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/Enumeration.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/Enumeration.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/FlatTypeFormat.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/FlatTypeFormat.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/FlatTypeFormat.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/FlatTypeFormat.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/FlatTypeReads.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/FlatTypeReads.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/FlatTypeReads.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/FlatTypeReads.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/FlatTypeWrites.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/FlatTypeWrites.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/FlatTypeWrites.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/FlatTypeWrites.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/NameCodingStrategy.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NameCodingStrategy.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/NameCodingStrategy.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NameCodingStrategy.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/NestedTypeFormat.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NestedTypeFormat.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/NestedTypeFormat.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NestedTypeFormat.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/NestedTypeReads.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NestedTypeReads.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/NestedTypeReads.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NestedTypeReads.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/NestedTypeWrites.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NestedTypeWrites.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/NestedTypeWrites.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/NestedTypeWrites.scala diff --git a/play-json-generic/src/main/scala/com/evolution/playjson/generic/Util.scala b/play-json-generic/src/main/scala-2/com/evolution/playjson/generic/Util.scala similarity index 100% rename from play-json-generic/src/main/scala/com/evolution/playjson/generic/Util.scala rename to play-json-generic/src/main/scala-2/com/evolution/playjson/generic/Util.scala diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/EnumMappings.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/EnumMappings.scala new file mode 100644 index 0000000..732b313 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/EnumMappings.scala @@ -0,0 +1,15 @@ +package com.evolution.playjson.generic + +import scala.deriving.Mirror +import scala.compiletime.summonAll + +case class EnumMappings[A](labels: Map[A, String]) + +object EnumMappings: + inline given valueMap[E](using m: Mirror.SumOf[E]): EnumMappings[E] = + // First, we make a compile-time check that all of subtypes of E are singletons + // (i.e. case objects) by requiring that there's an instance of ValueOf for each subtype. + val singletons = summonAll[Tuple.Map[m.MirroredElemTypes, ValueOf]] + // Then, we can safely obtain a list of ValueOf instances and map each subtype to its string representation. + val elems = singletons.toList.asInstanceOf[List[ValueOf[E]]] + EnumMappings(elems.view.map(_.value).map(e => e -> e.toString).toMap) diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Enumeration.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Enumeration.scala new file mode 100644 index 0000000..32f3333 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Enumeration.scala @@ -0,0 +1,25 @@ +package com.evolution.playjson.generic + +import play.api.libs.json._ + +class Enumeration[A] private(enumMappings: EnumMappings[A]): + + def format(using nameCodingStrategy: NameCodingStrategy): Format[A] = new Format[A]: + + val labelsLookup: Map[A, String] = enumMappings.labels.map { case (k, v) => (k, nameCodingStrategy(v)) } + val valuesLookup: Map[String, A] = labelsLookup.map(_.swap) + + def writes(o: A): JsValue = JsString(labelsLookup(o)) + + def reads(json: JsValue): JsResult[A] = { + for { + s <- json.validate[JsString] + v <- valuesLookup.get(s.value) match { + case Some(v) => JsSuccess(v) + case None => JsError(s"Cannot parse ${ s.value }") + } + } yield v + } + +object Enumeration: + def apply[A](using enumMappings: EnumMappings[A]) = new Enumeration[A](enumMappings) diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeFormat.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeFormat.scala new file mode 100644 index 0000000..e834e81 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeFormat.scala @@ -0,0 +1,16 @@ +package com.evolution.playjson.generic + +import play.api.libs.json._ + +/** + * This is a helper class for creating a `Format` instance for a sealed trait hierarchy. + * + * When reading from JSON, it will look for a `type` field in the JSON object and use its value to determine which + * subtype to use. The `type` field will be removed from the JSON object before the subtype's `Reads` is called. + * + * When writing to JSON, it will add a `type` field to the JSON object with the value of the subtype's simple name + * (without package prefix). + */ +object FlatTypeFormat: + def apply[A](using reads: FlatTypeReads[A], writes: FlatTypeWrites[A]): OFormat[A] = + OFormat(reads.reads(_), writes.writes(_)) diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeReads.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeReads.scala new file mode 100644 index 0000000..f918c87 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeReads.scala @@ -0,0 +1,99 @@ +package com.evolution.playjson.generic + +import scala.deriving.Mirror +import scala.compiletime.* +import play.api.libs.json.* +import scala.annotation.nowarn + +/** + * This is a helper class for creating a `Reads` instance for a sealed trait hierarchy. + * It will look for a `type` field in the JSON object and use its value to determine which subtype to use. + * The `type` field will be removed from the JSON object before the subtype's `Reads` is called. + * + * The difference between this class and `NestedTypeReads` is that this class uses the simple name of the subtype + * instead of the full prefixed name. This means that you cannot use the same simple name for multiple subtypes. + * + * Example: + * + * {{{ + * + * sealed trait Parent + * case class Child1(field1: String) extends Parent + * case class Child2(field2: Int) extends Parent + * + * object Child1: + * given Reads[Child1] = Json.reads[Child1] + * object Child2: + * given Reads[Child2] = Json.reads[Child2] + * + * val reads: FlatTypeReads[Parent] = summon[FlatTypeReads[Parent]] + * + * val json: JsValue = Json.parse("""{"type": "Child1", "field1": "value"}""") + * val result: JsResult[Parent] = reads.reads(json) // JsSuccess(Child1(value),) + * }}} + */ +trait FlatTypeReads[T] extends Reads[T]: + override def reads(jsValue: JsValue): JsResult[T] + +object FlatTypeReads: + def create[A](f: JsValue => JsResult[A]): FlatTypeReads[A] = + (json: JsValue) => f(json) + + def apply[A](using ev: FlatTypeReads[A]): FlatTypeReads[A] = ev + + /** + * This is the first method that will be called when the compiler is looking for an instance of `FlatTypeReads`. + * It will look for a `type` field in the JSON object and use its value to determine which subtype of `A` to use. + * Then, it will look for an instance of `Reads` for that subtype and use it to read the JSON object. + */ + inline given deriveFlatTypeReads[A](using + m: Mirror.SumOf[A], + nameCodingStrategy: NameCodingStrategy + ): FlatTypeReads[A] = + create[A] { json => + for { + obj <- json.validate[JsObject] + typ <- (obj \ "type").validate[String] + result <- deriveReads[A](typ) match + case Some(reads) => reads.reads(obj - "type") + case None => JsError("Failed to find decoder") + } yield result + } + + /** + * Recursively search the given tuple of types for one that matches the given type name and has a `Reads` instance. + * + * @param typ the type name to search for + * @param nameCodingStrategy the naming strategy to use when comparing the type name to the names of the types in + * the tuple + */ + private inline def deriveReadsForSum[A, T <: Tuple]( + typ: String + )(using nameCodingStrategy: NameCodingStrategy): Option[Reads[A]] = + inline erasedValue[T] match + case _: EmptyTuple => None + case _: (h *: t) => + deriveReads[h](typ) match + case None => deriveReadsForSum[A, t](typ) + case Some(value) => Some(value.asInstanceOf[Reads[A]]) + + private inline def deriveReads[A](typ: String)(using nameCodingStrategy: NameCodingStrategy): Option[Reads[A]] = + summonFrom { + case m: Mirror.ProductOf[A] => + // product (case class or case object) + val name = constValue[m.MirroredLabel] + if typ == nameCodingStrategy(name) + then Some(summonInline[Reads[A]]) + else None + case m: Mirror.SumOf[A] => + // sum (trait) + deriveReadsForSum[A, m.MirroredElemTypes](typ) + case v: ValueOf[A] => + // Singleton type (object without `case` modifier) + val name = singletonName[A] + if typ == nameCodingStrategy(name) + then Some(summonInline[Reads[A]]) + else None + } + end deriveReads +end FlatTypeReads diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeWrites.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeWrites.scala new file mode 100644 index 0000000..387a6d8 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeWrites.scala @@ -0,0 +1,79 @@ +package com.evolution.playjson.generic + +import play.api.libs.json.* + +import scala.deriving.Mirror +import scala.compiletime.* + +/** + * This is a helper class for creating a `Writes` instance for a sealed trait hierarchy. + * It will add a `type` field to the JSON object with the value of the subtype's simple name (without package prefix). + * + * Example: + * + * {{{ + * sealed trait Parent + * case class Child1(field1: String) extends Parent + * case class Child2(field2: Int) extends Parent + * + * object Child1: + * given OWrites[Child1] = Json.writes[Child1] + * object Child2: + * given OWrites[Child2] = Json.writes[Child2] + * + * val writes: FlatTypeWrites[Parent] = summon[FlatTypeWrites[Parent]] + * + * val json: JsValue = writes.writes(Child1("value")) // {"type": "Child1", "field1": "value"} + * }}} + */ +trait FlatTypeWrites[A] extends Writes[A]: + override def writes(o: A): JsObject + +object FlatTypeWrites: + def apply[A](using ev: FlatTypeWrites[A]): FlatTypeWrites[A] = ev + + def create[A](f: A => JsObject): FlatTypeWrites[A] = (o: A) => f(o) + + inline given deriveFlatTypeWrites[A](using + m: Mirror.SumOf[A], + nameCodingStrategy: NameCodingStrategy + ): FlatTypeWrites[A] = + // Generate writes instances for all subtypes of A and pick + // the one that matches the type of the passed value. + val writes = summonWrites[m.MirroredElemTypes] + create { value => + writes(m.ordinal(value)).asInstanceOf[FlatTypeWrites[A]].writes(value) + } + + /** + * Recursively summon `FlatTypeWrites` instances for all types in the given tuple. + */ + private inline def summonWrites[T <: Tuple](using + nameCodingStrategy: NameCodingStrategy + ): List[FlatTypeWrites[?]] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (head *: tail) => + summonWrite[head].asInstanceOf[FlatTypeWrites[?]] :: summonWrites[tail] + + private inline def summonWrite[A](using + nameCodingStrategy: NameCodingStrategy + ): FlatTypeWrites[A] = + summonFrom { + case m: Mirror.ProductOf[A] => + val name = constValue[m.MirroredLabel] + val writes = summonEnrichedWrites[A](nameCodingStrategy(name)) + create(value => writes.writes(value)) + case m: Mirror.SumOf[A] => + val allWrites = summonWrites[m.MirroredElemTypes] + create { value => + val idx = m.ordinal(value) + allWrites(idx).asInstanceOf[FlatTypeWrites[A]].writes(value) + } + case valueOf: ValueOf[A] => + // Singleton type (object without `case` modifier) + val name = singletonName[A] + val writes = summonEnrichedWrites[A](nameCodingStrategy(name)) + create(value => writes.writes(value)) + } +end FlatTypeWrites diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NameCodingStrategy.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NameCodingStrategy.scala new file mode 100644 index 0000000..530e1a7 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NameCodingStrategy.scala @@ -0,0 +1,28 @@ +package com.evolution.playjson.generic + +trait NameCodingStrategy extends ((String) => String) + +trait LowPriority: + given default: NameCodingStrategy = new NameCodingStrategy() { + override def apply(s: String): String = s + } + +object NameCodingStrategy extends LowPriority + +object NameCodingStrategies: + + private def lowerCaseSepCoding(sep: String): NameCodingStrategy = new NameCodingStrategy() { + override def apply(s: String): String = s.split("(? JsResult[A]): NestedTypeReads[A] = + (jsValue: JsValue) => f(jsValue) + + /** + * This is the first method that will be called when the compiler is looking for an instance of `NestedTypeReads`. + * It will look for a `type` field in the JSON object and use its value to determine which subtype of `A` to use. + * Then, it will look for an instance of `Reads` for that subtype and use it to read the JSON object. + * + * @param m + * @return + */ + inline given derive[A](using m: Mirror.SumOf[A]): NestedTypeReads[A] = + create[A] { jsValue => + for { + obj <- jsValue.validate[JsObject] + typ <- (obj \ "type").validate[String] + result <- deriveNestedTypeReadsForSum[A, m.MirroredElemTypes](typ, prefix = "") match + case Some(reads) => reads.reads(obj - "type") + case None => JsError(s"Could not find a Reads for type $typ") + } yield result + } + + /** + * Traverse the tuple of types and look for a `Reads` instance for the type that matches the given `typ`. + * If no `Reads` instance is found, return `None`. Otherwise, return the `Reads` instance. + * This method is used to traverse the tuple of types that represent the subtypes of a sealed trait. + * + * @param typ the value of the `type` field in the JSON object. + * @param prefix the prefix to use when building the full prefixed name of the subtype. + * For example, if the `prefix` is `com.example` and the `typ` is `Child1`, the full prefixed name of the subtype + * is `com.example.Child1`. If the `prefix` is empty, the full prefixed name of the subtype is just the `typ`. + */ + private inline def deriveNestedTypeReadsForSum[A, T <: Tuple]( + typ: String, + prefix: String + ): Option[Reads[A]] = + inline erasedValue[T] match + case _: EmptyTuple => None + case _: (head *: tail) => + deriveNestedTypeReads[head](typ, prefix) match + case None => deriveNestedTypeReadsForSum[A, tail](typ, prefix) + case Some(reads) => Some(reads.asInstanceOf[Reads[A]]) + + private inline def deriveNestedTypeReads[A]( + typ: String, + prefix: String + ): Option[Reads[A]] = + summonFrom { + case m: Mirror.ProductOf[A] => + val name = constValue[m.MirroredLabel] + if (prefixName(prefix, name) == typ) Some(summonInline[Reads[A]]) + else None + case m: Mirror.SumOf[A] => + val sumName = constValue[m.MirroredLabel] + deriveNestedTypeReadsForSum[A, m.MirroredElemTypes](typ, prefixName(prefix, sumName)) + case valueOf: ValueOf[A] => + // Singleton type (object without `case` modifier) + val name = singletonName[A] + if (prefixName(prefix, name) == typ) Some(summonInline[Reads[A]]) + else None + } +end NestedTypeReads diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NestedTypeWrites.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NestedTypeWrites.scala new file mode 100644 index 0000000..1f21598 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/NestedTypeWrites.scala @@ -0,0 +1,53 @@ +package com.evolution.playjson.generic + +import play.api.libs.json.* + +import scala.compiletime.* +import scala.deriving.Mirror +import scala.annotation.nowarn + +trait NestedTypeWrites[A] extends Writes[A]: + override def writes(o: A): JsObject + +object NestedTypeWrites: + def apply[A](using ev: NestedTypeWrites[A]): NestedTypeWrites[A] = ev + + def create[A](f: A => JsObject): NestedTypeWrites[A] = (value: A) => f(value) + + inline def summonWrite[A](prefix: String): NestedTypeWrites[A] = + summonFrom { + case m: Mirror.ProductOf[A] => + val name = constValue[m.MirroredLabel] + val writes = summonEnrichedWrites[A](prefixName(prefix, name)) + create(value => writes.writes(value)) + case m: Mirror.SumOf[A] => + val sumName = constValue[m.MirroredLabel] + val allWrites = summonWrites[m.MirroredElemTypes](prefixName(prefix, sumName)) + create[A] { value => + val idx = m.ordinal(value) + allWrites(idx).asInstanceOf[NestedTypeWrites[A]].writes(value) + } + case valueOf: ValueOf[A] => + // singleton type (object without `case` modifier) + val name = singletonName[A] + val writes = summonEnrichedWrites[A](prefixName(prefix, name)) + create(value => writes.writes(value)) + } + + inline def summonWrites[T <: Tuple]( + prefix: String + ): List[NestedTypeWrites[?]] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (head *: tail) => + summonWrite[head](prefix) :: summonWrites[tail](prefix) + + inline given deriveNestedTypeWrites[A](using + m: Mirror.SumOf[A] + ): NestedTypeWrites[A] = + val writes = summonWrites[m.MirroredElemTypes](prefix = "") + create { value => + val idx = m.ordinal(value) + writes(idx).asInstanceOf[NestedTypeWrites[A]].writes(value) + } +end NestedTypeWrites diff --git a/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Util.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Util.scala new file mode 100644 index 0000000..49eb116 --- /dev/null +++ b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/Util.scala @@ -0,0 +1,31 @@ +package com.evolution.playjson.generic + +import play.api.libs.json.* + +import scala.compiletime.* + +/** + * Summons an `OWrites[A]` instance for type `A` and enriches it with a `type` field. + * The `type` fields contains the passed `name` value. + * + * @param name the value for the `type` field + * @tparam A the type to summon an `OWrites[A]` instance for + */ +private[generic] inline def summonEnrichedWrites[A](name: String): OWrites[A] = + summonInline[OWrites[A]].transform(jsObject => + JsObject(Seq("type" -> JsString(name))) ++ jsObject + ) + +/** + * Prefixes the passed `name` with the passed `prefix` if the `prefix` is not blank. + */ +private[generic] inline def prefixName(prefix: String, name: String) = + if prefix.isBlank() then name else s"$prefix.$name" + +/** + * Return the name of the given singleton type (object without `case` modifier). Originally, + * `valueOf.value.toString()` returns something like `com.evolution.playjson.generic.Message$Out$Ack$@307d9c1d` + * which is why we have to split it, drop the last element and take "len - 1" element + */ +private[generic] inline def singletonName[A](using valueOf: ValueOf[A]): String = + valueOf.value.toString().split("\\$").dropRight(1).last diff --git a/play-json-generic/src/test/scala/com/evolution/playjson/generic/EnumerationDerivalSpec.scala b/play-json-generic/src/test/scala/com/evolution/playjson/generic/EnumerationDerivalSpec.scala index ee4d643..c314e85 100644 --- a/play-json-generic/src/test/scala/com/evolution/playjson/generic/EnumerationDerivalSpec.scala +++ b/play-json-generic/src/test/scala/com/evolution/playjson/generic/EnumerationDerivalSpec.scala @@ -28,7 +28,7 @@ class EnumerationDerivalSpec extends AnyFlatSpec with Matchers { } it should "be able to derive formats" in { - implicit val fmt = Enumeration[AnEvent].format + implicit val fmt: Format[AnEvent] = Enumeration[AnEvent].format val typ: AnEvent = AnEvent.DoneSome val js = Json.toJson(typ) diff --git a/play-json-jsoniter/shared/src/main/scala/com/github/plokhotnyuk/jsoniter_scala/core/Formats.scala b/play-json-jsoniter/shared/src/main/scala/com/github/plokhotnyuk/jsoniter_scala/core/Formats.scala index dde93d2..1925fe1 100644 --- a/play-json-jsoniter/shared/src/main/scala/com/github/plokhotnyuk/jsoniter_scala/core/Formats.scala +++ b/play-json-jsoniter/shared/src/main/scala/com/github/plokhotnyuk/jsoniter_scala/core/Formats.scala @@ -10,7 +10,7 @@ import scala.util.control.NonFatal * It is an internal implementation for [[com.evolution.playjson.jsoniter.PlayJsonJsoniter]] */ object Formats { - private[this] val pool = new ThreadLocal[(Array[Byte], JsonReader, JsonWriter)] { + private[Formats] val pool = new ThreadLocal[(Array[Byte], JsonReader, JsonWriter)] { override def initialValue(): (Array[Byte], JsonReader, JsonWriter) = { val buf = new Array[Byte](128) (buf, new JsonReader(buf, charBuf = new Array[Char](128)), new JsonWriter(buf)) diff --git a/play-json-tools/src/main/scala/com/evolution/playjson/tools/PlayJsonHelper.scala b/play-json-tools/src/main/scala/com/evolution/playjson/tools/PlayJsonHelper.scala index 37ed172..3c367ab 100644 --- a/play-json-tools/src/main/scala/com/evolution/playjson/tools/PlayJsonHelper.scala +++ b/play-json-tools/src/main/scala/com/evolution/playjson/tools/PlayJsonHelper.scala @@ -48,8 +48,8 @@ object PlayJsonHelper { def reads(json: JsValue): JsResult[Instant] = { def readStr = for {x <- json.validate[String]} yield { - val temporal = Try { Format parse x } recover { case _: DateTimeParseException => IsoFormat parse x } - Instant from temporal.get + val temporal = Try { Format.parse(x) } recover { case _: DateTimeParseException => IsoFormat.parse(x) } + Instant.from(temporal.get) } def readNum = for {x <- json.validate[Long]} yield Instant.ofEpochMilli(x) @@ -57,7 +57,7 @@ object PlayJsonHelper { readStr orElse readNum } - def writes(o: Instant): JsValue = JsString(Format format o) + def writes(o: Instant): JsValue = JsString(Format.format(o)) } @@ -71,7 +71,7 @@ object PlayJsonHelper { } yield LocalTime.parse(time, Format) } - def writes(o: LocalTime): JsValue = JsString(Format format o) + def writes(o: LocalTime): JsValue = JsString(Format.format(o)) } diff --git a/play-json-tools/src/test/scala/com/evolution/playjson/tools/DiscriminatedEitherFormatSpec.scala b/play-json-tools/src/test/scala/com/evolution/playjson/tools/DiscriminatedEitherFormatSpec.scala index 41a8100..cc73fda 100644 --- a/play-json-tools/src/test/scala/com/evolution/playjson/tools/DiscriminatedEitherFormatSpec.scala +++ b/play-json-tools/src/test/scala/com/evolution/playjson/tools/DiscriminatedEitherFormatSpec.scala @@ -6,7 +6,7 @@ import org.scalatest.matchers.should.Matchers import play.api.libs.json._ class DiscriminatedEitherFormatSpec extends AnyFunSuite with Matchers { - implicit val eitherFormat: OFormat[Either[String, Int]] = DiscriminatedEitherFormat.eitherFormat("L", "R"): OFormat[Either[String, Int]] + implicit val eitherFormat: OFormat[Either[String,Int]] = DiscriminatedEitherFormat.eitherFormat("L", "R"): OFormat[Either[String, Int]] val left: Either[String, Int] = Left("foo") val right: Either[String, Int] = Right(1) val fromEitherJson: JsValue => JsResult[Either[String, Int]] = Json.fromJson[Either[String, Int]](_) diff --git a/play-json-tools/src/test/scala/com/evolution/playjson/tools/FlatFormatSpec.scala b/play-json-tools/src/test/scala/com/evolution/playjson/tools/FlatFormatSpec.scala index 7ec1644..1a80aaa 100644 --- a/play-json-tools/src/test/scala/com/evolution/playjson/tools/FlatFormatSpec.scala +++ b/play-json-tools/src/test/scala/com/evolution/playjson/tools/FlatFormatSpec.scala @@ -45,4 +45,4 @@ object FlatFormatSpec { object Outer { implicit val JsonFormat: Format[Outer] = FlatFormat("inner", Json.format[Outer]) } -} \ No newline at end of file +}