Skip to content

Commit

Permalink
feat: implement JsonCodecConfiguration for scala 3
Browse files Browse the repository at this point in the history
feat: implement explicitEmptyCollections for JsonCodecConfiguration
  • Loading branch information
ThijsBroersen committed Nov 28, 2024
1 parent b7795b8 commit 793367e
Show file tree
Hide file tree
Showing 9 changed files with 268 additions and 105 deletions.
71 changes: 14 additions & 57 deletions zio-json/shared/src/main/scala-2.x/zio/json/macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package zio.json

import magnolia1._
import zio.Chunk
import zio.json.JsonCodecConfiguration.SumTypeHandling
import zio.json.JsonCodecConfiguration.SumTypeHandling.WrapperWithClassNameField
import zio.json.JsonDecoder.{ JsonError, UnsafeJson }
import zio.json.ast.Json
import zio.json.internal.{ Lexer, RetractReader, StringMatrix, Write }
Expand All @@ -22,7 +20,8 @@ final case class jsonField(name: String) extends Annotation
*/
final case class jsonAliases(alias: String, aliases: String*) extends Annotation

final class jsonExplicitNull extends Annotation
final class jsonExplicitNull extends Annotation
final class jsonExplicitEmptyCollection extends Annotation

/**
* If used on a sealed class, will determine the name of the field for
Expand Down Expand Up @@ -201,59 +200,6 @@ final class jsonNoExtraFields extends Annotation
*/
final class jsonExclude extends Annotation

// TODO: implement same configuration for Scala 3 once this issue is resolved: https://github.com/softwaremill/magnolia/issues/296
/**
* Implicit codec derivation configuration.
*
* @param sumTypeHandling see [[jsonDiscriminator]]
* @param fieldNameMapping see [[jsonMemberNames]]
* @param allowExtraFields see [[jsonNoExtraFields]]
* @param sumTypeMapping see [[jsonHintNames]]
*/
final case class JsonCodecConfiguration(
sumTypeHandling: SumTypeHandling = WrapperWithClassNameField,
fieldNameMapping: JsonMemberFormat = IdentityFormat,
allowExtraFields: Boolean = true,
sumTypeMapping: JsonMemberFormat = IdentityFormat,
explicitNulls: Boolean = false
)

object JsonCodecConfiguration {
implicit val default: JsonCodecConfiguration = JsonCodecConfiguration()

sealed trait SumTypeHandling {
def discriminatorField: Option[String]
}

object SumTypeHandling {

/**
* Use an object with a single key that is the class name.
*/
case object WrapperWithClassNameField extends SumTypeHandling {
override def discriminatorField: Option[String] = None
}

/**
* For sealed classes, will determine the name of the field for
* disambiguating classes.
*
* The default is to not use a typehint field and instead
* have an object with a single key that is the class name.
* See [[WrapperWithClassNameField]].
*
* Note that using a discriminator is less performant, uses more memory, and may
* be prone to DOS attacks that are impossible with the default encoding. In
* addition, there is slightly less type safety when using custom product
* encoders (which must write an unenforced object type). Only use this option
* if you must model an externally defined schema.
*/
final case class DiscriminatorField(name: String) extends SumTypeHandling {
override def discriminatorField: Option[String] = Some(name)
}
}
}

