From 793367ea0df5a8ca91d49c985a4034970f1d2e62 Mon Sep 17 00:00:00 2001 From: Thijs Broersen Date: Thu, 28 Nov 2024 22:52:37 +0100 Subject: [PATCH 1/3] feat: implement JsonCodecConfiguration for scala 3 feat: implement explicitEmptyCollections for JsonCodecConfiguration --- .../src/main/scala-2.x/zio/json/macros.scala | 71 +++--------- .../zio/json/JsonCodecVersionSpecific.scala | 2 +- .../zio/json/JsonDecoderVersionSpecific.scala | 2 +- .../zio/json/JsonEncoderVersionSpecific.scala | 2 +- .../src/main/scala-3/zio/json/macros.scala | 60 +++++++---- .../zio/json/JsonCodecConfiguration.scala | 57 ++++++++++ .../src/main/scala/zio/json/JsonDecoder.scala | 48 +++++++-- .../src/main/scala/zio/json/JsonEncoder.scala | 101 ++++++++++++++---- .../json/ConfigurableDeriveCodecSpec.scala | 30 ++++++ 9 files changed, 268 insertions(+), 105 deletions(-) create mode 100644 zio-json/shared/src/main/scala/zio/json/JsonCodecConfiguration.scala rename zio-json/shared/src/test/{scala-2.x => scala}/zio/json/ConfigurableDeriveCodecSpec.scala (87%) diff --git a/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala b/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala index 42fffbb9..59b31d30 100644 --- a/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala +++ b/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala @@ -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 } @@ -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 @@ -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] @@ -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("{") @@ -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(",") diff --git a/zio-json/shared/src/main/scala-3/zio/json/JsonCodecVersionSpecific.scala b/zio-json/shared/src/main/scala-3/zio/json/JsonCodecVersionSpecific.scala index 4d309c80..f9c180f0 100644 --- a/zio-json/shared/src/main/scala-3/zio/json/JsonCodecVersionSpecific.scala +++ b/zio-json/shared/src/main/scala-3/zio/json/JsonCodecVersionSpecific.scala @@ -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] } 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 ad020b00..30bc02f1 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 @@ -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 { 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 17f501c4..f0d14cf2 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 @@ -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 { diff --git a/zio-json/shared/src/main/scala-3/zio/json/macros.scala b/zio-json/shared/src/main/scala-3/zio/json/macros.scala index f5f04c4c..79b8cf3b 100644 --- a/zio-json/shared/src/main/scala-3/zio/json/macros.scala +++ b/zio-json/shared/src/main/scala-3/zio/json/macros.scala @@ -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. @@ -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) @@ -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 @@ -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] { @@ -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) @@ -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 @@ -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) { @@ -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] { @@ -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) } } @@ -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 @@ -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] diff --git a/zio-json/shared/src/main/scala/zio/json/JsonCodecConfiguration.scala b/zio-json/shared/src/main/scala/zio/json/JsonCodecConfiguration.scala new file mode 100644 index 00000000..f6b31f68 --- /dev/null +++ b/zio-json/shared/src/main/scala/zio/json/JsonCodecConfiguration.scala @@ -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) + } + } +} 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 5be8a54e..b5837b14 100644 --- a/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala +++ b/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala @@ -563,44 +563,80 @@ private[json] trait DecoderLowPriority1 extends DecoderLowPriority2 { builder(trace, in, immutable.HashSet.newBuilder[A]) } - implicit def map[K: JsonFieldDecoder, V: JsonDecoder]: JsonDecoder[Map[K, V]] = + implicit def map[K: JsonFieldDecoder, V: JsonDecoder](implicit + config: JsonCodecConfiguration + ): JsonDecoder[Map[K, V]] = new JsonDecoder[Map[K, V]] { + override def unsafeDecodeMissing(trace: List[JsonError]): Map[K, V] = + if (!config.explicitEmptyCollections) Map.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): Map[K, V] = keyValueBuilder(trace, in, Map.newBuilder[K, V]) } - implicit def hashMap[K: JsonFieldDecoder, V: JsonDecoder]: JsonDecoder[immutable.HashMap[K, V]] = + implicit def hashMap[K: JsonFieldDecoder, V: JsonDecoder](implicit + config: JsonCodecConfiguration + ): JsonDecoder[immutable.HashMap[K, V]] = new JsonDecoder[immutable.HashMap[K, V]] { + override def unsafeDecodeMissing(trace: List[JsonError]): immutable.HashMap[K, V] = + if (!config.explicitEmptyCollections) immutable.HashMap.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): immutable.HashMap[K, V] = keyValueBuilder(trace, in, immutable.HashMap.newBuilder[K, V]) } - implicit def mutableMap[K: JsonFieldDecoder, V: JsonDecoder]: JsonDecoder[mutable.Map[K, V]] = + implicit def mutableMap[K: JsonFieldDecoder, V: JsonDecoder](implicit + config: JsonCodecConfiguration + ): JsonDecoder[mutable.Map[K, V]] = new JsonDecoder[mutable.Map[K, V]] { + override def unsafeDecodeMissing(trace: List[JsonError]): mutable.Map[K, V] = + if (!config.explicitEmptyCollections) mutable.Map.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): mutable.Map[K, V] = keyValueBuilder(trace, in, mutable.Map.newBuilder[K, V]) } - implicit def sortedSet[A: Ordering: JsonDecoder]: JsonDecoder[immutable.SortedSet[A]] = + implicit def sortedSet[A: Ordering: JsonDecoder](implicit + config: JsonCodecConfiguration + ): JsonDecoder[immutable.SortedSet[A]] = new JsonDecoder[immutable.SortedSet[A]] { + override def unsafeDecodeMissing(trace: List[JsonError]): immutable.SortedSet[A] = + if (!config.explicitEmptyCollections) immutable.SortedSet.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): immutable.SortedSet[A] = builder(trace, in, immutable.SortedSet.newBuilder[A]) } - implicit def sortedMap[K: JsonFieldDecoder: Ordering, V: JsonDecoder]: JsonDecoder[collection.SortedMap[K, V]] = + implicit def sortedMap[K: JsonFieldDecoder: Ordering, V: JsonDecoder](implicit + config: JsonCodecConfiguration + ): JsonDecoder[collection.SortedMap[K, V]] = new JsonDecoder[collection.SortedMap[K, V]] { + override def unsafeDecodeMissing(trace: List[JsonError]): collection.SortedMap[K, V] = + if (!config.explicitEmptyCollections) collection.SortedMap.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): collection.SortedMap[K, V] = keyValueBuilder(trace, in, collection.SortedMap.newBuilder[K, V]) } - implicit def listMap[K: JsonFieldDecoder, V: JsonDecoder]: JsonDecoder[immutable.ListMap[K, V]] = + implicit def listMap[K: JsonFieldDecoder, V: JsonDecoder](implicit + config: JsonCodecConfiguration + ): JsonDecoder[immutable.ListMap[K, V]] = new JsonDecoder[immutable.ListMap[K, V]] { + override def unsafeDecodeMissing(trace: List[JsonError]): immutable.ListMap[K, V] = + if (!config.explicitEmptyCollections) immutable.ListMap.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): immutable.ListMap[K, V] = keyValueBuilder(trace, in, immutable.ListMap.newBuilder[K, V]) } 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 7098a1b8..7697bfc4 100644 --- a/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala +++ b/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala @@ -39,6 +39,8 @@ trait JsonEncoder[A] extends JsonEncoderPlatformSpecific[A] { override def isNothing(b: B): Boolean = self.isNothing(f(b)) + override def isEmpty(b: B): Boolean = self.isEmpty(f(b)) + override final def toJsonAST(b: B): Either[String, Json] = self.toJsonAST(f(b)) } @@ -82,6 +84,12 @@ trait JsonEncoder[A] extends JsonEncoderPlatformSpecific[A] { */ def isNothing(a: A): Boolean = false + /** + * This default may be overridden when this value may be empty within a JSON object and still + * be encoded. + */ + def isEmpty(a: A): Boolean = false + /** * Returns this encoder but narrowed to the its given sub-type */ @@ -187,6 +195,8 @@ object JsonEncoder extends GeneratedTupleEncoders with EncoderLowPriority1 with override def isNothing(a: A): Boolean = encoder.isNothing(a) + override def isEmpty(a: A): Boolean = encoder.isEmpty(a) + override def toJsonAST(a: A): Either[String, Json] = encoder.toJsonAST(a) } @@ -341,44 +351,80 @@ private[json] trait EncoderLowPriority1 extends EncoderLowPriority2 { .map(Json.Arr(_)) } - implicit def seq[A: JsonEncoder]: JsonEncoder[Seq[A]] = iterable[A, Seq] + implicit def seq[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[Seq[A]] = iterable[A, Seq] - implicit def chunk[A: JsonEncoder]: JsonEncoder[Chunk[A]] = iterable[A, Chunk] + implicit def chunk[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[Chunk[A]] = iterable[A, Chunk] - implicit def nonEmptyChunk[A: JsonEncoder]: JsonEncoder[NonEmptyChunk[A]] = chunk[A].contramap(_.toChunk) + implicit def nonEmptyChunk[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[NonEmptyChunk[A]] = chunk[A].contramap(_.toChunk) - implicit def indexedSeq[A: JsonEncoder]: JsonEncoder[IndexedSeq[A]] = iterable[A, IndexedSeq] + implicit def indexedSeq[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[IndexedSeq[A]] = iterable[A, IndexedSeq] - implicit def linearSeq[A: JsonEncoder]: JsonEncoder[immutable.LinearSeq[A]] = iterable[A, immutable.LinearSeq] + implicit def linearSeq[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[immutable.LinearSeq[A]] = iterable[A, immutable.LinearSeq] - implicit def listSet[A: JsonEncoder]: JsonEncoder[immutable.ListSet[A]] = iterable[A, immutable.ListSet] + implicit def listSet[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[immutable.ListSet[A]] = iterable[A, immutable.ListSet] - implicit def treeSet[A: JsonEncoder]: JsonEncoder[immutable.TreeSet[A]] = iterable[A, immutable.TreeSet] + implicit def treeSet[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[immutable.TreeSet[A]] = iterable[A, immutable.TreeSet] - implicit def list[A: JsonEncoder]: JsonEncoder[List[A]] = iterable[A, List] + implicit def list[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[List[A]] = iterable[A, List] - implicit def vector[A: JsonEncoder]: JsonEncoder[Vector[A]] = iterable[A, Vector] + implicit def vector[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[Vector[A]] = iterable[A, Vector] - implicit def set[A: JsonEncoder]: JsonEncoder[Set[A]] = iterable[A, Set] + implicit def set[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[Set[A]] = + iterable[A, Set] - implicit def hashSet[A: JsonEncoder]: JsonEncoder[immutable.HashSet[A]] = iterable[A, immutable.HashSet] + implicit def hashSet[A: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[immutable.HashSet[A]] = + iterable[A, immutable.HashSet] - implicit def sortedSet[A: Ordering: JsonEncoder]: JsonEncoder[immutable.SortedSet[A]] = + implicit def sortedSet[A: Ordering: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[immutable.SortedSet[A]] = iterable[A, immutable.SortedSet] - implicit def map[K: JsonFieldEncoder, V: JsonEncoder]: JsonEncoder[Map[K, V]] = + implicit def map[K: JsonFieldEncoder, V: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[Map[K, V]] = keyValueIterable[K, V, Map] - implicit def hashMap[K: JsonFieldEncoder, V: JsonEncoder]: JsonEncoder[immutable.HashMap[K, V]] = + implicit def hashMap[K: JsonFieldEncoder, V: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[immutable.HashMap[K, V]] = keyValueIterable[K, V, immutable.HashMap] - implicit def mutableMap[K: JsonFieldEncoder, V: JsonEncoder]: JsonEncoder[mutable.Map[K, V]] = + implicit def mutableMap[K: JsonFieldEncoder, V: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[mutable.Map[K, V]] = keyValueIterable[K, V, mutable.Map] - implicit def sortedMap[K: JsonFieldEncoder, V: JsonEncoder]: JsonEncoder[collection.SortedMap[K, V]] = + implicit def sortedMap[K: JsonFieldEncoder, V: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[collection.SortedMap[K, V]] = keyValueIterable[K, V, collection.SortedMap] - implicit def listMap[K: JsonFieldEncoder, V: JsonEncoder]: JsonEncoder[immutable.ListMap[K, V]] = + implicit def listMap[K: JsonFieldEncoder, V: JsonEncoder](implicit + config: JsonCodecConfiguration + ): JsonEncoder[immutable.ListMap[K, V]] = keyValueIterable[K, V, immutable.ListMap] } @@ -432,8 +478,12 @@ private[json] trait EncoderLowPriority2 extends EncoderLowPriority3 { // not implicit because this overlaps with encoders for lists of tuples def keyValueIterable[K, A, T[X, Y] <: Iterable[(X, Y)]](implicit K: JsonFieldEncoder[K], - A: JsonEncoder[A] + A: JsonEncoder[A], + config: JsonCodecConfiguration ): JsonEncoder[T[K, A]] = new JsonEncoder[T[K, A]] { + + override def isEmpty(a: T[K, A]): Boolean = a.isEmpty + def unsafeEncode(kvs: T[K, A], indent: Option[Int], out: Write): Unit = if (kvs.isEmpty) out.write("{}") else { @@ -447,7 +497,11 @@ private[json] trait EncoderLowPriority2 extends EncoderLowPriority3 { kvs.foreach { var first = true kv => - if (!A.isNothing(kv._2)) { + if ( + (!A.isNothing(kv._2) && !A.isEmpty(kv._2)) || (A + .isNothing(kv._2) && config.explicitNulls) || (A.isEmpty(kv._2) && config.explicitEmptyCollections) + ) { + // if (!A.isNothing(kv._2)) { if (first) first = false else out.write(',') string.unsafeEncode(K.unsafeEncodeField(kv._1), indent, out) @@ -462,7 +516,11 @@ private[json] trait EncoderLowPriority2 extends EncoderLowPriority3 { kvs.foreach { var first = true kv => - if (!A.isNothing(kv._2)) { + if ( + (!A.isNothing(kv._2) && !A.isEmpty(kv._2)) || (A + .isNothing(kv._2) && config.explicitNulls) || (A.isEmpty(kv._2) && config.explicitEmptyCollections) + ) { + // if (!A.isNothing(kv._2)) { if (first) first = false else { out.write(',') @@ -491,7 +549,8 @@ private[json] trait EncoderLowPriority2 extends EncoderLowPriority3 { // not implicit because this overlaps with encoders for lists of tuples def keyValueChunk[K, A](implicit K: JsonFieldEncoder[K], - A: JsonEncoder[A] + A: JsonEncoder[A], + config: JsonCodecConfiguration ): JsonEncoder[({ type lambda[X, Y] = Chunk[(X, Y)] })#lambda[K, A]] = keyValueIterable[K, A, ({ type lambda[X, Y] = Chunk[(X, Y)] })#lambda] } diff --git a/zio-json/shared/src/test/scala-2.x/zio/json/ConfigurableDeriveCodecSpec.scala b/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala similarity index 87% rename from zio-json/shared/src/test/scala-2.x/zio/json/ConfigurableDeriveCodecSpec.scala rename to zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala index 2cafc819..48162f94 100644 --- a/zio-json/shared/src/test/scala-2.x/zio/json/ConfigurableDeriveCodecSpec.scala +++ b/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala @@ -16,6 +16,8 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { case class OptionalField(a: Option[Int]) + case class EmptyMap(a: Map[String, String]) + def spec = suite("ConfigurableDeriveCodecSpec")( suite("defaults")( suite("string")( @@ -194,6 +196,34 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { expectedObj.toJson == expectedStr ) } + ), + suite("explicit empty collections")( + test("should write empty collections if set to true") { + val expectedStr = """{"a":{}}""" + val expectedObj = EmptyMap(Map.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyMap] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyMap].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("should not write empty collections if set to false") { + val expectedStr = """{}""" + val expectedObj = EmptyMap(Map.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyMap] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyMap].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + } ) ) } From c6ad7cde73e581f3cc50aea7b61c1c3564cf7560 Mon Sep 17 00:00:00 2001 From: Thijs Broersen Date: Fri, 29 Nov 2024 00:58:59 +0100 Subject: [PATCH 2/3] add tests, fix codecs --- .../src/main/scala/zio/json/JsonDecoder.scala | 163 +++++-- .../src/main/scala/zio/json/JsonEncoder.scala | 21 +- .../json/ConfigurableDeriveCodecSpec.scala | 436 +++++++++++++++++- 3 files changed, 544 insertions(+), 76 deletions(-) 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 b5837b14..13ffe3de 100644 --- a/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala +++ b/zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala @@ -482,86 +482,141 @@ object JsonDecoder extends GeneratedTupleDecoders with DecoderLowPriority1 with private[json] trait DecoderLowPriority1 extends DecoderLowPriority2 { this: JsonDecoder.type => - implicit def array[A: JsonDecoder: reflect.ClassTag]: JsonDecoder[Array[A]] = new JsonDecoder[Array[A]] { + implicit def array[A: JsonDecoder: reflect.ClassTag](implicit config: JsonCodecConfiguration): JsonDecoder[Array[A]] = + new JsonDecoder[Array[A]] { - def unsafeDecode(trace: List[JsonError], in: RetractReader): Array[A] = - builder(trace, in, Array.newBuilder[A]) - } + override def unsafeDecodeMissing(trace: List[JsonError]): Array[A] = + if (!config.explicitEmptyCollections) Array.empty + else super.unsafeDecodeMissing(trace) - implicit def seq[A: JsonDecoder]: JsonDecoder[Seq[A]] = new JsonDecoder[Seq[A]] { + def unsafeDecode(trace: List[JsonError], in: RetractReader): Array[A] = + builder(trace, in, Array.newBuilder[A]) + } - def unsafeDecode(trace: List[JsonError], in: RetractReader): Seq[A] = - builder(trace, in, immutable.Seq.newBuilder[A]) - } + implicit def seq[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[Seq[A]] = + new JsonDecoder[Seq[A]] { - implicit def chunk[A: JsonDecoder]: JsonDecoder[Chunk[A]] = new JsonDecoder[Chunk[A]] { - val decoder = JsonDecoder[A] - def unsafeDecode(trace: List[JsonError], in: RetractReader): Chunk[A] = - builder(trace, in, zio.ChunkBuilder.make[A]()) + override def unsafeDecodeMissing(trace: List[JsonError]): Seq[A] = + if (!config.explicitEmptyCollections) Seq.empty + else super.unsafeDecodeMissing(trace) - override final def unsafeFromJsonAST(trace: List[JsonError], json: Json): Chunk[A] = - json match { - case Json.Arr(elements) => - elements.zipWithIndex.map { case (json, i) => - decoder.unsafeFromJsonAST(JsonError.ArrayAccess(i) :: trace, json) - } - case _ => throw UnsafeJson(JsonError.Message("Not an array") :: trace) - } - } + def unsafeDecode(trace: List[JsonError], in: RetractReader): Seq[A] = + builder(trace, in, immutable.Seq.newBuilder[A]) + } + + implicit def chunk[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[Chunk[A]] = + new JsonDecoder[Chunk[A]] { + + override def unsafeDecodeMissing(trace: List[JsonError]): Chunk[A] = + if (!config.explicitEmptyCollections) Chunk.empty + else super.unsafeDecodeMissing(trace) + + val decoder = JsonDecoder[A] + def unsafeDecode(trace: List[JsonError], in: RetractReader): Chunk[A] = + builder(trace, in, zio.ChunkBuilder.make[A]()) + + override final def unsafeFromJsonAST(trace: List[JsonError], json: Json): Chunk[A] = + json match { + case Json.Arr(elements) => + elements.zipWithIndex.map { case (json, i) => + decoder.unsafeFromJsonAST(JsonError.ArrayAccess(i) :: trace, json) + } + case _ => throw UnsafeJson(JsonError.Message("Not an array") :: trace) + } + } implicit def nonEmptyChunk[A: JsonDecoder]: JsonDecoder[NonEmptyChunk[A]] = chunk[A].mapOrFail(NonEmptyChunk.fromChunk(_).toRight("Chunk was empty")) - implicit def indexedSeq[A: JsonDecoder]: JsonDecoder[IndexedSeq[A]] = + implicit def indexedSeq[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[IndexedSeq[A]] = new JsonDecoder[IndexedSeq[A]] { + override def unsafeDecodeMissing(trace: List[JsonError]): IndexedSeq[A] = + if (!config.explicitEmptyCollections) IndexedSeq.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): IndexedSeq[A] = builder(trace, in, IndexedSeq.newBuilder[A]) } - implicit def linearSeq[A: JsonDecoder]: JsonDecoder[immutable.LinearSeq[A]] = + implicit def linearSeq[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[immutable.LinearSeq[A]] = new JsonDecoder[immutable.LinearSeq[A]] { + override def unsafeDecodeMissing(trace: List[JsonError]): immutable.LinearSeq[A] = + if (!config.explicitEmptyCollections) immutable.LinearSeq.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): LinearSeq[A] = builder(trace, in, immutable.LinearSeq.newBuilder[A]) } - implicit def listSet[A: JsonDecoder]: JsonDecoder[immutable.ListSet[A]] = new JsonDecoder[immutable.ListSet[A]] { + implicit def listSet[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[immutable.ListSet[A]] = + new JsonDecoder[immutable.ListSet[A]] { - def unsafeDecode(trace: List[JsonError], in: RetractReader): ListSet[A] = - builder(trace, in, immutable.ListSet.newBuilder[A]) - } + override def unsafeDecodeMissing(trace: List[JsonError]): immutable.ListSet[A] = + if (!config.explicitEmptyCollections) immutable.ListSet.empty + else super.unsafeDecodeMissing(trace) - implicit def treeSet[A: JsonDecoder: Ordering]: JsonDecoder[immutable.TreeSet[A]] = + def unsafeDecode(trace: List[JsonError], in: RetractReader): ListSet[A] = + builder(trace, in, immutable.ListSet.newBuilder[A]) + } + + implicit def treeSet[A: JsonDecoder: Ordering](implicit + config: JsonCodecConfiguration + ): JsonDecoder[immutable.TreeSet[A]] = new JsonDecoder[immutable.TreeSet[A]] { + override def unsafeDecodeMissing(trace: List[JsonError]): immutable.TreeSet[A] = + if (!config.explicitEmptyCollections) immutable.TreeSet.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): TreeSet[A] = builder(trace, in, immutable.TreeSet.newBuilder[A]) } - implicit def list[A: JsonDecoder]: JsonDecoder[List[A]] = new JsonDecoder[List[A]] { + implicit def list[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[List[A]] = + new JsonDecoder[List[A]] { - def unsafeDecode(trace: List[JsonError], in: RetractReader): List[A] = - builder(trace, in, new mutable.ListBuffer[A]) - } + override def unsafeDecodeMissing(trace: List[JsonError]): List[A] = + if (!config.explicitEmptyCollections) List.empty + else super.unsafeDecodeMissing(trace) - implicit def vector[A: JsonDecoder]: JsonDecoder[Vector[A]] = new JsonDecoder[Vector[A]] { + def unsafeDecode(trace: List[JsonError], in: RetractReader): List[A] = + builder(trace, in, new mutable.ListBuffer[A]) + } - def unsafeDecode(trace: List[JsonError], in: RetractReader): Vector[A] = - builder(trace, in, immutable.Vector.newBuilder[A]) - } + implicit def vector[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[Vector[A]] = + new JsonDecoder[Vector[A]] { - implicit def set[A: JsonDecoder]: JsonDecoder[Set[A]] = new JsonDecoder[Set[A]] { + override def unsafeDecodeMissing(trace: List[JsonError]): Vector[A] = + if (!config.explicitEmptyCollections) Vector.empty + else super.unsafeDecodeMissing(trace) - def unsafeDecode(trace: List[JsonError], in: RetractReader): Set[A] = - builder(trace, in, Set.newBuilder[A]) - } + def unsafeDecode(trace: List[JsonError], in: RetractReader): Vector[A] = + builder(trace, in, immutable.Vector.newBuilder[A]) + } - implicit def hashSet[A: JsonDecoder]: JsonDecoder[immutable.HashSet[A]] = new JsonDecoder[immutable.HashSet[A]] { + implicit def set[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[Set[A]] = + new JsonDecoder[Set[A]] { - def unsafeDecode(trace: List[JsonError], in: RetractReader): immutable.HashSet[A] = - builder(trace, in, immutable.HashSet.newBuilder[A]) - } + override def unsafeDecodeMissing(trace: List[JsonError]): Set[A] = + if (!config.explicitEmptyCollections) Set.empty + else super.unsafeDecodeMissing(trace) + + def unsafeDecode(trace: List[JsonError], in: RetractReader): Set[A] = + builder(trace, in, Set.newBuilder[A]) + } + + implicit def hashSet[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[immutable.HashSet[A]] = + new JsonDecoder[immutable.HashSet[A]] { + + override def unsafeDecodeMissing(trace: List[JsonError]): immutable.HashSet[A] = + if (!config.explicitEmptyCollections) immutable.HashSet.empty + else super.unsafeDecodeMissing(trace) + + def unsafeDecode(trace: List[JsonError], in: RetractReader): immutable.HashSet[A] = + builder(trace, in, immutable.HashSet.newBuilder[A]) + } implicit def map[K: JsonFieldDecoder, V: JsonDecoder](implicit config: JsonCodecConfiguration @@ -656,19 +711,29 @@ private[json] trait DecoderLowPriority1 extends DecoderLowPriority2 { private[json] trait DecoderLowPriority2 extends DecoderLowPriority3 { this: JsonDecoder.type => - implicit def iterable[A: JsonDecoder]: JsonDecoder[Iterable[A]] = new JsonDecoder[Iterable[A]] { + implicit def iterable[A: JsonDecoder](implicit config: JsonCodecConfiguration): JsonDecoder[Iterable[A]] = + new JsonDecoder[Iterable[A]] { - def unsafeDecode(trace: List[JsonError], in: RetractReader): Iterable[A] = - builder(trace, in, immutable.Iterable.newBuilder[A]) - } + override def unsafeDecodeMissing(trace: List[JsonError]): Iterable[A] = + if (!config.explicitEmptyCollections) Iterable.empty + else super.unsafeDecodeMissing(trace) + + def unsafeDecode(trace: List[JsonError], in: RetractReader): Iterable[A] = + builder(trace, in, immutable.Iterable.newBuilder[A]) + } // not implicit because this overlaps with decoders for lists of tuples def keyValueChunk[K, A](implicit K: JsonFieldDecoder[K], - A: JsonDecoder[A] + A: JsonDecoder[A], + config: JsonCodecConfiguration ): JsonDecoder[Chunk[(K, A)]] = new JsonDecoder[Chunk[(K, A)]] { + override def unsafeDecodeMissing(trace: List[JsonError]): Chunk[(K, A)] = + if (!config.explicitEmptyCollections) Chunk.empty + else super.unsafeDecodeMissing(trace) + def unsafeDecode(trace: List[JsonError], in: RetractReader): Chunk[(K, A)] = keyValueBuilder[K, A, ({ type lambda[X, Y] = Chunk[(X, Y)] })#lambda]( trace, 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 7697bfc4..44e00936 100644 --- a/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala +++ b/zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala @@ -306,8 +306,15 @@ object JsonEncoder extends GeneratedTupleEncoders with EncoderLowPriority1 with private[json] trait EncoderLowPriority1 extends EncoderLowPriority2 { this: JsonEncoder.type => - implicit def array[A](implicit A: JsonEncoder[A], classTag: ClassTag[A]): JsonEncoder[Array[A]] = + implicit def array[A](implicit + A: JsonEncoder[A], + classTag: ClassTag[A], + config: JsonCodecConfiguration + ): JsonEncoder[Array[A]] = new JsonEncoder[Array[A]] { + + override def isEmpty(as: Array[A]): Boolean = as.isEmpty + def unsafeEncode(as: Array[A], indent: Option[Int], out: Write): Unit = if (as.isEmpty) out.write("[]") else { @@ -359,9 +366,7 @@ private[json] trait EncoderLowPriority1 extends EncoderLowPriority2 { config: JsonCodecConfiguration ): JsonEncoder[Chunk[A]] = iterable[A, Chunk] - implicit def nonEmptyChunk[A: JsonEncoder](implicit - config: JsonCodecConfiguration - ): JsonEncoder[NonEmptyChunk[A]] = chunk[A].contramap(_.toChunk) + implicit def nonEmptyChunk[A: JsonEncoder]: JsonEncoder[NonEmptyChunk[A]] = chunk[A].contramap(_.toChunk) implicit def indexedSeq[A: JsonEncoder](implicit config: JsonCodecConfiguration @@ -431,8 +436,14 @@ private[json] trait EncoderLowPriority1 extends EncoderLowPriority2 { private[json] trait EncoderLowPriority2 extends EncoderLowPriority3 { this: JsonEncoder.type => - implicit def iterable[A, T[X] <: Iterable[X]](implicit A: JsonEncoder[A]): JsonEncoder[T[A]] = + implicit def iterable[A, T[X] <: Iterable[X]](implicit + A: JsonEncoder[A], + config: JsonCodecConfiguration + ): JsonEncoder[T[A]] = new JsonEncoder[T[A]] { + + override def isEmpty(as: T[A]): Boolean = as.isEmpty + def unsafeEncode(as: T[A], indent: Option[Int], out: Write): Unit = if (as.isEmpty) out.write("[]") else { diff --git a/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala b/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala index 48162f94..bffa9650 100644 --- a/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala +++ b/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala @@ -3,6 +3,10 @@ package zio.json import zio.json.JsonCodecConfiguration.SumTypeHandling.DiscriminatorField import zio.json.ast.Json import zio.test._ +import zio.Chunk + +import scala.collection.immutable +import scala.collection.mutable object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { case class ClassWithFields(someField: Int, someOtherField: String) @@ -18,6 +22,8 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { case class EmptyMap(a: Map[String, String]) + case class EmptyList(a: List[String]) + def spec = suite("ConfigurableDeriveCodecSpec")( suite("defaults")( suite("string")( @@ -198,32 +204,418 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { } ), suite("explicit empty collections")( - test("should write empty collections if set to true") { - val expectedStr = """{"a":{}}""" - val expectedObj = EmptyMap(Map.empty) + suite("should write empty collections if set to true")( + test("for an array") { + case class EmptyArray(a: Array[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyArray(Array.empty) - implicit val config: JsonCodecConfiguration = - JsonCodecConfiguration(explicitEmptyCollections = true) - implicit val codec: JsonCodec[EmptyMap] = DeriveJsonCodec.gen + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyArray] = DeriveJsonCodec.gen - assertTrue( - expectedStr.fromJson[EmptyMap].toOption.get == expectedObj, - expectedObj.toJson == expectedStr - ) - }, - test("should not write empty collections if set to false") { - val expectedStr = """{}""" - val expectedObj = EmptyMap(Map.empty) + assertTrue(expectedStr.fromJson[EmptyArray].toOption.get.a.isEmpty, expectedObj.toJson == expectedStr) + }, + test("for a seq") { + case class EmptySeq(a: Seq[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptySeq(Seq.empty) - implicit val config: JsonCodecConfiguration = - JsonCodecConfiguration(explicitEmptyCollections = false) - implicit val codec: JsonCodec[EmptyMap] = DeriveJsonCodec.gen + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptySeq] = DeriveJsonCodec.gen - assertTrue( - expectedStr.fromJson[EmptyMap].toOption.get == expectedObj, - expectedObj.toJson == expectedStr - ) - } + assertTrue(expectedStr.fromJson[EmptySeq].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a chunk") { + case class EmptyChunk(a: Chunk[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyChunk(Chunk.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyChunk] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyChunk].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for an indexed seq") { + case class EmptyIndexedSeq(a: IndexedSeq[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyIndexedSeq(IndexedSeq.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyIndexedSeq] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyIndexedSeq].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a linear seq") { + case class EmptyLinearSeq(a: immutable.LinearSeq[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyLinearSeq(immutable.LinearSeq.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyLinearSeq] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyLinearSeq].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a list set") { + case class EmptyListSet(a: immutable.ListSet[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyListSet(immutable.ListSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyListSet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyListSet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a tree set") { + case class EmptyTreeSet(a: immutable.TreeSet[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyTreeSet(immutable.TreeSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyTreeSet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyTreeSet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a list") { + case class EmptyList(a: List[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyList(List.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyList] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyList].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a vector") { + case class EmptyVector(a: Vector[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyVector(Vector.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyVector] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyVector].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a set") { + case class EmptySet(a: Set[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptySet(Set.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptySet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptySet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a hash set") { + case class EmptyHashSet(a: immutable.HashSet[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptyHashSet(immutable.HashSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyHashSet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyHashSet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a sorted set") { + case class EmptySortedSet(a: immutable.SortedSet[Int]) + val expectedStr = """{"a":[]}""" + val expectedObj = EmptySortedSet(immutable.SortedSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptySortedSet] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptySortedSet].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a map") { + case class EmptyMap(a: Map[String, String]) + val expectedStr = """{"a":{}}""" + val expectedObj = EmptyMap(Map.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyMap] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyMap].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a hash map") { + case class EmptyHashMap(a: immutable.HashMap[String, String]) + val expectedStr = """{"a":{}}""" + val expectedObj = EmptyHashMap(immutable.HashMap.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyHashMap] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyHashMap].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a mutable map") { + case class EmptyMutableMap(a: mutable.Map[String, String]) + val expectedStr = """{"a":{}}""" + val expectedObj = EmptyMutableMap(mutable.Map.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyMutableMap] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyMutableMap].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a sorted map") { + case class EmptySortedMap(a: collection.SortedMap[String, String]) + val expectedStr = """{"a":{}}""" + val expectedObj = EmptySortedMap(collection.SortedMap.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptySortedMap] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptySortedMap].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a list map") { + case class EmptyListMap(a: immutable.ListMap[String, String]) + val expectedStr = """{"a":{}}""" + val expectedObj = EmptyListMap(immutable.ListMap.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = true) + implicit val codec: JsonCodec[EmptyListMap] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyListMap].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + } + ), + suite("should not write empty collections if set to false")( + test("for an array") { + case class EmptyArray(a: Array[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyArray(Array.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyArray] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyArray].toOption.get.a.isEmpty, expectedObj.toJson == expectedStr) + }, + test("for a seq") { + case class EmptySeq(a: Seq[Int]) + val expectedStr = """{}""" + val expectedObj = EmptySeq(Seq.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptySeq] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptySeq].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a chunk") { + case class EmptyChunk(a: Chunk[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyChunk(Chunk.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyChunk] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyChunk].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for an indexed seq") { + case class EmptyIndexedSeq(a: IndexedSeq[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyIndexedSeq(IndexedSeq.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyIndexedSeq] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyIndexedSeq].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a linear seq") { + case class EmptyLinearSeq(a: immutable.LinearSeq[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyLinearSeq(immutable.LinearSeq.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyLinearSeq] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyLinearSeq].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a list set") { + case class EmptyListSet(a: immutable.ListSet[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyListSet(immutable.ListSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyListSet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyListSet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a treeSet") { + case class EmptyTreeSet(a: immutable.TreeSet[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyTreeSet(immutable.TreeSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyTreeSet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyTreeSet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a list") { + val expectedStr = """{}""" + val expectedObj = EmptyList(List.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyList] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyList].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a vector") { + case class EmptyVector(a: Vector[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyVector(Vector.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyVector] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyVector].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a set") { + case class EmptySet(a: Set[Int]) + val expectedStr = """{}""" + val expectedObj = EmptySet(Set.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptySet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptySet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a hash set") { + case class EmptyHashSet(a: immutable.HashSet[Int]) + val expectedStr = """{}""" + val expectedObj = EmptyHashSet(immutable.HashSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyHashSet] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyHashSet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a sorted set") { + case class EmptySortedSet(a: immutable.SortedSet[Int]) + val expectedStr = """{}""" + val expectedObj = EmptySortedSet(immutable.SortedSet.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptySortedSet] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptySortedSet].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a map") { + val expectedStr = """{}""" + val expectedObj = EmptyMap(Map.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyMap] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyMap].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a hashMap") { + case class EmptyHashMap(a: immutable.HashMap[String, String]) + val expectedStr = """{}""" + val expectedObj = EmptyHashMap(immutable.HashMap.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyHashMap] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyHashMap].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + }, + test("for a mutable map") { + case class EmptyMutableMap(a: mutable.Map[String, String]) + val expectedStr = """{}""" + val expectedObj = EmptyMutableMap(mutable.Map.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyMutableMap] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptyMutableMap].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a sorted map") { + case class EmptySortedMap(a: collection.SortedMap[String, String]) + val expectedStr = """{}""" + val expectedObj = EmptySortedMap(collection.SortedMap.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptySortedMap] = DeriveJsonCodec.gen + + assertTrue( + expectedStr.fromJson[EmptySortedMap].toOption.get == expectedObj, + expectedObj.toJson == expectedStr + ) + }, + test("for a list map") { + case class EmptyListMap(a: immutable.ListMap[String, String]) + val expectedStr = """{}""" + val expectedObj = EmptyListMap(immutable.ListMap.empty) + + implicit val config: JsonCodecConfiguration = + JsonCodecConfiguration(explicitEmptyCollections = false) + implicit val codec: JsonCodec[EmptyListMap] = DeriveJsonCodec.gen + + assertTrue(expectedStr.fromJson[EmptyListMap].toOption.get == expectedObj, expectedObj.toJson == expectedStr) + } + ) ) ) } From 7c31061c509ac2feee4233c97769c183301b02bf Mon Sep 17 00:00:00 2001 From: Thijs Broersen Date: Fri, 29 Nov 2024 01:00:26 +0100 Subject: [PATCH 3/3] cleanup --- .../test/scala/zio/json/ConfigurableDeriveCodecSpec.scala | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala b/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala index bffa9650..a4224fbc 100644 --- a/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala +++ b/zio-json/shared/src/test/scala/zio/json/ConfigurableDeriveCodecSpec.scala @@ -20,10 +20,6 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { case class OptionalField(a: Option[Int]) - case class EmptyMap(a: Map[String, String]) - - case class EmptyList(a: List[String]) - def spec = suite("ConfigurableDeriveCodecSpec")( suite("defaults")( suite("string")( @@ -493,6 +489,7 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { assertTrue(expectedStr.fromJson[EmptyTreeSet].toOption.get == expectedObj, expectedObj.toJson == expectedStr) }, test("for a list") { + case class EmptyList(a: List[Int]) val expectedStr = """{}""" val expectedObj = EmptyList(List.empty) @@ -553,6 +550,7 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault { ) }, test("for a map") { + case class EmptyMap(a: Map[String, String]) val expectedStr = """{}""" val expectedObj = EmptyMap(Map.empty)