diff --git a/core/js/src/main/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInput.scala b/core/js/src/main/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInput.scala index e29da71e2..c6a00e302 100644 --- a/core/js/src/main/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInput.scala +++ b/core/js/src/main/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInput.scala @@ -131,13 +131,16 @@ final class NativeJsonListInput(array: js.Array[js.Any], options: NativeFormatOp } final class NativeJsonObjectInput(dict: js.Dictionary[js.Any], options: NativeFormatOptions) extends ObjectInput { - private val it = dict.iterator + private val it = dict.iterator.filterNot { case (_, value) => js.isUndefined(value) } override def hasNext: Boolean = it.hasNext override def peekField(name: String): Opt[FieldInput] = - if (dict.contains(name)) Opt(new NativeJsonFieldInput(name, dict(name), options)) else Opt.Empty + if (dict.contains(name) && !js.isUndefined(dict(name))) + Opt(new NativeJsonFieldInput(name, dict(name), options)) + else + Opt.Empty override def nextField(): FieldInput = { val (key, value) = it.next() diff --git a/core/js/src/test/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInputOutputTest.scala b/core/js/src/test/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInputOutputTest.scala index 7c2d8b1e4..f424bc5f3 100644 --- a/core/js/src/test/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInputOutputTest.scala +++ b/core/js/src/test/scala/com/avsystem/commons/serialization/nativejs/NativeJsonInputOutputTest.scala @@ -3,9 +3,11 @@ package serialization.nativejs import com.avsystem.commons.misc.{Bytes, Timestamp} import com.avsystem.commons.serialization.json.WrappedJson -import com.avsystem.commons.serialization.{GenCodec, HasGenCodec} +import com.avsystem.commons.serialization.{optionalParam, GenCodec, HasGenCodec} import org.scalatest.funsuite.AnyFunSuite +import scala.scalajs.js + object NativeJsonInputOutputTest { case class TestModel( @@ -20,6 +22,14 @@ object NativeJsonInputOutputTest { rawJson: WrappedJson, ) object TestModel extends HasGenCodec[TestModel] + + case class OptionalFieldsModel( + required: String, + @optionalParam opt: Opt[String], + @optionalParam option: Option[Int], + withDefault: Int = 42, + ) + object OptionalFieldsModel extends HasGenCodec[OptionalFieldsModel] } class NativeJsonInputOutputTest extends AnyFunSuite { @@ -79,4 +89,48 @@ class NativeJsonInputOutputTest extends AnyFunSuite { val deserialized = NativeJsonInput.readString[T](raw, options) assert(deserialized == input) } + + // --- issue #848: JS `undefined` field values must be treated as absent --- + + test("undefined fields are treated as absent when reading a case class") { + val dict = js.Dictionary[js.Any]( + "required" -> "abc", + "opt" -> js.undefined, + "option" -> js.undefined, + "withDefault" -> js.undefined, + ) + assert(NativeJsonInput.read[OptionalFieldsModel](dict) == OptionalFieldsModel("abc", Opt.Empty, None, 42)) + } + + test("undefined fields behave identically to omitted fields") { + val withUndefined = js.Dictionary[js.Any]( + "required" -> "abc", + "opt" -> js.undefined, + "option" -> js.undefined, + "withDefault" -> js.undefined, + ) + val omitted = js.Dictionary[js.Any]("required" -> "abc") + assert(NativeJsonInput.read[OptionalFieldsModel](withUndefined) == NativeJsonInput.read[OptionalFieldsModel](omitted)) + } + + test("undefined value for a required field is treated as missing") { + val dict = js.Dictionary[js.Any]("required" -> js.undefined, "option" -> 5) + // MissingField (not a generic "cannot read" failure) proves the undefined field was skipped entirely + assertThrows[GenCodec.MissingField] { + NativeJsonInput.read[OptionalFieldsModel](dict) + } + } + + test("undefined entries are skipped when reading a Map (iterator path)") { + val dict = js.Dictionary[js.Any]("a" -> "1", "b" -> js.undefined, "c" -> "3") + assert(NativeJsonInput.read[Map[String, String]](dict) == Map("a" -> "1", "c" -> "3")) + } + + test("peekField treats an undefined value as an absent field") { + val dict = js.Dictionary[js.Any]("defined" -> "value", "undef" -> js.undefined) + val objectInput = new NativeJsonInput(dict, NativeFormatOptions.RawString).readObject() + assert(objectInput.peekField("undef").isEmpty) // present-but-undefined -> absent + assert(objectInput.peekField("missing").isEmpty) // truly absent + assert(objectInput.peekField("defined").isDefined) + } }