Skip to content

Resolve generic types with one resolver for both mappers (plan item 4.4) - #4435

Merged
evanchooly merged 2 commits into
masterfrom
generic-type-resolver
Nov 6, 2026
Merged

evanchooly merged 2 commits into
masterfrom
generic-type-resolver

Conversation

@evanchooly

@evanchooly evanchooly commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Plan item 4.4 (#19, A6): one generic-type resolver for both mappers.

Changes

  • New TypeResolver (internal). For each class it binds every type variable its supertypes declare to the type the hierarchy gives it. The binding is by TypeVariable identity, not by name or position. It follows superclasses and interfaces, and the bindings are cached per class with a ClassValue. resolve(context, type) substitutes those bindings through ParameterizedType, GenericArrayType and wildcards. A generic array whose component resolves to a class becomes that array class, so T[] with T = String is String[], the same as a declared String[].
  • TypeData.get uses it for the type arguments it reports, replacing the positional TypeParameters. TypeData.Builder.build() no longer wraps a list that the constructor copies anyway.
  • The reflective mapper resolves every field and getter type against the entity: EntityModel.getTypeData(Type). This replaces the by-name parameterization map, propagateTypes and the 3-arg getTypeData, which only handled a bare T.
  • Critter's PropertyModelGenerator calls the same resolver and drops its own, which kept only plain-class bindings. GenerationUtils.emitTypeData now emits WildCardTypeData rather than flattening wildcards.
  • The game plan marks 4.4 done.

Fixes

Case Before After
class ReorderedMap<V,K> extends HashMap<K,V>, field ReorderedMap<String,Integer> key String, value Integer: swapped key Integer, value String
Base<T>{T value; List<T> values;}, Sub extends Base<List<String>> reflection List<String> / List<Object>; critter Object for value List<String> / List<List<String>> in both
Middle<T,M> extends Root<List<T>>, Leaf extends Middle<String,Integer>, Root's T value reflection List<Object>, critter Object List<String> in both
ArrayBase<T>{T[] array;}, StringArray extends ArrayBase<String> Object (array) String[]
List<? extends Number>, List<? super Integer> in critter List<Number>, List<Object> List<? extends Number>, List<? extends Object>, as in reflection

Tests

  • New TestGenericResolution covers each row above, plus getter-based properties, round trips for the reordered map, the List<String> binding and a List<? super Integer> holding a string and an integer, and an unbound T[]. Each assertion runs against whichever mapper the build selects; under critter it also asserts that critter built the model (a CritterEntityModel) instead of falling back to reflection, except for the array entities critter doesn't support yet. The new resolution cases failed before this change with both mappers. Without the emitTypeData change, the two wildcard cases still fail under critter.
  • TypeDataTest gains a binding through an interface (Holder<T>) and a subclass of its implementor.
  • Generated models: I ran the critter AOT leg locally (generate-test-models, then the tests against the generated models) for these tests.
  • Full suites: core, validation and Kotlin: 0 failures with -Dmorphia.mapper=critter and with reflection.

Decisions to review

  1. Critter shares the resolver instead of getting a second one over classfile signatures, as the plan expected. PropertyModelGenerator already reads each member's generic type through reflection; only typeData(String), which nothing outside tests calls, parses signatures. One resolver keeps the mappers from drifting. Generated models still need no reflection at runtime, since the resolved TypeData is emitted as constants.
  2. ? super X still decodes as its upper bound, Object, contrary to the plan, which listed it as a bug. A List<? super Integer> can hold a List<Object> with strings in it, and decoding it as Integer threw ClassCastException (caught in review; the mixed-list round trip now covers it). The plan entry records this.
  3. Interface bindings are included. That covers a getter declared in a generic interface. They can't conflict with superclass bindings, because bindings are keyed by identity.
  4. EntityModel.getTypeData(Class, TypeData, Type) is replaced by getTypeData(Type). The class is @MorphiaInternal and revapi excludes it.
  5. Still open, not regressions:
    • Critter skips array-typed properties in both runtime and AOT mode, so the array cases only exercise reflection.
    • A type variable of an enclosing class (Outer<T>.Inner) still resolves to Object.
    • Mutually recursive generics (Foo extends Base<List<Foo>>) still recurse in TypeData.get, as before.

Benchmarks

CodecBenchmark, before (master at 1ef42e8) and after, both mappers, -prof gc. This step only changes how TypeData is built at mapping time, so the encode/decode path shouldn't move, and allocation doesn't: every B/op is within 1%. The first full run showed reflection 3–12% slower across the board, encode included, and critter 2–7% faster, the kind of uniform shift that means machine noise. Rerunning reflection over three alternating rounds puts every median within ±1.3% (the branch's outliers in round 1 coincide with test runs I had going).

reflection: full run
benchmark model before ns/op after ns/op Δ time before B/op after B/op
decode - 25,241 ± 503 25,391 ± 580 +0.6% 47,433 47,769
decode ARRAYS 26,438 ± 184 27,285 ± 438 +3.2% 15,569 15,569
decode COLLECTIONS 8,038 ± 57 8,976 ± 232 +11.7% 16,704 16,712
decode CONCRETE_COLLECTIONS 2,635 ± 45 2,672 ± 39 +1.4% 5,664 5,672
decode LAZY_REFERENCE 2,566 ± 22 2,694 ± 70 +5.0% 6,072 6,072
decode LIFECYCLE 682 ± 32 721 ± 51 +5.7% 1,704 1,704
decode NESTED 2,752 ± 48 2,904 ± 95 +5.5% 5,656 5,648
decode POLYMORPHIC 4,900 ± 93 5,184 ± 167 +5.8% 9,544 9,544
decode SIMPLE 1,183 ± 236 1,086 ± 88 -8.2% 2,112 2,112
decodeInCursor ARRAYS 26,835 ± 232 28,781 ± 347 +7.3% 15,257 15,257
decodeInCursor COLLECTIONS 8,371 ± 70 8,816 ± 218 +5.3% 16,392 16,400
decodeInCursor CONCRETE_COLLECTIONS 2,781 ± 40 2,855 ± 95 +2.7% 5,360 5,360
decodeInCursor LAZY_REFERENCE 2,759 ± 27 2,870 ± 57 +4.0% 5,784 5,784
decodeInCursor LIFECYCLE 696 ± 42 735 ± 37 +5.7% 1,392 1,392
decodeInCursor NESTED 2,692 ± 42 3,220 ± 472 +19.6% 5,344 5,344
decodeInCursor POLYMORPHIC 4,994 ± 130 5,549 ± 591 +11.1% 9,224 9,216
decodeInCursor SIMPLE 1,025 ± 45 1,138 ± 85 +11.1% 1,800 1,808
encode - 21,723 ± 250 22,253 ± 271 +2.4% 22,769 22,769
encode ARRAYS 31,955 ± 1,106 33,271 ± 1,108 +4.1% 98,721 98,721
encode COLLECTIONS 7,520 ± 89 8,250 ± 466 +9.7% 7,336 7,335
encode CONCRETE_COLLECTIONS 2,078 ± 38 2,127 ± 23 +2.4% 3,125 3,104
encode LAZY_REFERENCE 1,823 ± 33 1,812 ± 29 -0.6% 3,843 3,832
encode LIFECYCLE 371 ± 4 384 ± 9 +3.6% 1,144 1,144
encode NESTED 2,243 ± 28 2,346 ± 67 +4.6% 1,856 1,856
encode POLYMORPHIC 4,051 ± 42 4,205 ± 90 +3.8% 4,200 4,200
encode SIMPLE 751 ± 13 799 ± 12 +6.4% 1,216 1,216
critter: full run
benchmark model before ns/op after ns/op Δ time before B/op after B/op
decode - 27,235 ± 354 26,722 ± 351 -1.9% 47,517 47,601
decode ARRAYS 27,411 ± 244 26,500 ± 225 -3.3% 15,569 15,569
decode COLLECTIONS 9,073 ± 183 8,893 ± 111 -2.0% 16,712 16,712
decode CONCRETE_COLLECTIONS 2,789 ± 59 2,795 ± 95 +0.2% 5,664 5,672
decode LAZY_REFERENCE 2,723 ± 72 2,627 ± 56 -3.5% 6,072 6,072
decode LIFECYCLE 694 ± 13 665 ± 8 -4.2% 1,704 1,704
decode NESTED 3,165 ± 64 3,064 ± 149 -3.2% 5,656 5,648
decode POLYMORPHIC 5,680 ± 89 5,547 ± 229 -2.4% 9,552 9,552
decode SIMPLE 1,090 ± 32 1,082 ± 53 -0.7% 2,112 2,112
decodeInCursor ARRAYS 27,372 ± 323 27,052 ± 247 -1.2% 15,257 15,257
decodeInCursor COLLECTIONS 9,045 ± 135 9,078 ± 191 +0.4% 16,400 16,400
decodeInCursor CONCRETE_COLLECTIONS 2,804 ± 52 2,839 ± 99 +1.2% 5,360 5,360
decodeInCursor LAZY_REFERENCE 2,788 ± 72 2,732 ± 41 -2.0% 5,784 5,784
decodeInCursor LIFECYCLE 705 ± 31 690 ± 30 -2.0% 1,392 1,392
decodeInCursor NESTED 3,256 ± 76 3,127 ± 30 -4.0% 5,344 5,352
decodeInCursor POLYMORPHIC 5,639 ± 91 5,572 ± 125 -1.2% 9,240 9,240
decodeInCursor SIMPLE 1,067 ± 33 1,081 ± 60 +1.4% 1,800 1,800
encode - 22,591 ± 613 21,875 ± 290 -3.2% 22,769 22,769
encode ARRAYS 32,933 ± 256 31,660 ± 298 -3.9% 98,721 98,721
encode COLLECTIONS 7,798 ± 82 7,631 ± 85 -2.1% 7,336 7,336
encode CONCRETE_COLLECTIONS 2,097 ± 27 2,047 ± 16 -2.4% 3,104 3,104
encode LAZY_REFERENCE 1,717 ± 27 1,663 ± 20 -3.2% 3,832 3,832
encode LIFECYCLE 378 ± 17 352 ± 7 -6.9% 1,144 1,144
encode NESTED 2,381 ± 19 2,283 ± 13 -4.1% 1,856 1,856
encode POLYMORPHIC 4,194 ± 56 4,125 ± 41 -1.7% 4,200 4,200
encode SIMPLE 760 ± 56 727 ± 4 -4.4% 1,216 1,216

Reflection rerun, 3 alternating rounds (ns/op per round, Δ of medians):

benchmark model before after Δ
decode - 24,257, 24,485, 24,355 24,581, 24,756, 24,596 +1.0%
decode COLLECTIONS 8,282, 8,433, 8,138 11,812, 8,392, 8,198 +1.3%
decode NESTED 2,677, 2,738, 2,693 4,384, 2,717, 2,618 +0.9%
decode POLYMORPHIC 4,904, 4,945, 4,853 6,331, 4,967, 4,954 +1.3%
decode SIMPLE 1,081, 1,009, 1,028 1,495, 1,037, 1,011 +1.0%
encode - 21,472, 21,421, 21,443 21,687, 21,400, 21,431 -0.1%
encode COLLECTIONS 7,473, 7,646, 7,506 7,726, 7,359, 7,450 -0.7%
encode NESTED 2,212, 2,196, 2,220 2,283, 2,234, 2,237 +1.1%
encode POLYMORPHIC 4,044, 3,989, 3,996 4,025, 4,047, 4,000 +0.7%
encode SIMPLE 750, 756, 745 1,020, 759, 752 +1.3%

TypeResolver binds each type variable a class's supertypes declare to the
type the hierarchy gives it, by identity rather than by name or position,
cached per class with a ClassValue. TypeData, the reflective mapper and
critter's PropertyModelGenerator all use it, replacing three separate
implementations.

Fixes a generic map subclass that reorders its type parameters getting
its key and value types swapped, `? super` wildcards losing their bound,
type variables nested in a field's type (List<T>) or bound to a
parameterized type staying unresolved, critter keeping only plain-class
bindings, and critter flattening wildcards in the TypeData it emits.
Copilot AI balanced review requested due to automatic review settings November 6, 2026 00:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Lower-bound codec selection breaks valid mixed-value contravariant collections, while the Critter tests can mask generation failures by accepting reflective fallback.

2 open issues
What changed in this PR

This PR centralizes generic-type resolution for Morphia’s reflective and Critter mappers, addressing plan item 4.4 and reducing differences in their type metadata.

Changes:

  • Adds a cached resolver for inherited type variables, nested generics, arrays, and wildcards.
  • Integrates shared resolution into both mappers and preserves wildcard metadata in generated models.
  • Adds regression tests for generic bindings and round trips, and marks the plan item complete.
File Description
design/​mapping-game-plan.md Marks item 4.4 complete and explains the shared-resolver approach.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​TestGenericResolution.java Adds generic-resolution and round-trip coverage.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​pojo/​TypeDataTest.java Tests inherited interface bindings.
core/​src/​main/​java/​dev/​morphia/​mapping/​conventions/​MethodDiscovery.java Resolves getter types against the entity.
core/​src/​main/​java/​dev/​morphia/​mapping/​conventions/​FieldDiscovery.java Resolves field types against the entity.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​WildCardTypeData.java Exposes wildcard construction and bound direction.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​TypeResolver.java Implements cached hierarchy bindings and type substitution.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​TypeData.java Uses shared argument resolution and preserves lower bounds.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityModel.java Replaces name-based resolution with the shared resolver.
core/​src/​main/​java/​dev/​morphia/​critter/​parser/​generator/​PropertyModelGenerator.java Replaces Critter’s separate resolver.
core/​src/​main/​java/​dev/​morphia/​critter/​parser/​generator/​GenerationUtils.java Emits wildcard metadata in generated models.

Review effort: Balanced

A List<? super Integer> can hold a List<Object>'s values, so decoding its
elements as Integer threw ClassCastException on a mixed list; the wildcard
keeps resolving to its upper bound, as before. TestGenericResolution now
round-trips such a list, and under critter asserts the model is a
CritterEntityModel so a fallback to reflection can't pass unnoticed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain to block approval of the shared generic-type resolution change.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@evanchooly
evanchooly merged commit 3a0cbaf into master Nov 6, 2026
5 checks passed
@evanchooly
evanchooly deleted the generic-type-resolver branch November 6, 2026 02:49
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.

2 participants