Skip to content

Commit

Permalink
feat: explicitEmptyCollections V2, for more json (or less) (#1239)
Browse files Browse the repository at this point in the history
  • Loading branch information
ThijsBroersen authored Jan 26, 2025
1 parent 511ef7f commit 8cde6d6
Show file tree
Hide file tree
Showing 10 changed files with 1,862 additions and 410 deletions.
37 changes: 37 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,43 @@ The following two expressions result in an equal value:

The `@jsonAliases` annotation supports multiple aliases. The annotation has no effect on encoding.

## Nulls, explicitNulls

By default `null` values are omitted from the JSON output. This behavior can be changed by using the `@jsonExplicitNull` annotation on a case class, field or setting `JsonCodecConfiguration.explicitNulls` to `true`.
Missing nulls on decoding are always allowed.

```scala mdoc
@jsonExplicitNull
case class Mango(ripeness: Option[Int])

object Mango {
implicit val codec: JsonCodec[Mango] = DeriveJsonCodec.gen[Mango]
}
```
The following expression results in a JSON document with a `null` value:
```scala mdoc
Mango(None).toJson
"""{}""".fromJson[Mango]
```

## Empty Collections, explicitEmptyCollections

By default `empty collections` (all supported collection types and case classes) are included from the JSON output an decoding requires empty collections to be present. This behavior can be changed by using the `@jsonExplicitEmptyCollections(encoding = false, decoding = false)` annotation on a case class, field or setting `JsonCodecConfiguration.explicitEmptyCollections` to `ExplicitEmptyCollections(encoding = false, decoding = false)`. The result is that empty collections are omitted from the JSON output and when decoding empty collections are created. It is also possible to have different values for encoding and decoding by using `@jsonExplicitEmptyCollections(encoding = true, decoding = false)` or `@jsonExplicitEmptyCollections(encoding = false, decoding = true)`.

```scala mdoc
@jsonExplicitEmptyCollections(encoding = false, decoding = false)
case class Pineapple(leaves: List[String])

object Pineapple {
implicit val codec: JsonCodec[Pineapple] = DeriveJsonCodec.gen[Pineapple]
}
```
The following expression results in a JSON document with an empty collection:
```scala mdoc
Pineapple(Nil).toJson
"""{}""".fromJson[Pineapple]
```

## @jsonDerive

**Requires zio-json-macros**
Expand Down
151 changes: 104 additions & 47 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 @@ -4,7 +4,7 @@ import magnolia1._
import zio.Chunk
import zio.json.JsonDecoder.JsonError
import zio.json.ast.Json
import zio.json.internal.{ Lexer, RetractReader, StringMatrix, Write }
import zio.json.internal.{ FieldEncoder, Lexer, RetractReader, StringMatrix, Write }

import scala.annotation._
import scala.language.experimental.macros
Expand All @@ -24,6 +24,11 @@ final case class jsonAliases(alias: String, aliases: String*) extends Annotation
*/
final class jsonExplicitNull extends Annotation

/**
* When disabled keys with empty collections will be omitted from the JSON.
*/
final case class jsonExplicitEmptyCollections(encoding: Boolean = true, decoding: Boolean = true) extends Annotation

/**
* If used on a sealed class, will determine the name of the field for disambiguating classes.
*
Expand Down Expand Up @@ -211,7 +216,8 @@ object DeriveJsonDecoder {
}.isDefined || !config.allowExtraFields

if (ctx.parameters.isEmpty)
new JsonDecoder[A] {
new CollectionJsonDecoder[A] {
override def unsafeDecodeMissing(trace: List[JsonError]): A = ctx.rawConstruct(Nil)

def unsafeDecode(trace: List[JsonError], in: RetractReader): A = {
if (no_extra) {
Expand All @@ -231,7 +237,7 @@ object DeriveJsonDecoder {
}
}
else
new JsonDecoder[A] {
new CollectionJsonDecoder[A] {
private[this] val (names, aliases): (Array[String], Array[(String, Int)]) = {
val names = new Array[String](ctx.parameters.size)
val aliasesBuilder = Array.newBuilder[(String, Int)]
Expand Down Expand Up @@ -262,11 +268,55 @@ object DeriveJsonDecoder {
private[this] val len = names.length
private[this] val matrix = new StringMatrix(names, aliases)
private[this] val spans = names.map(JsonError.ObjectAccess)
private[this] val defaults = ctx.parameters.map(_.evaluateDefault).toArray
private[this] val defaults = ctx.parameters.map(_.evaluateDefault.orNull).toArray
private[this] lazy val tcs =
ctx.parameters.map(_.typeclass).toArray.asInstanceOf[Array[JsonDecoder[Any]]]
private[this] lazy val namesMap = (names.zipWithIndex ++ aliases).toMap

private[this] val explicitEmptyCollections =
ctx.annotations.collectFirst { case a: jsonExplicitEmptyCollections =>
a.decoding
}.getOrElse(config.explicitEmptyCollections.decoding)

private[this] val missingValueDecoder =
if (explicitEmptyCollections) {
lazy val missingValueDecoders = tcs.map { d =>
if (allowMissingValueDecoder(d)) d
else null
}
(idx: Int, trace: List[JsonError]) => {
val trace_ = spans(idx) :: trace
val decoder = missingValueDecoders(idx)
if (decoder eq null) Lexer.error("missing", trace_)
decoder.unsafeDecodeMissing(trace_)
}
} else { (idx: Int, trace: List[JsonError]) =>
tcs(idx).unsafeDecodeMissing(spans(idx) :: trace)
}

@tailrec
private[this] def allowMissingValueDecoder(d: JsonDecoder[_]): Boolean = d match {
case _: OptionJsonDecoder[_] => true
case _: CollectionJsonDecoder[_] => !explicitEmptyCollections
case d: MappedJsonDecoder[_] => allowMissingValueDecoder(d.underlying)
case _ => false
}

override def unsafeDecodeMissing(trace: List[JsonError]): A = {
val ps = new Array[Any](len)
var idx = 0
while (idx < len) {
if (ps(idx) == null) {
val default = defaults(idx)
ps(idx) =
if (default ne null) default()
else missingValueDecoder(idx, trace)
}
idx += 1
}
ctx.rawConstruct(new ArraySeq(ps))
}

override def unsafeDecode(trace: List[JsonError], in: RetractReader): A = {
Lexer.char(trace, in, '{')

Expand All @@ -286,12 +336,12 @@ object DeriveJsonDecoder {
val default = defaults(idx)
ps(idx) =
if (
(default eq None) || in.nextNonWhitespace() != 'n' && {
(default eq null) || in.nextNonWhitespace() != 'n' && {
in.retract()
true
}
) tcs(idx).unsafeDecode(spans(idx) :: trace, in)
else if (in.readChar() == 'u' && in.readChar() == 'l' && in.readChar() == 'l') default.get()
else if (in.readChar() == 'u' && in.readChar() == 'l' && in.readChar() == 'l') default()
else Lexer.error("expected 'null'", spans(idx) :: trace)
} else if (no_extra) Lexer.error("invalid extra field", trace)
else Lexer.skipValue(trace, in)
Expand All @@ -301,8 +351,8 @@ object DeriveJsonDecoder {
if (ps(idx) == null) {
val default = defaults(idx)
ps(idx) =
if (default ne None) default.get()
else tcs(idx).unsafeDecodeMissing(spans(idx) :: trace)
if (default ne null) default()
else missingValueDecoder(idx, trace)
}
idx += 1
}
Expand All @@ -320,7 +370,7 @@ object DeriveJsonDecoder {
if (ps(idx) != null) Lexer.error("duplicate", trace)
val default = defaults(idx)
ps(idx) =
if ((default ne None) && (value eq Json.Null)) default.get()
if ((default ne null) && (value eq Json.Null)) default()
else tcs(idx).unsafeFromJsonAST(spans(idx) :: trace, value)
case _ =>
if (no_extra) Lexer.error("invalid extra field", trace)
Expand All @@ -331,8 +381,8 @@ object DeriveJsonDecoder {
if (ps(idx) == null) {
val default = defaults(idx)
ps(idx) =
if (default ne None) default.get()
else tcs(idx).unsafeDecodeMissing(spans(idx) :: trace)
if (default ne null) default()
else missingValueDecoder(idx, trace)
}
idx += 1
}
Expand Down Expand Up @@ -433,6 +483,8 @@ object DeriveJsonEncoder {
if (ctx.parameters.isEmpty)
new JsonEncoder[A] {

override def isEmpty(a: A): Boolean = true

def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = out.write("{}")

override final def toJsonAST(a: A): Either[String, Json] =
Expand All @@ -449,25 +501,34 @@ object DeriveJsonEncoder {
private[this] val params = ctx.parameters
.filter(p => p.annotations.collectFirst { case _: jsonExclude => () }.isEmpty)
.toArray
private[this] val names =
params.map { p =>
p.annotations.collectFirst { case jsonField(name) =>
name
}.getOrElse(if (transformNames) nameTransform(p.label) else p.label)
}

private[this] val explicitNulls =
config.explicitNulls || ctx.annotations.exists(_.isInstanceOf[jsonExplicitNull])
private[this] lazy val fields = params.map {
var idx = 0
p =>
val field = (
p,
names(idx),
p.typeclass.asInstanceOf[JsonEncoder[Any]],
explicitNulls || p.annotations.exists(_.isInstanceOf[jsonExplicitNull])
)
idx += 1
field
private[this] val explicitEmptyCollections =
ctx.annotations.collectFirst { case a: jsonExplicitEmptyCollections =>
a.encoding
}.getOrElse(config.explicitEmptyCollections.encoding)

private[this] lazy val fields: Array[FieldEncoder[Any, Param[JsonEncoder, A]]] = params.map { p =>
val name = p.annotations.collectFirst { case jsonField(name) =>
name
}.getOrElse(if (transformNames) nameTransform(p.label) else p.label)
val withExplicitNulls = explicitNulls || p.annotations.exists(_.isInstanceOf[jsonExplicitNull])
val withExplicitEmptyCollections = p.annotations.collectFirst { case a: jsonExplicitEmptyCollections =>
a.encoding
}.getOrElse(explicitEmptyCollections)
new FieldEncoder(
p,
name,
p.typeclass.asInstanceOf[JsonEncoder[Any]],
withExplicitNulls = withExplicitNulls,
withExplicitEmptyCollections = withExplicitEmptyCollections
)
}

override def isEmpty(a: A): Boolean = fields.forall { field =>
val paramValue = field.p.dereference(a)
field.encoder.isEmpty(paramValue) || field.encoder.isNothing(paramValue)
}

def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = {
Expand All @@ -479,20 +540,17 @@ object DeriveJsonEncoder {
var prevFields = false // whether any fields have been written
while (idx < fields.length) {
val field = fields(idx)
val p = field._1.dereference(a)
if ({
val isNothing = field._3.isNothing(p)
!isNothing || field._4
}) {
val p = field.p.dereference(a)
field.encodeOrSkip(p) { () =>
// if we have at least one field already, we need a comma
if (prevFields) {
out.write(',')
JsonEncoder.pad(indent_, out)
}
JsonEncoder.string.unsafeEncode(field._2, indent_, out)
JsonEncoder.string.unsafeEncode(field.name, indent_, out)
if (indent.isEmpty) out.write(':')
else out.write(" : ")
field._3.unsafeEncode(p, indent_, out)
field.encoder.unsafeEncode(p, indent_, out)
prevFields = true // record that we have at least one field so far
}
idx += 1
Expand All @@ -502,18 +560,17 @@ object DeriveJsonEncoder {
}

override final def toJsonAST(a: A): Either[String, Json] =
ctx.parameters
.foldLeft[Either[String, Chunk[(String, Json)]]](Right(Chunk.empty)) { case (c, param) =>
val name = param.annotations.collectFirst { case jsonField(name) =>
name
}.getOrElse(nameTransform(param.label))
val writeNulls = explicitNulls || param.annotations.exists(_.isInstanceOf[jsonExplicitNull])
c.flatMap { chunk =>
param.typeclass.toJsonAST(param.dereference(a)).map { value =>
if (!writeNulls && value == Json.Null) chunk
else chunk :+ name -> value
}
}
fields
.foldLeft[Either[String, Chunk[(String, Json)]]](Right(Chunk.empty)) { case (c, field) =>
val param = field.p
val paramValue = field.p.dereference(a).asInstanceOf[param.PType]
field.encodeOrDefault(paramValue)(
() =>
c.flatMap { chunk =>
param.typeclass.toJsonAST(paramValue).map(value => chunk :+ field.name -> value)
},
c
)
}
.map(Json.Obj.apply)
}
Expand Down
Loading

0 comments on commit 8cde6d6

Please sign in to comment.