object DeriveJsonDecoder {
type Typeclass[A] = JsonDecoder[A]

Expand Down Expand Up @@ -561,8 +507,14 @@ object DeriveJsonEncoder {
val explicitNulls: Boolean =
config.explicitNulls || ctx.annotations.exists(_.isInstanceOf[jsonExplicitNull])

val explicitEmptyCollections: Boolean =
config.explicitEmptyCollections || ctx.annotations.exists(_.isInstanceOf[jsonExplicitEmptyCollection])

lazy val tcs: Array[JsonEncoder[Any]] = params.map(p => p.typeclass.asInstanceOf[JsonEncoder[Any]])
val len: Int = params.length

override def isEmpty(a: A): Boolean = params.forall(p => p.typeclass.isEmpty(p.dereference(a)))

def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = {
var i = 0
out.write("{")
Expand All @@ -574,7 +526,12 @@ object DeriveJsonEncoder {
val tc = tcs(i)
val p = params(i).dereference(a)
val writeNulls = explicitNulls || params(i).annotations.exists(_.isInstanceOf[jsonExplicitNull])
if (!tc.isNothing(p) || writeNulls) {
val writeEmptyCollections =
explicitEmptyCollections || params(i).annotations.exists(_.isInstanceOf[jsonExplicitEmptyCollection])
if (
(!tc.isNothing(p) && !tc.isEmpty(p)) || (tc
.isNothing(p) && writeNulls) || (tc.isEmpty(p) && writeEmptyCollections)
) {
// if we have at least one field already, we need a comma
if (prevFields) {
if (indent.isEmpty) out.write(",")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package zio.json

private[json] trait JsonCodecVersionSpecific {
inline def derived[A: deriving.Mirror.Of]: JsonCodec[A] = DeriveJsonCodec.gen[A]
inline def derived[A: deriving.Mirror.Of](using config: JsonCodecConfiguration): JsonCodec[A] = DeriveJsonCodec.gen[A]

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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]
inline def derived[A: deriving.Mirror.Of](using config: JsonCodecConfiguration): JsonDecoder[A] = DeriveJsonDecoder.gen[A]
}

trait DecoderLowPriorityVersionSpecific {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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]
inline def derived[A: deriving.Mirror.Of](using config: JsonCodecConfiguration): JsonEncoder[A] = DeriveJsonEncoder.gen[A]
}

private[json] trait EncoderLowPriorityVersionSpecific {
Expand Down
60 changes: 42 additions & 18 deletions zio-json/shared/src/main/scala-3/zio/json/macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ final case class jsonAliases(alias: String, aliases: String*) extends Annotation
*/
final class jsonExplicitNull extends Annotation

/**
* Empty collections will be encoded as `null`.
*/
final class jsonExplicitEmptyCollection extends Annotation

/**
* If used on a sealed class, will determine the name of the field for
* disambiguating classes.
Expand Down Expand Up @@ -225,19 +230,20 @@ private class CaseObjectDecoder[Typeclass[*], A](val ctx: CaseClass[Typeclass, A
case _ => throw UnsafeJson(JsonError.Message("Not an object") :: trace)
}
}

// TODO: implement same configuration as for Scala 2 once this issue is resolved: https://github.com/softwaremill/magnolia/issues/296
object DeriveJsonDecoder extends Derivation[JsonDecoder] { self =>

final class JsonDecoderDerivation(config: JsonCodecConfiguration) extends Derivation[JsonDecoder] { self =>
def join[A](ctx: CaseClass[Typeclass, A]): JsonDecoder[A] = {
val (transformNames, nameTransform): (Boolean, String => String) =
ctx.annotations.collectFirst { case jsonMemberNames(format) => format }
.orElse(Some(config.fieldNameMapping))
.filter(_ != IdentityFormat)
.map(true -> _)
.getOrElse(false -> identity)

val no_extra = ctx
.annotations
.collectFirst { case _: jsonNoExtraFields => () }
.isDefined
.isDefined || !config.allowExtraFields

if (ctx.params.isEmpty) {
new CaseObjectDecoder(ctx, no_extra)
Expand Down Expand Up @@ -387,7 +393,7 @@ object DeriveJsonDecoder extends Derivation[JsonDecoder] { self =>

def split[A](ctx: SealedTrait[JsonDecoder, A]): JsonDecoder[A] = {
val jsonHintFormat: JsonMemberFormat =
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(IdentityFormat)
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(config.sumTypeMapping)
val names: Array[String] = IArray.genericWrapArray(ctx.subtypes.map { p =>
p.annotations.collectFirst { case jsonHint(name) =>
name
Expand All @@ -407,7 +413,7 @@ object DeriveJsonDecoder extends Derivation[JsonDecoder] { self =>
!ctx.isEnum && ctx.subtypes.forall(_.isObject)
)

def discrim = ctx.annotations.collectFirst { case jsonDiscriminator(n) => n }
def discrim = ctx.annotations.collectFirst { case jsonDiscriminator(n) => n }.orElse(config.sumTypeHandling.discriminatorField)

if (isEnumeration && discrim.isEmpty) {
new JsonDecoder[A] {
Expand Down Expand Up @@ -542,14 +548,23 @@ private lazy val caseObjectEncoder = new JsonEncoder[Any] {
Right(Json.Obj(Chunk.empty))
}

object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
def join[A](ctx: CaseClass[Typeclass, A]): JsonEncoder[A] =
object DeriveJsonDecoder {
inline def gen[A](using config: JsonCodecConfiguration, mirror: Mirror.Of[A]) = {
val derivation = new JsonDecoderDerivation(config)
derivation.derived[A]
}
}

final class JsonEncoderDerivation(config: JsonCodecConfiguration) extends Derivation[JsonEncoder] { self =>
def join[A](ctx: CaseClass[Typeclass, A]): JsonEncoder[A] =
if (ctx.params.isEmpty) {
caseObjectEncoder.narrow[A]
} else {
new JsonEncoder[A] {
val (transformNames, nameTransform): (Boolean, String => String) =
ctx.annotations.collectFirst { case jsonMemberNames(format) => format }
.orElse(Some(config.fieldNameMapping))
.filter(_ != IdentityFormat)
.map(true -> _)
.getOrElse(false -> identity)

Expand All @@ -575,7 +590,8 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
})
.toArray

val explicitNulls = ctx.annotations.exists(_.isInstanceOf[jsonExplicitNull])
val explicitNulls = config.explicitNulls || ctx.annotations.exists(_.isInstanceOf[jsonExplicitNull])
val explicitEmptyCollections = config.explicitEmptyCollections || ctx.annotations.exists(_.isInstanceOf[jsonExplicitEmptyCollection])

lazy val tcs: Array[JsonEncoder[Any]] =
IArray.genericWrapArray(params.map(_.typeclass.asInstanceOf[JsonEncoder[Any]])).toArray
Expand All @@ -593,7 +609,11 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
val tc = tcs(i)
val p = params(i).deref(a)
val writeNulls = explicitNulls || params(i).annotations.exists(_.isInstanceOf[jsonExplicitNull])
if (! tc.isNothing(p) || writeNulls) {
val writeEmptyCollections = explicitEmptyCollections || params(i).annotations.exists(_.isInstanceOf[jsonExplicitEmptyCollection])
if (
(!tc.isNothing(p) && !tc.isEmpty(p)) || (tc
.isNothing(p) && writeNulls) || (tc.isEmpty(p) && writeEmptyCollections)
) {
// if we have at least one field already, we need a comma
if (prevFields) {
if (indent.isEmpty) {
Expand Down Expand Up @@ -641,20 +661,20 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
}
}

def split[A](ctx: SealedTrait[JsonEncoder, A]): JsonEncoder[A] = {
def split[A](ctx: SealedTrait[JsonEncoder, A]): JsonEncoder[A] = {
val isEnumeration =
(ctx.isEnum && ctx.subtypes.forall(_.typeclass == caseObjectEncoder)) || (
!ctx.isEnum && ctx.subtypes.forall(_.isObject)
)

val jsonHintFormat: JsonMemberFormat =
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(IdentityFormat)
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(config.sumTypeMapping)

val discrim = ctx
.annotations
.collectFirst {
case jsonDiscriminator(n) => n
}
}.orElse(config.sumTypeHandling.discriminatorField)

if (isEnumeration && discrim.isEmpty) {
new JsonEncoder[A] {
Expand Down Expand Up @@ -750,7 +770,7 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
JsonEncoder.string.unsafeEncode(getName(sub.annotations, sub.typeInfo.short), indent_, out)

// whitespace is always off by 2 spaces at the end, probably not worth fixing
val intermediate = new NestedWriter(out, indent_)
val intermediate = new DeriveJsonEncoder.NestedWriter(out, indent_)
sub.typeclass.unsafeEncode(sub.cast(a), indent, intermediate)
}
}
Expand All @@ -766,16 +786,20 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
}
}
}
}

inline def gen[A](using mirror: Mirror.Of[A]) = self.derived[A]
object DeriveJsonEncoder {
inline def gen[A](using config: JsonCodecConfiguration, mirror: Mirror.Of[A]) = {
val derivation = new JsonEncoderDerivation(config)
derivation.derived[A]
}

// intercepts the first `{` of a nested writer and discards it. We also need to
// inject a `,` unless an empty object `{}` has been written.
private[this] final class NestedWriter(out: Write, indent: Option[Int]) extends Write {
private[json] final class NestedWriter(out: Write, indent: Option[Int]) extends Write {
private[this] var first, second = true

def write(c: Char): Unit = write(c.toString) // could be optimised

def write(s: String): Unit =
if (first || second) {
var i = 0
Expand All @@ -798,7 +822,7 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
}

object DeriveJsonCodec {
inline def gen[A](using mirror: Mirror.Of[A]) = {
inline def gen[A](using mirror: Mirror.Of[A], config: JsonCodecConfiguration) = {
val encoder = DeriveJsonEncoder.gen[A]
val decoder = DeriveJsonDecoder.gen[A]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package zio.json

import zio.json.JsonCodecConfiguration.SumTypeHandling
import zio.json.JsonCodecConfiguration.SumTypeHandling.WrapperWithClassNameField

/**
* Implicit codec derivation configuration.
*
* @param sumTypeHandling see [[jsonDiscriminator]]
* @param fieldNameMapping see [[jsonMemberNames]]
* @param allowExtraFields see [[jsonNoExtraFields]]
* @param sumTypeMapping see [[jsonHintNames]]
*/
final case class JsonCodecConfiguration(
sumTypeHandling: SumTypeHandling = WrapperWithClassNameField,
fieldNameMapping: JsonMemberFormat = IdentityFormat,
allowExtraFields: Boolean = true,
sumTypeMapping: JsonMemberFormat = IdentityFormat,
explicitNulls: Boolean = false,
explicitEmptyCollections: Boolean = true
)

object JsonCodecConfiguration {
implicit val default: JsonCodecConfiguration = JsonCodecConfiguration()

sealed trait SumTypeHandling {
def discriminatorField: Option[String]
}

object SumTypeHandling {

/**
* Use an object with a single key that is the class name.
*/
case object WrapperWithClassNameField extends SumTypeHandling {
override def discriminatorField: Option[String] = None
}

/**
* For sealed classes, will determine the name of the field for
* disambiguating classes.
*
* The default is to not use a typehint field and instead
* have an object with a single key that is the class name.
* See [[WrapperWithClassNameField]].
*
* Note that using a discriminator is less performant, uses more memory, and may
* be prone to DOS attacks that are impossible with the default encoding. In
* addition, there is slightly less type safety when using custom product
* encoders (which must write an unenforced object type). Only use this option
* if you must model an externally defined schema.
*/
final case class DiscriminatorField(name: String) extends SumTypeHandling {
override def discriminatorField: Option[String] = Some(name)
}
}
}
Loading

0 comments on commit 793367e

Please sign in to comment.