Skip to content

Commit

Permalink
Cross-compile generic module to Scala 3
Browse files Browse the repository at this point in the history
  • Loading branch information
Z1kkurat committed May 8, 2024
1 parent bb1ceb3 commit a29f47a
Show file tree
Hide file tree
Showing 29 changed files with 490 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ jobs:
with:
type: ${{ job.status }}
job_name: Build
url: ${{ secrets.SLACK_WEBHOOK }}
url: ${{ secrets.SLACK_WEBHOOK }}
18 changes: 16 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,4 +40,8 @@ ignore
scripts/tmp
ignored/

.java-version
.java-version

.bloop/
.metals/
.vscode
12 changes: 7 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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(_))
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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("(?<!^)(?=[A-Z])").map(_.toLowerCase).mkString(sep)
}

given kebabCase: NameCodingStrategy = lowerCaseSepCoding("-")

given snakeCase: NameCodingStrategy = lowerCaseSepCoding("_")

given noSepCase: NameCodingStrategy = new NameCodingStrategy() {
override def apply(s: String): String = s.toLowerCase
}

given upperCase: NameCodingStrategy = new NameCodingStrategy() {
override def apply(s: String): String = s.toUpperCase
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.evolution.playjson.generic

import play.api.libs.json.OFormat

object NestedTypeFormat:
def apply[A](using reads: NestedTypeReads[A], writes: NestedTypeWrites[A]): OFormat[A] =
OFormat(reads.reads(_), writes.writes(_))
Loading

0 comments on commit a29f47a

Please sign in to comment.