diff --git a/docs/decoding.md b/docs/decoding.md index eaabaecf7..7d43c1ff9 100644 --- a/docs/decoding.md +++ b/docs/decoding.md @@ -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 diff --git a/zio-json/shared/src/main/scala-2.x/zio/json/JsonDecoderVersionSpecific.scala b/zio-json/shared/src/main/scala-2.x/zio/json/JsonDecoderVersionSpecific.scala index 1a3e5e1f3..5e8d10030 100644 --- a/zio-json/shared/src/main/scala-2.x/zio/json/JsonDecoderVersionSpecific.scala +++ b/zio-json/shared/src/main/scala-2.x/zio/json/JsonDecoderVersionSpecific.scala @@ -1,3 +1,5 @@ package zio.json private[json] trait JsonDecoderVersionSpecific + +private[json] trait DecoderLowPriorityVersionSpecific diff --git a/zio-json/shared/src/main/scala-2.x/zio/json/JsonEncoderVersionSpecific.scala b/zio-json/shared/src/main/scala-2.x/zio/json/JsonEncoderVersionSpecific.scala index f9508efa0..8b360d95d 100644 --- a/zio-json/shared/src/main/scala-2.x/zio/json/JsonEncoderVersionSpecific.scala +++ b/zio-json/shared/src/main/scala-2.x/zio/json/JsonEncoderVersionSpecific.scala @@ -1,3 +1,5 @@ package zio.json private[json] trait JsonEncoderVersionSpecific + +private[json] trait EncoderLowPriorityVersionSpecific diff --git a/zio-json/shared/src/main/scala-3/zio/json/JsonDecoderVersionSpecific.scala b/zio-json/shared/src/main/scala-3/zio/json/JsonDecoderVersionSpecific.scala index 5cb9df085..64f51eaee 100644 --- a/zio-json/shared/src/main/scala-3/zio/json/JsonDecoderVersionSpecific.scala +++ b/zio-json/shared/src/main/scala-3/zio/json/JsonDecoderVersionSpecific.scala @@ -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]) + } + ) +} diff --git a/zio-json/shared/src/main/scala-3/zio/json/JsonEncoderVersionSpecific.scala b/zio-json/shared/src/main/scala-3/zio/json/JsonEncoderVersionSpecific.scala index 09a5f91cd..97d623113 100644 --- a/zio-json/shared/src/main/scala-3/zio/json/JsonEncoderVersionSpecific.scala +++ b/zio-json/shared/src/main/scala-3/zio/json/JsonEncoderVersionSpecific.scala @@ -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] +} diff --git a/zio-json/shared/src/main/scala-3/zio/json/union_derivation.scala b/zio-json/shared/src/main/scala-3/zio/json/union_derivation.scala new file mode 100644 index 000000000..0195b89c0 --- /dev/null +++ b/zio-json/shared/src/main/scala-3/zio/json/union_derivation.scala @@ -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] } + ) diff --git a/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala b/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala index 2d4b26507..ddf45c725 100644 --- a/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala +++ b/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala @@ -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 } diff --git a/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala b/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala index cb2882b56..6b1dc40e6 100644 --- a/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala +++ b/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala @@ -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 } diff --git a/zio-json/shared/src/test/scala-3/zio/json/DerivedCodecSpec.scala b/zio-json/shared/src/test/scala-3/zio/json/DerivedCodecSpec.scala index 7a15eb5ff..84d1f2313 100644 --- a/zio-json/shared/src/test/scala-3/zio/json/DerivedCodecSpec.scala +++ b/zio-json/shared/src/test/scala-3/zio/json/DerivedCodecSpec.scala @@ -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")))) + }, ) } diff --git a/zio-json/shared/src/test/scala-3/zio/json/DerivedDecoderSpec.scala b/zio-json/shared/src/test/scala-3/zio/json/DerivedDecoderSpec.scala index a2d702e34..a1520753c 100644 --- a/zio-json/shared/src/test/scala-3/zio/json/DerivedDecoderSpec.scala +++ b/zio-json/shared/src/test/scala-3/zio/json/DerivedDecoderSpec.scala @@ -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)")) } ) } diff --git a/zio-json/shared/src/test/scala-3/zio/json/DerivedEncoderSpec.scala b/zio-json/shared/src/test/scala-3/zio/json/DerivedEncoderSpec.scala index 20fd42888..9b7fc862d 100644 --- a/zio-json/shared/src/test/scala-3/zio/json/DerivedEncoderSpec.scala +++ b/zio-json/shared/src/test/scala-3/zio/json/DerivedEncoderSpec.scala @@ -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"}""") } ) }