Skip to content

Commit

Permalink
feat: implement Scala 3 Constant and Union support for string-based l…
Browse files Browse the repository at this point in the history
…iterals as enums (#1125)

* feat: implement Scala 3 Constant and Union support for string-based literals as enums

* add JsonCodec test

* format

* add non-mdoc examples (needs Scala 3)
  • Loading branch information
ThijsBroersen authored Jun 13, 2024
1 parent df9c147 commit 608bb6e
Show file tree
Hide file tree
Showing 11 changed files with 147 additions and 3 deletions.
16 changes: 16 additions & 0 deletions docs/decoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ object Fruit {
"""{ "Apple": { "poison": false }}""".fromJson[Fruit]
```

### String-based union types (Enum)
The codecs support string-based union types (enums) out of the box. This is useful when the overhead of a Enum is not desired.

```scala
val appleOrBanana: "Apple" | "Banana" = "Apple"
```
Decoding succeeds because 'Apple' is a valid value
```scala
appleOrBanana.toJson
"Apple".fromJson["Apple" | "Banana"]
```
Decoding fail because 'Pear' is not a valid value
```scala
"Peer".fromJson["Apple" | "Banana"]
```

Almost all of the standard library data types are supported as fields on the case class, and it is easy to add support if one is missing.

## Manual instances
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
package zio.json

private[json] trait JsonDecoderVersionSpecific

private[json] trait DecoderLowPriorityVersionSpecific
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
package zio.json

private[json] trait JsonEncoderVersionSpecific

private[json] trait EncoderLowPriorityVersionSpecific
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
package zio.json

import scala.compiletime.*
import scala.compiletime.ops.any.IsConst

private[json] trait JsonDecoderVersionSpecific {
inline def derived[A: deriving.Mirror.Of]: JsonDecoder[A] = DeriveJsonDecoder.gen[A]
}

trait DecoderLowPriorityVersionSpecific {

inline given unionOfStringEnumeration[T](using IsUnionOf[String, T]): JsonDecoder[T] =
val values = UnionDerivation.constValueUnionTuple[String, T]
JsonDecoder.string.mapOrFail(
{
case raw if values.toList.contains(raw) => Right(raw.asInstanceOf[T])
case _ => Left("expected one of: " + values.toList.mkString(", "))
}
)

inline given constStringToEnum[T <: String](using IsConst[T] =:= true): JsonDecoder[T] =
JsonDecoder.string.mapOrFail(
{
case raw if raw == constValue[T] => Right(constValue[T])
case _ => Left("expected one of: " + constValue[T])
}
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
package zio.json

import scala.compiletime.ops.any.IsConst

private[json] trait JsonEncoderVersionSpecific {
inline def derived[A: deriving.Mirror.Of]: JsonEncoder[A] = DeriveJsonEncoder.gen[A]
}

private[json] trait EncoderLowPriorityVersionSpecific {

inline given unionOfStringEnumeration[T](using IsUnionOf[String, T]): JsonEncoder[T] =
JsonEncoder.string.asInstanceOf[JsonEncoder[T]]

inline given constStringToEnum[T <: String](using IsConst[T] =:= true): JsonEncoder[T] =
JsonEncoder.string.narrow[T]
}
74 changes: 74 additions & 0 deletions zio-json/shared/src/main/scala-3/zio/json/union_derivation.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package zio.json

import scala.compiletime.*
import scala.deriving.*
import scala.quoted.*

@scala.annotation.implicitNotFound("${A} is not a union type")
sealed trait IsUnion[A]

object IsUnion:

private val singleton: IsUnion[Any] = new IsUnion[Any] {}

transparent inline given derived[A]: IsUnion[A] = ${ deriveImpl[A] }

private def deriveImpl[A](using quotes: Quotes, t: Type[A]): Expr[IsUnion[A]] =
import quotes.reflect.*
val tpe: TypeRepr = TypeRepr.of[A]
tpe.dealias match
case o: OrType => ('{ IsUnion.singleton.asInstanceOf[IsUnion[A]] }).asExprOf[IsUnion[A]]
case other => report.errorAndAbort(s"${tpe.show} is not a Union")

@scala.annotation.implicitNotFound("${A} is not a union of ${T}")
sealed trait IsUnionOf[T, A]

object IsUnionOf:

private val singleton: IsUnionOf[Any, Any] = new IsUnionOf[Any, Any] {}

transparent inline given derived[T, A]: IsUnionOf[T, A] = ${ deriveImpl[T, A] }

private def deriveImpl[T, A](using quotes: Quotes, t: Type[T], a: Type[A]): Expr[IsUnionOf[T, A]] =
import quotes.reflect.*
val tpe: TypeRepr = TypeRepr.of[A]
val bound: TypeRepr = TypeRepr.of[T]

def validateTypes(tpe: TypeRepr): Unit =
tpe.dealias match
case o: OrType =>
validateTypes(o.left)
validateTypes(o.right)
case o =>
if o <:< bound then ()
else report.errorAndAbort(s"${o.show} is not a subtype of ${bound.show}")

tpe.dealias match
case o: OrType =>
validateTypes(o)
('{ IsUnionOf.singleton.asInstanceOf[IsUnionOf[T, A]] }).asExprOf[IsUnionOf[T, A]]
case other => report.errorAndAbort(s"${tpe.show} is not a Union")

object UnionDerivation:
transparent inline def constValueUnionTuple[T, A](using IsUnionOf[T, A]): Tuple = ${ constValueUnionTupleImpl[T, A] }

private def constValueUnionTupleImpl[T: Type, A: Type](using Quotes): Expr[Tuple] =
Expr.ofTupleFromSeq(constTypes[T, A])

private def constTypes[T: Type, A: Type](using Quotes): List[Expr[Any]] =
import quotes.reflect.*
val tpe: TypeRepr = TypeRepr.of[A]
val bound: TypeRepr = TypeRepr.of[T]

def transformTypes(tpe: TypeRepr): List[TypeRepr] =
tpe.dealias match
case o: OrType =>
transformTypes(o.left) ::: transformTypes(o.right)
case o: Constant if o <:< bound && o.isSingleton =>
o :: Nil
case o =>
report.errorAndAbort(s"${o.show} is not a subtype of ${bound.show}")

transformTypes(tpe).distinct.map(_.asType match
case '[t] => '{ constValue[t] }
)
2 changes: 1 addition & 1 deletion zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,6 @@ private[json] trait DecoderLowPriority3 extends DecoderLowPriority4 {
}
}

private[json] trait DecoderLowPriority4 {
private[json] trait DecoderLowPriority4 extends DecoderLowPriorityVersionSpecific {
implicit def fromCodec[A](implicit codec: JsonCodec[A]): JsonDecoder[A] = codec.decoder
}
2 changes: 1 addition & 1 deletion zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,6 @@ private[json] trait EncoderLowPriority3 extends EncoderLowPriority4 {
implicit val currency: JsonEncoder[java.util.Currency] = stringify(_.toString)
}

private[json] trait EncoderLowPriority4 {
private[json] trait EncoderLowPriority4 extends EncoderLowPriorityVersionSpecific {
implicit def fromCodec[A](implicit codec: JsonCodec[A]): JsonEncoder[A] = codec.encoder
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ object DerivedCodecSpec extends ZIOSpecDefault {
(Foo.Qux(Foo.Bar): Foo).toJson.fromJson[Foo]
"""
})(isRight(anything))
}
},
test("Derives and encodes for a union of string-based literals") {
case class Foo(aOrB: "A" | "B", optA: Option["A"]) derives JsonCodec

assertTrue(Foo("A", Some("A")).toJson.fromJson[Foo] == Right(Foo("A", Some("A"))))
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ object DerivedDecoderSpec extends ZIOSpecDefault {
"{\"Qux\":{\"foo\":{\"Bar\":{}}}}".fromJson[Foo]
"""
})(isRight(anything))
},
test("Derives and decodes for a union of string-based literals") {
case class Foo(aOrB: "A" | "B", optA: Option["A"]) derives JsonDecoder

assertTrue("""{"aOrB": "A", "optA": "A"}""".fromJson[Foo] == Right(Foo("A", Some("A")))) &&
assertTrue("""{"aOrB": "C"}""".fromJson[Foo] == Left(".aOrB(expected one of: A, B)"))
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ object DerivedEncoderSpec extends ZIOSpecDefault {
(Foo.Qux(Foo.Bar): Foo).toJson
"""
})(isRight(anything))
},
test("Derives and encodes for a union of string-based literals") {
case class Foo(aOrB: "A" | "B", optA: Option["A"]) derives JsonEncoder

assertTrue(Foo("A", Some("A")).toJson == """{"aOrB":"A","optA":"A"}""")
}
)
}

0 comments on commit 608bb6e

Please sign in to comment.