From 45012488cb4ba4ebd81a21e4a2ce7a445f605a5a Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Fri, 19 Jun 2026 13:34:10 +0100 Subject: [PATCH] fix(util): round-trip null Value in ValueOrCompletion serialization A materialized Flow that emits null produces Value(null), but none of the serializers could round-trip it: - Fory force-cast the read ref to non-null Any (NullPointerException). - The Jackson 2/3 deserializers conflated an absent value field with a present-but-null one, throwing "Missing value field" on "value": null. Drop the Fory non-null cast, and in both Jackson deserializers throw only when the field is genuinely absent, treating a present null node as null. Adds a null-Value round-trip test to each serialization spec. --- .../serialization/fory/ValueOrCompletionSerializer.kt | 2 +- .../jackson2/ValueOrCompletionDeserializer.kt | 5 +++-- .../jackson3/ValueOrCompletionDeserializer.kt | 5 +++-- .../fory/ValueOrCompletionSerializationTest.kt | 9 ++++++++- .../jackson2/ValueOrCompletionSerializationTest.kt | 8 ++++++++ .../jackson3/ValueOrCompletionSerializationTest.kt | 8 ++++++++ 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializer.kt index 80a001b..3b7ac40 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializer.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializer.kt @@ -38,7 +38,7 @@ internal class ValueOrCompletionSerializer( override fun read(readContext: ReadContext): ValueOrCompletion<*> { return when (Type.entries[readContext.readByte().toInt()]) { Type.VALUE -> { - val value = readContext.readRef() as Any + val value = readContext.readRef() ValueOrCompletion.Value(value) } Type.COMPLETION -> { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionDeserializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionDeserializer.kt index a4517d7..851df7d 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionDeserializer.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionDeserializer.kt @@ -16,9 +16,10 @@ internal class ValueOrCompletionDeserializer : ?: throw JsonMappingException.from(p, "Missing type field for ValueOrCompletion") return when (type) { "value" -> { - val value = - node.get("value")?.let { p.codec.treeToValue(it, Any::class.java) } + val valueNode = + node.get("value") ?: throw JsonMappingException.from(p, "Missing value field for value type") + val value = if (valueNode.isNull) null else p.codec.treeToValue(valueNode, Any::class.java) ValueOrCompletion.Value(value) } "completion" -> { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionDeserializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionDeserializer.kt index 3c7abdb..b745174 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionDeserializer.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionDeserializer.kt @@ -15,9 +15,10 @@ internal class ValueOrCompletionDeserializer : ?: throw DatabindException.from(p, "Missing type field for ValueOrCompletion") return when (type) { "value" -> { - val value = - node.get("value")?.let { ctxt.readTreeAsValue(it, Any::class.java) } + val valueNode = + node.get("value") ?: throw DatabindException.from(p, "Missing value field for value type") + val value = if (valueNode.isNull) null else ctxt.readTreeAsValue(valueNode, Any::class.java) ValueOrCompletion.Value(value) } "completion" -> { diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializationTest.kt index 8f72ab7..e4b1761 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializationTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/ValueOrCompletionSerializationTest.kt @@ -50,6 +50,13 @@ class ValueOrCompletionSerializationTest : deserialized shouldBe event } + test("Value (null)") { + val event = ValueOrCompletion.Value(null) + val bytes = fory.serialize(event) + val deserialized = fory.deserialize(bytes) + deserialized shouldBe event + } + test("Completion") { val event = ValueOrCompletion.Completion(null) val bytes = fory.serialize(event) @@ -92,7 +99,7 @@ class ValueOrCompletionSerializationTest : val bytes = foryNoType.serialize(event) val deserialized = foryNoType.deserialize(bytes) as ValueOrCompletion.Completion deserialized.throwable.shouldBeInstanceOf() - deserialized.throwable?.message shouldBe "aah" + deserialized.throwable.message shouldBe "aah" } } }, diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionSerializationTest.kt index 3aa5da0..af89189 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionSerializationTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/ValueOrCompletionSerializationTest.kt @@ -20,6 +20,14 @@ class ValueOrCompletionSerializationTest : deserialized shouldBe event } + test("Value (null)") { + val event: ValueOrCompletion = ValueOrCompletion.Value(null) + val json = mapper.writeValueAsString(event) + val deserialized = + mapper.readValue(json, object : TypeReference>() {}) + deserialized shouldBe event + } + test("Completion (null)") { val event: ValueOrCompletion = ValueOrCompletion.Completion(null) val json = mapper.writeValueAsString(event) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionSerializationTest.kt index cf1579f..17bd87b 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionSerializationTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/ValueOrCompletionSerializationTest.kt @@ -19,6 +19,14 @@ class ValueOrCompletionSerializationTest : deserialized shouldBe event } + test("Value (null)") { + val event: ValueOrCompletion = ValueOrCompletion.Value(null) + val json = mapper.writeValueAsString(event) + val deserialized = + mapper.readValue(json, object : TypeReference>() {}) + deserialized shouldBe event + } + test("Completion (null)") { val event: ValueOrCompletion = ValueOrCompletion.Completion(null) val json = mapper.writeValueAsString(event)