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

feat: implement JsonCodecConfiguration for scala 3 and explicitEmptyCollections for JsonCodecConfiguration #1193

Merged
Merged
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
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])
Copy link
Contributor Author

@ThijsBroersen ThijsBroersen Nov 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see some inconsistencies on the priority of JsonCodecConfiguration vs annotations, sometimes the config take priority (e.g. explicit nulls), sometimes the annotations (e.g. discriminator).
For the new config I chose to use the same pattern as explicitNulls as it also has to do with missing values. But I think it would make more sense if annotations take priority. The config first is nicer because it can overwrite conditionally.


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
Loading