From 917eb65884dea93343520a9700cce35854f2df5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:22:49 +0000 Subject: [PATCH] Support polymorphic type info for unsigned integer types The UByte/UShort/UInt/ULong serializers extended StdSerializer directly and did not implement `serializeWithType()`, so an unsigned number failed to serialize whenever type information was required for it: an `Any` property under default typing, a property annotated with `@JsonTypeInfo` (including collection contents), or a nullable unsigned property under `DefaultTyping.EVERYTHING`. Schema generators were also told the type is "any". Make the serializers extend StdScalarSerializer, whose `serializeWithType()` writes the type id followed by the numeric value, and implement `acceptJsonFormatVisitor()` with the integer format. The deserializers already read the typed form and are left unchanged. Ported from FasterXML/jackson-module-kotlin#1228. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0137oSJi6gZrpgW99q17VZVL --- .../ser/serializers/KotlinSerializers.kt | 22 ++- .../UnsignedNumbersJsonFormatVisitorTest.kt | 64 ++++++++ .../test/UnsignedNumbersTypeInfoTest.kt | 142 ++++++++++++++++++ 3 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersJsonFormatVisitorTest.kt create mode 100644 src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersTypeInfoTest.kt diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt index 12cffa8c..e1a73f02 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/serializers/KotlinSerializers.kt @@ -2,12 +2,15 @@ package io.github.projectmapk.jackson.module.kogera.ser.serializers import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser.NumberType import com.fasterxml.jackson.databind.BeanDescription import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.SerializationConfig import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper import com.fasterxml.jackson.databind.module.SimpleSerializers +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer import com.fasterxml.jackson.databind.ser.std.StdSerializer import io.github.projectmapk.jackson.module.kogera.ANY_TO_ANY_METHOD_TYPE import io.github.projectmapk.jackson.module.kogera.GenericValueClassUnboxConverter @@ -29,28 +32,37 @@ import java.lang.reflect.Method import java.lang.reflect.Modifier import java.math.BigInteger -internal object UByteSerializer : StdSerializer(UByte::class.java) { +// The unsigned integer serializers extend StdScalarSerializer so that polymorphic type information +// (e.g. default typing on an `Any` property) is written as for other scalar values, +// and report the integer format to schema generators (StdScalarSerializer assumes a string by default). +internal object UByteSerializer : StdScalarSerializer(UByte::class.java) { private fun readResolve(): Any = UByteSerializer override fun serialize(value: UByte, gen: JsonGenerator, provider: SerializerProvider) = gen .writeNumber(value.toShort()) + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.INT) } -internal object UShortSerializer : StdSerializer(UShort::class.java) { +internal object UShortSerializer : StdScalarSerializer(UShort::class.java) { private fun readResolve(): Any = UShortSerializer override fun serialize(value: UShort, gen: JsonGenerator, provider: SerializerProvider) = gen .writeNumber(value.toInt()) + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.INT) } -internal object UIntSerializer : StdSerializer(UInt::class.java) { +internal object UIntSerializer : StdScalarSerializer(UInt::class.java) { private fun readResolve(): Any = UIntSerializer override fun serialize(value: UInt, gen: JsonGenerator, provider: SerializerProvider) = gen .writeNumber(value.toLong()) + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.LONG) } -internal object ULongSerializer : StdSerializer(ULong::class.java) { +internal object ULongSerializer : StdScalarSerializer(ULong::class.java) { private fun readResolve(): Any = ULongSerializer override fun serialize(value: ULong, gen: JsonGenerator, provider: SerializerProvider) { @@ -60,6 +72,8 @@ internal object ULongSerializer : StdSerializer(ULong::class.java) { else -> gen.writeNumber(BigInteger(value.toString())) } } + + override fun acceptJsonFormatVisitor(visitor: JsonFormatVisitorWrapper, typeHint: JavaType?) = visitIntFormat(visitor, typeHint, NumberType.BIG_INTEGER) } // Class must be UnboxableValueClass. diff --git a/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersJsonFormatVisitorTest.kt b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersJsonFormatVisitorTest.kt new file mode 100644 index 00000000..a0c1aed7 --- /dev/null +++ b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersJsonFormatVisitorTest.kt @@ -0,0 +1,64 @@ +package io.github.projectmapk.jackson.module.kogera.zPorted.test + +import com.fasterxml.jackson.core.JsonParser.NumberType +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonAnyFormatVisitor +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonStringFormatVisitor +import io.github.projectmapk.jackson.module.kogera.defaultMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The unsigned integer serializers report the integer format to schema generators + * (previously "any", the StdSerializer default). + */ +internal class UnsignedNumbersJsonFormatVisitorTest { + private class RecordingVisitor : JsonFormatVisitorWrapper.Base() { + var format: String? = null + var numberType: NumberType? = null + + override fun expectIntegerFormat(type: JavaType): JsonIntegerFormatVisitor { + format = "integer" + + return object : JsonIntegerFormatVisitor.Base() { + override fun numberType(type: NumberType) { + numberType = type + } + } + } + + override fun expectAnyFormat(type: JavaType): JsonAnyFormatVisitor? { + format = "any" + return super.expectAnyFormat(type) + } + + override fun expectStringFormat(type: JavaType): JsonStringFormatVisitor? { + format = "string" + return super.expectStringFormat(type) + } + } + + private fun visit(type: Class<*>): RecordingVisitor = + RecordingVisitor().also { defaultMapper.acceptJsonFormatVisitor(type, it) } + + private fun assertInteger(type: Class<*>, numberType: NumberType) { + val visitor = visit(type) + + assertEquals("integer", visitor.format) + assertEquals(numberType, visitor.numberType) + } + + @Test + fun `UByte is an integer`() = assertInteger(UByte::class.java, NumberType.INT) + + @Test + fun `UShort is an integer`() = assertInteger(UShort::class.java, NumberType.INT) + + @Test + fun `UInt is an integer`() = assertInteger(UInt::class.java, NumberType.LONG) + + @Test + fun `ULong is an integer`() = assertInteger(ULong::class.java, NumberType.BIG_INTEGER) +} diff --git a/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersTypeInfoTest.kt b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersTypeInfoTest.kt new file mode 100644 index 00000000..642e15c5 --- /dev/null +++ b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zPorted/test/UnsignedNumbersTypeInfoTest.kt @@ -0,0 +1,142 @@ +package io.github.projectmapk.jackson.module.kogera.zPorted.test + +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.JsonTypeInfo.As +import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator +import io.github.projectmapk.jackson.module.kogera.defaultMapper +import io.github.projectmapk.jackson.module.kogera.jacksonMapperBuilder +import io.github.projectmapk.jackson.module.kogera.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * Unsigned numbers stored in a position that requires polymorphic type information + * (an `Any` property under default typing, or a property annotated with `@JsonTypeInfo`) + * are written with a type id, as other scalar values are, e.g. `["kotlin.UInt",1]`. + * + * Reading such input already worked before, so the round-trip tests check that + * the written form is the one the deserializers expect. + */ +internal class UnsignedNumbersTypeInfoTest { + data class AnyHolder(val value: Any) + + data class AnnotatedHolder(@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) val value: Any) + + data class AnnotatedWrapperObjectHolder( + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = As.WRAPPER_OBJECT) val value: Any, + ) + + private val typeValidator = BasicPolymorphicTypeValidator.builder().allowIfBaseType(Any::class.java).build() + + private val defaultTypingMapper = jacksonMapperBuilder() + .activateDefaultTyping(typeValidator, DefaultTyping.OBJECT_AND_NON_CONCRETE, As.PROPERTY) + .build() + + // The default validator denies `Any` as base type, so one that allows it is needed for the `Any` properties. + private val annotatedMapper = jacksonMapperBuilder().polymorphicTypeValidator(typeValidator).build() + + abstract inner class ForType(private val value: T, private val json: String) { + private val id = value::class.java.name + + @Test + fun `default typing - type id is written`() { + assertEquals("""{"value":["$id",$json]}""", defaultTypingMapper.writeValueAsString(AnyHolder(value))) + } + + @Test + fun `default typing - round trip`() { + val expected = AnyHolder(value) + val json = defaultTypingMapper.writeValueAsString(expected) + + assertEquals(expected, defaultTypingMapper.readValue(json)) + } + + @Test + fun `JsonTypeInfo as property - type id is written`() { + assertEquals("""{"value":["$id",$json]}""", annotatedMapper.writeValueAsString(AnnotatedHolder(value))) + } + + @Test + fun `JsonTypeInfo as property - round trip`() { + val expected = AnnotatedHolder(value) + val json = annotatedMapper.writeValueAsString(expected) + + assertEquals(expected, annotatedMapper.readValue(json)) + } + + @Test + fun `JsonTypeInfo as wrapper object - type id is written`() { + assertEquals( + """{"value":{"$id":$json}}""", + annotatedMapper.writeValueAsString(AnnotatedWrapperObjectHolder(value)), + ) + } + + @Test + fun `JsonTypeInfo as wrapper object - round trip`() { + val expected = AnnotatedWrapperObjectHolder(value) + val json = annotatedMapper.writeValueAsString(expected) + + assertEquals(expected, annotatedMapper.readValue(json)) + } + } + + @Nested + inner class ForUByte : ForType(UByte.MAX_VALUE, "255") + + @Nested + inner class ForUShort : ForType(UShort.MAX_VALUE, "65535") + + @Nested + inner class ForUInt : ForType(UInt.MAX_VALUE, "4294967295") + + @Nested + inner class ForULong : ForType(ULong.MAX_VALUE, "18446744073709551615") + + data class AnnotatedListHolder(@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) val values: List) + + // @JsonTypeInfo on a collection property applies to its contents, + // so here the serializer is selected by the declared unsigned type rather than by the runtime type. + @Test + fun `JsonTypeInfo on collection contents`() { + val expected = AnnotatedListHolder(listOf(1u, UInt.MAX_VALUE)) + val json = annotatedMapper.writeValueAsString(expected) + + assertEquals("""{"values":[["kotlin.UInt",1],["kotlin.UInt",4294967295]]}""", json) + assertEquals(expected, annotatedMapper.readValue(json)) + } + + data class DeclaredHolder(val value: UInt, val nullable: UInt?) + + // The unsigned types are final, so default typing other than EVERYTHING + // does not apply to properties declared with them. + @Test + fun `not applied to declared properties under NON_FINAL`() { + val mapper = jacksonMapperBuilder() + .activateDefaultTyping(typeValidator, DefaultTyping.NON_FINAL, As.PROPERTY) + .build() + val expected = DeclaredHolder(1u, 2u) + val json = mapper.writeValueAsString(expected) + + assertEquals("""{"value":1,"nullable":2}""", json) + assertEquals(expected, mapper.readValue(json)) + } + + data class NullableHolder(val nullable: UInt?) + + // A nullable unsigned property is boxed on the JVM, so EVERYTHING applies type information to it. + // EVERYTHING also adds a type id to the root object, so only the property is compared. + @Test + fun `applied to nullable declared property under EVERYTHING`() { + val mapper = jacksonMapperBuilder() + .activateDefaultTyping(typeValidator, DefaultTyping.EVERYTHING, As.PROPERTY) + .build() + val expected = NullableHolder(2u) + val json = mapper.writeValueAsString(expected) + + assertEquals(defaultMapper.readTree("""["kotlin.UInt",2]"""), defaultMapper.readTree(json)["nullable"]) + assertEquals(expected, mapper.readValue(json)) + } +}