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..f53a599c 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,21 @@ 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 config = summonInline[JsonCodecConfiguration] 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) @@ -385,9 +392,10 @@ object DeriveJsonDecoder extends Derivation[JsonDecoder] { self => } } - def split[A](ctx: SealedTrait[JsonDecoder, A]): JsonDecoder[A] = { + inline def split[A](ctx: SealedTrait[JsonDecoder, A]): JsonDecoder[A] = { + val config = summonInline[JsonCodecConfiguration] 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 +415,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 +550,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) @@ -576,6 +593,7 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self => .toArray val explicitNulls = ctx.annotations.exists(_.isInstanceOf[jsonExplicitNull]) + val explicitEmptyCollections = ctx.annotations.exists(_.isInstanceOf[jsonExplicitEmptyCollection]) lazy val tcs: Array[JsonEncoder[Any]] = IArray.genericWrapArray(params.map(_.typeclass.asInstanceOf[JsonEncoder[Any]])).toArray @@ -593,7 +611,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 +663,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 +772,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 +788,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 +824,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 + ) + } ) ) }