* Fix NPE resolving generic types across multi-level mapped superclass hierarchies

The fix introduced in ebc90e0e resolved TypeVariables only one level at a time:
mapGenerics(beanType) read only the direct generic superclass, so for a chain like
A extends B<String> / B<T> extends C<T>, processing C's fields produced an empty
map, genericTypeMap.get(TypeVariable) returned null, propertyType became null, and
AnnotationFields.readField threw an NPE calling prop.getPropertyType().isEnum().

Fix: build the full type-variable map once for the concrete bean type using
TypeResolver.getTypeVariableMap, which walks the entire superclass/interface
hierarchy and composes TypeVariable bindings transitively. The same map is passed
at every level of the recursive createProperties walk, so any TypeVariable at any
depth resolves correctly.

Type-resolution helpers (resolveType, resolveToClass, resolveCollectionTarget,
ResolvedParameterizedType) are consolidated in TypeReflectHelper so they are
shared across callers and independently testable.

Tests: TypeReflectHelperTest covers single and multi-level TypeVariable resolution
and collection-element resolution. QProductWithGenericTest adds an integration
regression test using a two-level generic chain
(ProductWithGenericMiddle extends GenericMiddleModel<Long> extends GenericBaseModel<Long>).

* Restore prior format only on DeployCreateProperties

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
This commit is contained in:
Andrey Glushkov
2026-06-30 16:18:41 +12:00
committed by GitHub
co-authored by robin.bygrave
parent c47fbd411b
commit 959951203d
7 changed files with 377 additions and 83 deletions
@@ -8,10 +8,12 @@ import io.ebeaninternal.api.CoreLog;
import io.ebeaninternal.server.deploy.ManyType;
import io.ebeaninternal.server.deploy.meta.*;
import io.ebeaninternal.server.type.TypeManager;
import io.ebeaninternal.server.type.TypeReflectHelper;
import jakarta.persistence.Convert;
import jakarta.persistence.PersistenceException;
import jakarta.persistence.Transient;
import jakarta.persistence.*;
import java.lang.reflect.*;
import java.util.HashMap;
import java.util.Map;
import static java.lang.System.Logger.Level.*;
@@ -37,7 +39,11 @@ public final class DeployCreateProperties {
* Create the appropriate properties for a bean.
*/
public void createProperties(DeployBeanDescriptor<?> desc) {
createProperties(desc, desc.getBeanType(), 0, new HashMap<>());
// Build the full type-variable map once for the entire hierarchy. TypeReflectHelper
// (via TypeResolver) walks superclasses and interfaces, composing mappings so that
// multi-level generic hierarchies (A extends B<T>, B<T> extends C<T>) resolve correctly.
Map<TypeVariable<?>, Type> typeMap = TypeReflectHelper.typeVariableMap(desc.getBeanType());
createProperties(desc, desc.getBeanType(), 0, typeMap);
desc.sortProperties();
}
@@ -63,10 +69,9 @@ public final class DeployCreateProperties {
}
/**
* properties the bean properties from Class. Some of these properties may not map to database
* columns.
* Create the bean properties from Class. Some of these properties may not map to database columns.
*/
private void createProperties(DeployBeanDescriptor<?> desc, Class<?> beanType, int level, Map<TypeVariable<?>, Class<?>> genericTypeMap) {
private void createProperties(DeployBeanDescriptor<?> desc, Class<?> beanType, int level, Map<TypeVariable<?>, Type> typeMap) {
if (beanType.equals(Model.class)) {
// ignore all fields on model (_$dbName)
return;
@@ -76,7 +81,7 @@ public final class DeployCreateProperties {
for (int i = 0; i < fields.length; i++) {
Field field = fields[i];
if (!ignoreField(field)) {
DeployBeanProperty prop = createProp(desc, field, beanType, genericTypeMap);
DeployBeanProperty prop = createProp(desc, field, beanType, typeMap);
if (prop != null) {
// set a order that gives priority to inherited properties
// push Id/EmbeddedId up and CreatedTimestamp/UpdatedTimestamp down
@@ -96,9 +101,10 @@ public final class DeployCreateProperties {
Class<?> superClass = beanType.getSuperclass();
if (!superClass.equals(Object.class)) {
// recursively add any properties in the inheritance hierarchy
// up to the Object.class level...
createProperties(desc, superClass, level + 1, mapGenerics(beanType));
// up to the Object.class level - the same typeMap covers the full hierarchy
createProperties(desc, superClass, level + 1, typeMap);
}
} catch (PersistenceException ex) {
throw ex;
} catch (Exception ex) {
@@ -118,18 +124,24 @@ public final class DeployCreateProperties {
return new DeployBeanPropertyAssocMany<>(desc, targetType, manyType);
}
private DeployBeanProperty createProp(DeployBeanDescriptor<?> desc, Field field, Map<TypeVariable<?>, Class<?>> genericTypeMap) {
Class<?> propertyType = field.getGenericType() instanceof TypeVariable<?>
? genericTypeMap.get(field.getGenericType())
: field.getType();
if (isSpecialScalarType(field)) {
return new DeployBeanProperty(desc, propertyType, field.getGenericType());
private DeployBeanProperty createProp(DeployBeanDescriptor<?> desc, Field field, Map<TypeVariable<?>, Type> typeMap) {
// Resolve the field's generic type through the accumulated type-variable map.
// This handles TypeVariables from generic superclasses at any depth in the hierarchy.
Type resolvedGenericType = TypeReflectHelper.resolveType(field.getGenericType(), typeMap);
Class<?> propertyType = TypeReflectHelper.resolveToClass(resolvedGenericType);
if (propertyType == null) {
propertyType = field.getType();
}
if (isSpecialScalarType(field)) {
return new DeployBeanProperty(desc, propertyType, resolvedGenericType);
}
// check for Collection type (list, set or map)
ManyType manyType = determineManyType.manyType(propertyType);
if (manyType != null) {
// List, Set or Map based object
Class<?> targetType = determineTargetType(field);
Class<?> targetType = determineTargetType(resolvedGenericType);
if (targetType == null) {
if (AnnotationUtil.has(field, Transient.class)) {
// not supporting this field (generic type used)
@@ -139,20 +151,25 @@ public final class DeployCreateProperties {
}
return createManyType(desc, targetType, manyType);
}
if (propertyType.isEnum() || propertyType.isPrimitive()) {
return new DeployBeanProperty(desc, propertyType, null, null);
}
ScalarType<?> scalarType = typeManager.type(propertyType);
if (scalarType != null) {
return new DeployBeanProperty(desc, propertyType, scalarType, null);
}
if (isTransientField(field)) {
// return with no ScalarType (still support JSON features)
return new DeployBeanProperty(desc, propertyType, null, null);
}
if (AnnotationUtil.has(field, Convert.class)) {
throw new IllegalStateException("No AttributeConverter registered for type " + propertyType + " at " + desc.getFullName() + "." + field.getName());
}
try {
return new DeployBeanPropertyAssocOne<>(desc, propertyType);
} catch (Exception e) {
@@ -176,8 +193,8 @@ public final class DeployCreateProperties {
return AnnotationUtil.has(field, Transient.class);
}
private DeployBeanProperty createProp(DeployBeanDescriptor<?> desc, Field field, Class<?> beanType, Map<TypeVariable<?>, Class<?>> genericTypeMap) {
DeployBeanProperty prop = createProp(desc, field, genericTypeMap);
private DeployBeanProperty createProp(DeployBeanDescriptor<?> desc, Field field, Class<?> beanType, Map<TypeVariable<?>, Type> typeMap) {
DeployBeanProperty prop = createProp(desc, field, typeMap);
if (prop == null) {
// transient annotation on unsupported type
return null;
@@ -193,75 +210,21 @@ public final class DeployCreateProperties {
* Determine the type of the List,Set or Map. Not been set explicitly so determine this from
* ParameterizedType.
*/
private Class<?> determineTargetType(Field field) {
Type genType = field.getGenericType();
private Class<?> determineTargetType(Type genType) {
if (genType instanceof ParameterizedType) {
ParameterizedType ptype = (ParameterizedType) genType;
Type[] typeArgs = ptype.getActualTypeArguments();
if (typeArgs.length == 1) {
// expecting set or list
if (typeArgs[0] instanceof Class<?>) {
return (Class<?>) typeArgs[0];
}
if (typeArgs[0] instanceof WildcardType) {
final Type[] upperBounds = ((WildcardType) typeArgs[0]).getUpperBounds();
if (upperBounds.length == 1 && upperBounds[0] instanceof Class<?>) {
// kotlin generated wildcard type
return (Class<?>) upperBounds[0];
}
}
// throw new RuntimeException("Unexpected Parameterised Type? "+typeArgs[0]);
return null;
return TypeReflectHelper.resolveCollectionTarget(typeArgs[0]);
}
if (typeArgs.length == 2) {
// this is probably a Map
if (typeArgs[1] instanceof ParameterizedType) {
// not supporting ParameterizedType on Map.
return null;
}
if (typeArgs[1] instanceof WildcardType) {
return Object.class;
}
return (Class<?>) typeArgs[1];
return TypeReflectHelper.resolveCollectionTarget(typeArgs[1]);
}
}
// if targetType is null, then must be set in annotations
return null;
}
private Map<TypeVariable<?>, Class<?>> mapGenerics(Class<?> clazz) {
Type genericSuperclass = clazz.getGenericSuperclass();
if (!(genericSuperclass instanceof ParameterizedType)) {
return new HashMap<>();
}
ParameterizedType parameterized = (ParameterizedType) genericSuperclass;
TypeVariable<?>[] typeVars = ((Class<?>) parameterized.getRawType()).getTypeParameters();
Type[] actualTypes = parameterized.getActualTypeArguments();
Map<TypeVariable<?>, Class<?>> typeMap = new HashMap<>();
for (int i = 0; i < typeVars.length; i++) {
Type actual = actualTypes[i];
Class<?> resolvedClass = resolveToClass(actual);
if (resolvedClass != null) {
typeMap.put(typeVars[i], resolvedClass);
} else {
// ignore
}
}
return typeMap;
}
private static Class<?> resolveToClass(Type type) {
if (type instanceof Class<?>) {
return (Class<?>) type;
} else if (type instanceof ParameterizedType) {
ParameterizedType pType = (ParameterizedType) type;
Type raw = pType.getRawType();
if (raw instanceof Class<?>) {
return (Class<?>) raw;
}
}
return null;
}
}
@@ -2,10 +2,20 @@ package io.ebeaninternal.server.type;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.lang.reflect.WildcardType;
import java.util.Map;
public final class TypeReflectHelper {
/**
* Return the full map of TypeVariable to resolved Type for the given class and its entire
* superclass/interface hierarchy. Used to resolve generic field types in mapped superclasses.
*/
public static Map<TypeVariable<?>, Type> typeVariableMap(Class<?> targetType) {
return TypeResolver.getTypeVariableMap(targetType);
}
public static Class<?>[] getParams(Class<?> cls, Class<?> matchRawType) {
return TypeResolver.resolveRawArgs(matchRawType, cls);
}
@@ -53,6 +63,85 @@ public final class TypeReflectHelper {
return typeArgs[0];
}
/**
* Return the raw Class for a Type, handling Class, ParameterizedType, and WildcardType.
* Returns null when the raw class cannot be determined.
*/
public static Class<?> resolveToClass(Type type) {
if (type instanceof Class) {
return (Class<?>) type;
}
if (type instanceof ParameterizedType) {
Type raw = ((ParameterizedType) type).getRawType();
if (raw instanceof Class) {
return (Class<?>) raw;
}
}
if (type instanceof WildcardType) {
Type[] upperBounds = ((WildcardType) type).getUpperBounds();
if (upperBounds.length == 1) {
return resolveToClass(upperBounds[0]);
}
}
return null;
}
/**
* Return the element class for a collection or map type argument (handles Class,
* ParameterizedType raw, and Kotlin-generated wildcard upper bounds).
*/
public static Class<?> resolveCollectionTarget(Type typeArg) {
if (typeArg instanceof Class) {
return (Class<?>) typeArg;
}
if (typeArg instanceof ParameterizedType) {
Type rawType = ((ParameterizedType) typeArg).getRawType();
if (rawType instanceof Class) {
return (Class<?>) rawType;
}
return null;
}
if (typeArg instanceof WildcardType) {
// kotlin generated wildcard type
Type[] upperBounds = ((WildcardType) typeArg).getUpperBounds();
if (upperBounds.length == 1) {
return resolveToClass(upperBounds[0]);
}
}
return null;
}
/**
* Resolve a Type through the given type-variable map, recursively substituting any bound
* TypeVariables and rebuilding ParameterizedTypes whose arguments changed.
*/
public static Type resolveType(Type type, Map<TypeVariable<?>, Type> typeMap) {
if (type instanceof TypeVariable) {
TypeVariable<?> typeVariable = (TypeVariable<?>) type;
Type resolved = typeMap.get(typeVariable);
return resolved != null ? resolveType(resolved, typeMap) : typeVariable;
}
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
Type[] actualArgs = parameterizedType.getActualTypeArguments();
Type[] resolvedArgs = new Type[actualArgs.length];
boolean changed = false;
for (int i = 0; i < actualArgs.length; i++) {
resolvedArgs[i] = resolveType(actualArgs[i], typeMap);
changed |= resolvedArgs[i] != actualArgs[i];
}
if (!changed) {
return parameterizedType;
}
return new ResolvedParameterizedType(
parameterizedType.getOwnerType(),
parameterizedType.getRawType(),
resolvedArgs
);
}
return type;
}
private static Class<?> getClass(Type type) {
while (true) {
if (type instanceof ParameterizedType) {
@@ -69,4 +158,31 @@ public final class TypeReflectHelper {
return (Class<?>) type;
}
}
private static final class ResolvedParameterizedType implements ParameterizedType {
private final Type ownerType;
private final Type rawType;
private final Type[] actualTypeArguments;
ResolvedParameterizedType(Type ownerType, Type rawType, Type[] actualTypeArguments) {
this.ownerType = ownerType;
this.rawType = rawType;
this.actualTypeArguments = actualTypeArguments;
}
@Override
public Type[] getActualTypeArguments() {
return actualTypeArguments.clone();
}
@Override
public Type getRawType() {
return rawType;
}
@Override
public Type getOwnerType() {
return ownerType;
}
}
}
@@ -136,7 +136,7 @@ final class TypeResolver {
return genericType instanceof Class ? (Class<?>) genericType : Unknown.class;
}
private static Map<TypeVariable<?>, Type> getTypeVariableMap(final Class<?> targetType) {
static Map<TypeVariable<?>, Type> getTypeVariableMap(final Class<?> targetType) {
Map<TypeVariable<?>, Type> map = new HashMap<>();
// Populate interfaces
populateSuperTypeArgs(targetType.getGenericInterfaces(), map);
@@ -14,10 +14,13 @@ import jakarta.persistence.AttributeConverter;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -117,13 +120,168 @@ public class TypeReflectHelperTest {
List<Order.Status> orderStatus = new ArrayList<>();
}
private static class RichText {
// --- resolveToClass ---
@Test
void resolveToClass_rawClass() {
assertThat(TypeReflectHelper.resolveToClass(String.class)).isEqualTo(String.class);
}
@Test
void resolveToClass_parameterizedType() throws NoSuchFieldException {
Field f = Some.class.getDeclaredField("orderStatus");
assertThat(TypeReflectHelper.resolveToClass(f.getGenericType())).isEqualTo(List.class);
}
@Test
void resolveToClass_wildcardType() throws NoSuchFieldException {
// List<? extends TestEnum> → wildcard upper bound = TestEnum
Field f = GenericFixtures.class.getDeclaredField("wildEnums");
Type wildcard = ((ParameterizedType) f.getGenericType()).getActualTypeArguments()[0];
assertThat(TypeReflectHelper.resolveToClass(wildcard)).isEqualTo(TestEnum.class);
}
@Test
void resolveToClass_unknown_returnsNull() throws NoSuchFieldException {
// A bare TypeVariable has no binding → should return null, not throw
Field f = GenericBase.class.getDeclaredField("value");
Type typeVar = f.getGenericType(); // TypeVariable T
assertThat(TypeReflectHelper.resolveToClass(typeVar)).isNull();
}
// --- resolveCollectionTarget ---
@Test
void resolveCollectionTarget_rawClass() {
assertThat(TypeReflectHelper.resolveCollectionTarget(String.class)).isEqualTo(String.class);
}
@Test
void resolveCollectionTarget_enumClass() throws NoSuchFieldException {
// List<TestEnum> → element type arg is TestEnum (plain Class)
Field f = GenericFixtures.class.getDeclaredField("enums");
ParameterizedType listType = (ParameterizedType) f.getGenericType();
Type arg = listType.getActualTypeArguments()[0];
assertThat(TypeReflectHelper.resolveCollectionTarget(arg)).isEqualTo(TestEnum.class);
}
@Test
void resolveCollectionTarget_nestedParameterizedType() throws NoSuchFieldException {
// List<List<String>> → element type arg is List<String>; raw = List.class
Field f = Nested.class.getDeclaredField("matrix");
ParameterizedType outer = (ParameterizedType) f.getGenericType();
Type inner = outer.getActualTypeArguments()[0]; // List<String> — a ParameterizedType
assertThat(TypeReflectHelper.resolveCollectionTarget(inner)).isEqualTo(List.class);
}
// --- resolveType + typeVariableMap: single-level generic superclass ---
@Test
void resolveType_singleLevel_typeVariable() throws NoSuchFieldException {
Map<TypeVariable<?>, Type> typeMap = TypeReflectHelper.typeVariableMap(SingleLevelConcrete.class);
Field field = GenericBase.class.getDeclaredField("value");
Type resolved = TypeReflectHelper.resolveType(field.getGenericType(), typeMap);
assertThat(TypeReflectHelper.resolveToClass(resolved)).isEqualTo(String.class);
}
@Test
void resolveType_singleLevel_collection() throws NoSuchFieldException {
Map<TypeVariable<?>, Type> typeMap = TypeReflectHelper.typeVariableMap(SingleLevelConcrete.class);
Field field = GenericBase.class.getDeclaredField("values");
Type resolved = TypeReflectHelper.resolveType(field.getGenericType(), typeMap);
assertThat(TypeReflectHelper.resolveToClass(resolved)).isEqualTo(List.class);
assertThat(((ParameterizedType) resolved).getActualTypeArguments()[0]).isEqualTo(String.class);
}
@Test
void resolveType_singleLevel_setCollection() throws NoSuchFieldException {
Map<TypeVariable<?>, Type> typeMap = TypeReflectHelper.typeVariableMap(SingleLevelConcrete.class);
Field field = GenericBase.class.getDeclaredField("valueSet");
Type resolved = TypeReflectHelper.resolveType(field.getGenericType(), typeMap);
assertThat(TypeReflectHelper.resolveToClass(resolved)).isEqualTo(Set.class);
assertThat(((ParameterizedType) resolved).getActualTypeArguments()[0]).isEqualTo(String.class);
}
// --- resolveType + typeVariableMap: multi-level generic hierarchy (bug regression) ---
@Test
void resolveType_multiLevel_typeVariable() throws NoSuchFieldException {
// MultiLevelConcrete extends GenericMiddle<Long> extends GenericBase<T>
// Before fix: GenericBase.T was unresolved when walking from GenericMiddle → GenericBase
Map<TypeVariable<?>, Type> typeMap = TypeReflectHelper.typeVariableMap(MultiLevelConcrete.class);
Field field = GenericBase.class.getDeclaredField("value");
Type resolved = TypeReflectHelper.resolveType(field.getGenericType(), typeMap);
assertThat(TypeReflectHelper.resolveToClass(resolved))
.as("GenericBase.T must resolve to Long through two levels of generic inheritance")
.isEqualTo(Long.class);
}
@Test
void resolveType_multiLevel_collection() throws NoSuchFieldException {
Map<TypeVariable<?>, Type> typeMap = TypeReflectHelper.typeVariableMap(MultiLevelConcrete.class);
Field field = GenericBase.class.getDeclaredField("values");
Type resolved = TypeReflectHelper.resolveType(field.getGenericType(), typeMap);
assertThat(TypeReflectHelper.resolveToClass(resolved)).isEqualTo(List.class);
assertThat(((ParameterizedType) resolved).getActualTypeArguments()[0])
.as("List element type must resolve to Long through two levels of generic inheritance")
.isEqualTo(Long.class);
}
@Test
void resolveType_nonGenericField_unchanged() throws NoSuchFieldException {
Map<TypeVariable<?>, Type> typeMap = TypeReflectHelper.typeVariableMap(MultiLevelConcrete.class);
Field field = GenericBase.class.getDeclaredField("name");
Type resolved = TypeReflectHelper.resolveType(field.getGenericType(), typeMap);
assertThat(resolved).isEqualTo(String.class);
}
// --- fixtures ---
/** Single-level generic base: one TypeVariable used as field type and collection element. */
private static class GenericBase<T> {
@SuppressWarnings("unused") T value;
@SuppressWarnings("unused") List<T> values;
@SuppressWarnings("unused") Set<T> valueSet;
@SuppressWarnings("unused") String name;
}
/** Concrete subclass - one level: T = String. */
private static class SingleLevelConcrete extends GenericBase<String> {}
/** Intermediate generic class - two levels deep: T is still unresolved here. */
private static class GenericMiddle<T> extends GenericBase<T> {}
/** Concrete subclass - two levels: T = Long, resolved through GenericMiddle. */
private static class MultiLevelConcrete extends GenericMiddle<Long> {}
private static class Nested {
@SuppressWarnings("unused") List<List<String>> matrix;
}
private enum TestEnum { A, B, C }
private static class GenericFixtures {
@SuppressWarnings("unused") List<? extends TestEnum> wildEnums;
@SuppressWarnings("unused") List<TestEnum> enums;
}
private static class RichText {}
private static class RichTextConverter extends Direct<RichText> {}
private static class Direct<M> implements ScalarTypeConverter<M, byte[]> {
private static class Direct<M> implements ScalarTypeConverter<M, byte[]> {
@Override
public M getNullValue() {
@@ -0,0 +1,12 @@
package org.example.domain;
import jakarta.persistence.MappedSuperclass;
/**
* Intermediate generic mapped superclass used to verify that type-variable resolution
* composes correctly across two levels of generic inheritance:
* {@code Concrete extends GenericMiddleModel<Long> extends GenericBaseModel<Long>}.
*/
@MappedSuperclass
public abstract class GenericMiddleModel<T> extends GenericBaseModel<T> {
}
@@ -0,0 +1,26 @@
package org.example.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
/**
* Entity that extends a two-level generic superclass chain:
* {@code ProductWithGenericMiddle extends GenericMiddleModel<Long> extends GenericBaseModel<Long>}.
*
* <p>Regression for the NPE in DeployCreateProperties when a TypeVariable could not be resolved
* because generic mappings were not composed across more than one superclass level.
*/
@Entity
@Table(name = "middle_product", schema = "foo")
public class ProductWithGenericMiddle extends GenericMiddleModel<Long> {
String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@@ -1,16 +1,13 @@
package org.querytest;
import io.ebean.InTuples;
import io.ebean.DB;
import org.example.domain.ProductWithGenericLong;
import org.example.domain.ProductWithGenericMiddle;
import org.example.domain.ProductWithGenericString;
import org.example.domain.query.QContact;
import org.example.domain.query.QProductWithGenericLong;
import org.example.domain.query.QProductWithGenericString;
import org.junit.jupiter.api.Test;
import java.time.ZonedDateTime;
import static org.assertj.core.api.Assertions.assertThat;
public class QProductWithGenericTest {
@@ -46,4 +43,26 @@ public class QProductWithGenericTest {
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("Gadget");
}
/**
* Regression test for the NPE that occurred when DeployCreateProperties processed an entity
* whose @Id type came from a TypeVariable two levels up in the generic superclass chain.
*
* <p>ProductWithGenericMiddle extends GenericMiddleModel&lt;Long&gt; extends GenericBaseModel&lt;Long&gt;
* — the id field is declared as {@code T id} in GenericBaseModel, which requires composing the
* generic type mappings across both superclass levels to resolve T → Long.
*/
@Test
void findById_multiLevelGenericSuperclass() {
var entity = new ProductWithGenericMiddle();
entity.setId(99L);
entity.setName("Widget");
entity.save();
var result = DB.find(ProductWithGenericMiddle.class, 99L);
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("Widget");
}
}