Skip to content

fix: JSON converter mapper caches, record component serde annotations, and declared-type writes - #448

Merged
zantvoort merged 2 commits into
mainfrom
fix/json-converter-caches
Aug 11, 2026
Merged

fix: JSON converter mapper caches, record component serde annotations, and declared-type writes#448
zantvoort merged 2 commits into
mainfrom
fix/json-converter-caches

Conversation

@zantvoort

@zantvoort zantvoort commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #410.

Mapper cache keying (the issue's three points)

  • The Jackson converters key their mapper cache on the field's shape. The sealed types are collected by walking the full generic type (raw type, type arguments, array components, wildcard bounds), so a sealed interface reached through a container type — @Json List<Shape> — gets its permitted subtypes registered and the discriminator resolves for container elements. Previously only a top-level sealed field triggered registration.
  • When a custom @JsonSerialize/@JsonDeserialize is present, the raw field type it is registered against joins the cache key. Two fields of different types sharing one serializer class each get a mapper serving their own type; previously the second field silently reused the first field's mapper and bypassed its serializer. Fields without custom serde keep sharing the plain mapper.
  • The kotlinx cache is keyed on the @Json flags alone — buildJson never read the sealed component — bounding the map at one mapper per flag combination (four) and dropping the Class retention.

Two further bugs surfaced by the regression tests

  • Record component annotations: @JsonSerialize/@JsonDeserialize do not declare the RECORD_COMPONENT target, so javac propagates them to the backing field, accessor and constructor parameter — and RecordComponent.getAnnotations() returns nothing. The converter's custom-serde branch was unreachable, and the existing round-trip test could not detect that: a bypassed serializer plus a bypassed deserializer still round-trips. DefaultORMReflectionImpl now folds component, backing field, accessor and constructor parameter annotations together (equal instances propagated to several sites collapse, keeping single-instance lookups unambiguous), matching the parameter + property merge storm-kotlin already does.
  • Declared-type serialization: toDatabase serialized with the value's erased runtime type, so @Json List<Shape> wrote elements without @type and the same converter could not read them back. Serialization now uses an ObjectWriter for the declared TypeReference, aligning the write side with the read side (and with the kotlinx converter, which always serialized via the declared type's serializer).

Behavior note: a field declared as a non-polymorphic supertype holding a subclass value now writes the declared properties only. The previous output wrote the subclass properties, which reading either dropped silently or rejected under @Json(failOnUnknown = true); subtype fidelity is expressed with @JsonTypeInfo, which now works in containers too.

Verification

  • New regression tests in jackson2 and jackson3: shared custom serializer across two field types (asserting the raw stored strings, so a bypassed serializer cannot pass), sealed-list read via discriminator, and round trips for sealed values top-level, in List and in Map.
  • Probes against the exact dependency versions (jackson-databind 2.17.0 and 3.0.0) confirm identical semantics on both generations for the polymorphic, container, exact-type, supertype and Object-declared cases.
  • Suites green: storm-jackson2 116, storm-jackson3 126, storm-kotlinx-serialization 196, storm-core 2605, storm-kotlin 1715, storm-test and storm-spring.

Kotlin interop coverage

storm-jackson2's suite now includes a Kotlin interop test compiled from src/test/kotlin, with storm-kotlin and jackson-module-kotlin on the test class path: the configuration every Kotlin application has. It pins the language seams the Java suite cannot reach — serde annotations on Kotlin constructor properties (which land on the constructor parameter, where storm-kotlin's parameter + property merge finds them), Kotlin sealed hierarchies top-level and as container elements (Kotlin emits the JVM PermittedSubclasses attribute, so the sealed-type walk sees them), one serializer class shared by fields of different Kotlin types, and Java sealed hierarchies enumerated through KClass.sealedSubclasses. The existing Java tests in the module now also run with the Kotlin reflection provider active, which delegates Java records to the default implementation; storm-jackson3's suite keeps covering the pure-Java configuration. storm-jackson2 stands at 116 tests, all green.

…, and declared-type writes

The Jackson converters key their mapper cache on the field's shape: the
sealed types collected from the full generic type and, when a custom
serializer or deserializer is present, the raw field type it is
registered for. Sealed interfaces reached through container types
(List, Set, Map, arrays) get their permitted subtypes registered, so
the discriminator resolves for container elements. The kotlinx cache
is keyed on the Json flags alone, bounding it at one mapper per flag
combination.

RecordField metadata now includes annotations that Java propagates to
the backing field, accessor or constructor parameter; an annotation
only reaches the record component itself when its targets include
RECORD_COMPONENT, which third-party annotations rarely declare.
JsonSerialize/JsonDeserialize on a Json record component are therefore
honored.

Json fields serialize with the declared field type instead of the
value's erased runtime type, so a polymorphic value writes the
discriminator that reading the column expects.

Fixes #410
storm-jackson2's suite gains a Kotlin interop test compiled from
src/test/kotlin, with storm-kotlin and jackson-module-kotlin on the
test class path: the configuration every Kotlin application has. It
covers the language seams the Java suite cannot reach: serde
annotations on Kotlin constructor properties, Kotlin sealed
hierarchies top-level and as container elements, one serializer class
shared by fields of different Kotlin types, and Java sealed
hierarchies enumerated through Kotlin reflection. The existing Java
tests now also run with the Kotlin provider active, which delegates
Java records to the default reflection; storm-jackson3's suite keeps
covering the pure-Java configuration.
@zantvoort
zantvoort merged commit bca9428 into main Aug 11, 2026
7 checks passed
@zantvoort
zantvoort deleted the fix/json-converter-caches branch August 11, 2026 13:12
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...core/repository/impl/DefaultORMReflectionImpl.java 94.73% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON converter caches: field-type-blind cache key, dead sealed-subtype registration for containers, unbounded kotlinx cache

1 participant