diff --git a/jsoniter-scala-macros/shared/src/main/scala-3/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMaker.scala b/jsoniter-scala-macros/shared/src/main/scala-3/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMaker.scala index b4436862b..d213496c9 100644 --- a/jsoniter-scala-macros/shared/src/main/scala-3/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMaker.scala +++ b/jsoniter-scala-macros/shared/src/main/scala-3/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMaker.scala @@ -56,6 +56,8 @@ final class stringified extends StaticAnnotation * arrays or collections (turned on by default) * @param transientNone a flag that turns on skipping serialization of fields that have empty values of * options (turned on by default) + * @param transientNull a flag that turns on skipping serialization of fields that have null values of + * objects (turned off by default) * @param requireCollectionFields a flag that turn on checking of presence of collection fields and forces * serialization when they are empty * @param bigDecimalPrecision a precision in 'BigDecimal' values (34 by default that is a precision for decimal128, @@ -113,6 +115,7 @@ class CodecMakerConfig private[macros] ( val transientDefault: Boolean, val transientEmpty: Boolean, val transientNone: Boolean, + val transientNull: Boolean, val requireCollectionFields: Boolean, val bigDecimalPrecision: Int, val bigDecimalScaleLimit: Int, @@ -161,6 +164,8 @@ class CodecMakerConfig private[macros] ( def withTransientNone(transientNone: Boolean): CodecMakerConfig = copy(transientNone = transientNone) + def withTransientNull(transientNull: Boolean): CodecMakerConfig = copy(transientNull = transientNull) + def withRequireCollectionFields(requireCollectionFields: Boolean): CodecMakerConfig = copy(requireCollectionFields = requireCollectionFields) @@ -224,6 +229,7 @@ class CodecMakerConfig private[macros] ( transientDefault: Boolean = transientDefault, transientEmpty: Boolean = transientEmpty, transientNone: Boolean = transientNone, + transientNull: Boolean = transientNull, requireCollectionFields: Boolean = requireCollectionFields, bigDecimalPrecision: Int = bigDecimalPrecision, bigDecimalScaleLimit: Int = bigDecimalScaleLimit, @@ -255,6 +261,7 @@ class CodecMakerConfig private[macros] ( transientDefault = transientDefault, transientEmpty = transientEmpty, transientNone = transientNone, + transientNull = transientNull, requireCollectionFields = requireCollectionFields, bigDecimalPrecision = bigDecimalPrecision, bigDecimalScaleLimit = bigDecimalScaleLimit, @@ -288,6 +295,7 @@ object CodecMakerConfig extends CodecMakerConfig( transientDefault = true, transientEmpty = true, transientNone = true, + transientNull = false, requireCollectionFields = false, bigDecimalPrecision = 34, bigDecimalScaleLimit = 6178, @@ -340,6 +348,7 @@ object CodecMakerConfig extends CodecMakerConfig( case '{ ($x: CodecMakerConfig).withTransientDefault($v) } => Some(x.valueOrAbort.withTransientDefault(v.valueOrAbort)) case '{ ($x: CodecMakerConfig).withTransientEmpty($v) } => Some(x.valueOrAbort.withTransientEmpty(v.valueOrAbort)) case '{ ($x: CodecMakerConfig).withTransientNone($v) } => Some(x.valueOrAbort.withTransientNone(v.valueOrAbort)) + case '{ ($x: CodecMakerConfig).withTransientNull($v) } => Some(x.valueOrAbort.withTransientNull(v.valueOrAbort)) case '{ ($x: CodecMakerConfig).withRequireCollectionFields($v) } => Some(x.valueOrAbort.withRequireCollectionFields(v.valueOrAbort)) case '{ ($x: CodecMakerConfig).withRequireDefaultFields($v) } => Some(x.valueOrAbort.withRequireDefaultFields(v.valueOrAbort)) case '{ ($x: CodecMakerConfig).withScalaTransientSupport($v) } => Some(x.valueOrAbort.withScalaTransientSupport(v.valueOrAbort)) @@ -677,6 +686,7 @@ object JsonCodecMaker { transientDefault = false, transientEmpty = false, transientNone = false, + transientNull = true, requireCollectionFields = false, bigDecimalPrecision = 34, bigDecimalScaleLimit = 6178, @@ -794,6 +804,11 @@ object JsonCodecMaker { def isOption(tpe: TypeRepr, types: List[TypeRepr]): Boolean = tpe <:< TypeRepr.of[Option[_]] && (cfg.skipNestedOptionValues || !types.headOption.exists(_ <:< TypeRepr.of[Option[_]])) + def isNullable(tpe: TypeRepr): Boolean = tpe match { + case OrType(left, right) => isNullable(right) || isNullable(left) + case _ => tpe =:= TypeRepr.of[Null] + } + def isIArray(tpe: TypeRepr): Boolean = tpe.typeSymbol.fullName == "scala.IArray$package$.IArray" def isCollection(tpe: TypeRepr): Boolean = tpe <:< TypeRepr.of[Iterable[_]] || tpe <:< TypeRepr.of[Iterator[_]] || @@ -1945,7 +1960,7 @@ object JsonCodecMaker { else mappedNames }) val required = fields.collect { - case fieldInfo if !((!cfg.requireDefaultFields && fieldInfo.symbol.flags.is(Flags.HasDefault)) || isOption(fieldInfo.resolvedTpe, types) || + case fieldInfo if !((!cfg.requireDefaultFields && fieldInfo.symbol.flags.is(Flags.HasDefault)) || isOption(fieldInfo.resolvedTpe, types) || isNullable(fieldInfo.resolvedTpe) || (!cfg.requireCollectionFields && isCollection(fieldInfo.resolvedTpe))) => fieldInfo.mappedName }.toSet val paramVarNum = fields.size @@ -2692,6 +2707,12 @@ object JsonCodecMaker { ${genWriteVal('{v.get}, tpe1 :: fTpe :: types, fieldInfo.isStringified, None, out)} } } + } else if (cfg.transientNull && isNullable(fTpe)) '{ + val v = ${Select(x.asTerm, fieldInfo.getterOrField).asExprOf[ft]} + if ((v != null) && v != ${d.asExprOf[ft]}) { + ${genWriteConstantKey(fieldInfo.mappedName, out)} + ${genWriteVal('v, fTpe :: types, fieldInfo.isStringified, None, out)} + } } else if (fTpe <:< TypeRepr.of[Array[_]]) { def cond(v: Expr[Array[_]])(using Quotes): Expr[Boolean] = val da = d.asExprOf[Array[_]] @@ -2756,6 +2777,12 @@ object JsonCodecMaker { ${genWriteVal('{ v.get }, tpe1 :: fTpe :: types, fieldInfo.isStringified, None, out)} } } + } else if (cfg.transientNull && isNullable(fTpe)) '{ + val v = ${Select(x.asTerm, fieldInfo.getterOrField).asExprOf[ft]} + if (v != null) { + ${genWriteConstantKey(fieldInfo.mappedName, out)} + ${genWriteVal('v, fTpe :: types, fieldInfo.isStringified, None, out)} + } } else if (cfg.transientEmpty && fTpe <:< TypeRepr.of[Array[_]]) '{ val v = ${Select(x.asTerm, fieldInfo.getterOrField).asExprOf[ft & Array[_]]} if (v.length != 0) { diff --git a/jsoniter-scala-macros/shared/src/test/scala-3/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMakerNullableSpec.scala b/jsoniter-scala-macros/shared/src/test/scala-3/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMakerNullableSpec.scala new file mode 100644 index 000000000..37d16c074 --- /dev/null +++ b/jsoniter-scala-macros/shared/src/test/scala-3/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMakerNullableSpec.scala @@ -0,0 +1,43 @@ +package com.github.plokhotnyuk.jsoniter_scala.macros + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker._ + +case class NullableProperty(a: Int | Null) + +given nullableValueCodec: JsonValueCodec[Int | Null] = new JsonValueCodec[Int | Null] { + def decodeValue(in: JsonReader, default: Int | Null): Int | Null = { + if (in.isNextToken('n')) { + in.rollbackToken() + in.readRawValAsBytes() + null + } else { + in.rollbackToken() + in.readInt() + } + } + + def encodeValue(x: Int | Null, out: JsonWriter): Unit = { + if (x == null) { + out.writeNull() + } else { + out.writeVal(x.asInstanceOf[Int]) + } + } + + val nullValue: Int | Null = null +} + +// (CodecMakerConfig.withDiscriminatorFieldName(None)) +class JsonCodecMakerNullableSpec extends VerifyingSpec { + "JsonCodecMaker.make generate codecs which" should { + "serialize and deserialize case class with nullable values (default behavior)" in { + verifySerDeser(make[NullableProperty], NullableProperty(a = null), + """{"a":null}""") + } + "serialize and deserialize case class with nullable values (transient null behavior)" in { + verifySerDeser(make[NullableProperty](CodecMakerConfig.withTransientNull(true)), NullableProperty(a = null), + """{}""") + } + } +} \ No newline at end of file diff --git a/version.sbt b/version.sbt index bffcba9c2..61717a384 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -ThisBuild / version := "2.36.8-SNAPSHOT" +ThisBuild / version := "2.36.9-SNAPSHOT"