From 0b2befd0c3fee71bfe12abca444f32c0bb3d26b0 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Fri, 26 Jun 2026 00:33:00 +0300 Subject: [PATCH 1/2] chore: simplify TristateModule type-resolution and serializer wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the repeated JavaType-resolution boilerplate in the Tristate deserializer path into three file-private helpers — ANY_TYPE (the Object fallback for raw/Tristate<*>), JavaType.firstContainedOrNull(), and JavaType.isTristate() — so the inner-type lookup reads the same way at each of its call sites instead of being open-coded three times. Drop the no-op ContextualSerializer from TristateSerializer: its createContextual just returned this, which is how Jackson treats a non-contextual serializer anyway. Payload typing is already resolved per value through the dynamic PropertySerializerMap, so there is no per-property type to capture. The deserializer's ContextualDeserializer is genuine and stays. Rewrite TristateSerializerModifier.changeProperties with List.replaceAll so the wrap-or-keep decision is a single expression rather than an indexed loop mutating the list as it iterates. All three are behavior-preserving; every type involved is internal, so there is no public-API change. --- .../sdk/serde/jackson/TristateModule.kt | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt index 7a08a587..661c5340 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt @@ -24,7 +24,6 @@ import com.fasterxml.jackson.databind.deser.Deserializers import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.ser.BeanPropertyWriter import com.fasterxml.jackson.databind.ser.BeanSerializerModifier -import com.fasterxml.jackson.databind.ser.ContextualSerializer import com.fasterxml.jackson.databind.ser.impl.PropertySerializerMap import com.fasterxml.jackson.databind.type.TypeFactory import org.dexpace.sdk.core.serde.Tristate @@ -77,6 +76,15 @@ public class TristateModule : SimpleModule(MODULE_NAME, com.fasterxml.jackson.co } } +/** The element [JavaType] for `Object` — the fallback when a `Tristate` is raw or `Tristate<*>`. */ +private val ANY_TYPE: JavaType = TypeFactory.defaultInstance().constructType(Any::class.java) + +/** The first contained type argument, or `null` when the type is raw / carries no parameters. */ +private fun JavaType.firstContainedOrNull(): JavaType? = if (containedTypeCount() > 0) containedType(0) else null + +/** Whether this type is (or extends) [Tristate]. */ +private fun JavaType.isTristate(): Boolean = Tristate::class.java.isAssignableFrom(rawClass) + /** * Resolver that returns [TristateDeserializer] for any [Tristate] target type. Needed because * the default [SimpleModule.addDeserializer] path keys on the raw class but `Tristate<*>` calls @@ -88,15 +96,9 @@ internal class TristateDeserializers internal constructor() : Deserializers.Base config: DeserializationConfig, beanDesc: BeanDescription, ): JsonDeserializer<*>? = - if (Tristate::class.java.isAssignableFrom(type.rawClass)) { + if (type.isTristate()) { // Inner type defaults to Object when the user wrote `Tristate<*>` or raw `Tristate`. - val inner: JavaType = - if (type.containedTypeCount() > 0) { - type.containedType(0) - } else { - TypeFactory.defaultInstance().constructType(Any::class.java) - } - TristateDeserializer(inner) + TristateDeserializer(type.firstContainedOrNull() ?: ANY_TYPE) } else { null } @@ -114,9 +116,8 @@ internal class TristateDeserializer internal constructor( // `Tristate`. Falling back to the constructor-provided innerType lets callers that // deserialize Tristate directly (without a wrapping bean) still get correct typing. val resolved: JavaType = - property?.type?.takeIf { Tristate::class.java.isAssignableFrom(it.rawClass) }?.let { wrapper -> - if (wrapper.containedTypeCount() > 0) wrapper.containedType(0) else null - } ?: innerType ?: TypeFactory.defaultInstance().constructType(Any::class.java) + property?.type?.takeIf { it.isTristate() }?.firstContainedOrNull() + ?: innerType ?: ANY_TYPE return TristateDeserializer(resolved) } @@ -127,7 +128,7 @@ internal class TristateDeserializer internal constructor( if (p.currentToken() == JsonToken.VALUE_NULL) { return Tristate.Null } - val target = innerType ?: TypeFactory.defaultInstance().constructType(Any::class.java) + val target = innerType ?: ANY_TYPE val value: Any? = ctxt.readValue(p, target) return if (value == null) Tristate.Null else Tristate.Present(value) } @@ -155,18 +156,13 @@ internal class TristateDeserializer internal constructor( * implementation writes `null` for both Absent and Null — JSON itself has no "field is * missing" concept when there's no enclosing object to omit a key from. */ -internal class TristateSerializer internal constructor() : JsonSerializer>(), ContextualSerializer { +internal class TristateSerializer internal constructor() : JsonSerializer>() { // Cached per-type sub-serializer lookup so repeated calls with the same T don't pay // ObjectMapper lookup cost. PropertySerializerMap.findAndAddPrimarySerializer is the // canonical Jackson idiom for this. @Volatile private var dynamicSerializers: PropertySerializerMap = PropertySerializerMap.emptyForProperties() - override fun createContextual( - prov: SerializerProvider, - property: BeanProperty?, - ): JsonSerializer<*> = this - override fun serialize( value: Tristate<*>, gen: JsonGenerator, @@ -225,10 +221,8 @@ internal class TristateSerializerModifier internal constructor() : BeanSerialize beanDesc: BeanDescription, beanProperties: MutableList, ): MutableList { - beanProperties.forEachIndexed { i, writer -> - if (Tristate::class.java.isAssignableFrom(writer.type.rawClass)) { - beanProperties[i] = TristatePropertyWriter(writer) - } + beanProperties.replaceAll { writer -> + if (writer.type.isTristate()) TristatePropertyWriter(writer) else writer } return beanProperties } From 33993beddfb18420a44e61e644b11f2df836f644 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Fri, 26 Jun 2026 00:39:39 +0300 Subject: [PATCH 2/2] chore: move Tristate JavaType helpers into Extensions.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate ANY_TYPE, JavaType.firstContainedOrNull(), and JavaType.isTristate() out of TristateModule.kt into a dedicated Extensions.kt, and drop the now-unused TypeFactory import from TristateModule.kt. The helpers stay internal — they are module-private implementation details, not public API. --- .../dexpace/sdk/serde/jackson/Extensions.kt | 21 +++++++++++++++++++ .../sdk/serde/jackson/TristateModule.kt | 10 --------- 2 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt new file mode 100644 index 00000000..0d0f824e --- /dev/null +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.serde.jackson + +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.type.TypeFactory +import org.dexpace.sdk.core.serde.Tristate + +/** The element [JavaType] for `Object` — the fallback when a `Tristate` is raw or `Tristate<*>`. */ +internal val ANY_TYPE: JavaType = TypeFactory.defaultInstance().constructType(Any::class.java) + +/** The first contained type argument, or `null` when the type is raw / carries no parameters. */ +internal fun JavaType.firstContainedOrNull(): JavaType? = if (containedTypeCount() > 0) containedType(0) else null + +/** Whether this type is (or extends) [Tristate]. */ +internal fun JavaType.isTristate(): Boolean = Tristate::class.java.isAssignableFrom(rawClass) diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt index 661c5340..d3d9aa3a 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt @@ -25,7 +25,6 @@ import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.ser.BeanPropertyWriter import com.fasterxml.jackson.databind.ser.BeanSerializerModifier import com.fasterxml.jackson.databind.ser.impl.PropertySerializerMap -import com.fasterxml.jackson.databind.type.TypeFactory import org.dexpace.sdk.core.serde.Tristate /** @@ -76,15 +75,6 @@ public class TristateModule : SimpleModule(MODULE_NAME, com.fasterxml.jackson.co } } -/** The element [JavaType] for `Object` — the fallback when a `Tristate` is raw or `Tristate<*>`. */ -private val ANY_TYPE: JavaType = TypeFactory.defaultInstance().constructType(Any::class.java) - -/** The first contained type argument, or `null` when the type is raw / carries no parameters. */ -private fun JavaType.firstContainedOrNull(): JavaType? = if (containedTypeCount() > 0) containedType(0) else null - -/** Whether this type is (or extends) [Tristate]. */ -private fun JavaType.isTristate(): Boolean = Tristate::class.java.isAssignableFrom(rawClass) - /** * Resolver that returns [TristateDeserializer] for any [Tristate] target type. Needed because * the default [SimpleModule.addDeserializer] path keys on the raw class but `Tristate<*>` calls