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

Cross-compile to Scala 3 #34

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
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
Loading