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 index 5a039a7..732b313 100644 --- 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 @@ -7,6 +7,9 @@ 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/FlatTypeFormat.scala b/play-json-generic/src/main/scala-3/com/evolution/playjson/generic/FlatTypeFormat.scala index 5c817ea..77ff67b 100644 --- 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 @@ -2,6 +2,15 @@ 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 _) \ No newline at end of file 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 index 7b3441f..a5b1910 100644 --- 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 @@ -5,6 +5,30 @@ 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. + * + * 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] @@ -14,6 +38,11 @@ object FlatTypeReads: 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 @@ -22,25 +51,30 @@ object FlatTypeReads: for { obj <- json.validate[JsObject] typ <- (obj \ "type").validate[String] - result <- deriveReads[A](obj, typ) match + 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]( - json: JsObject, typ: String - )(using nameCodingStrategy: NameCodingStrategy): Option[Reads[A]] = { + )(using nameCodingStrategy: NameCodingStrategy): Option[Reads[A]] = inline erasedValue[T] match case _: EmptyTuple => None case _: (h *: t) => - deriveReads[h](json, typ) match - case None => deriveReadsForSum[A, t](json, typ) + deriveReads[h](typ) match + case None => deriveReadsForSum[A, t](typ) case Some(value) => Some(value.asInstanceOf[Reads[A]]) - } - private inline def deriveReads[A](json: JsObject, typ: String)(using nameCodingStrategy: NameCodingStrategy): Option[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) @@ -50,7 +84,7 @@ object FlatTypeReads: else None case m: Mirror.SumOf[A] => // sum (trait) - deriveReadsForSum[A, m.MirroredElemTypes](json, typ) + deriveReadsForSum[A, m.MirroredElemTypes](typ) case v: ValueOf[A] => // Singleton type (object without `case` modifier) val name = singletonName[A] 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 index 1737a3d..387a6d8 100644 --- 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 @@ -5,6 +5,27 @@ 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 @@ -17,11 +38,16 @@ object FlatTypeWrites: 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[?]] =