diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployCreateProperties.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployCreateProperties.java index 668491f8b2..64345a34ca 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployCreateProperties.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployCreateProperties.java @@ -8,10 +8,12 @@ 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 DeployCreateProperties(TypeManager typeManager) { * 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, B extends C) resolve correctly. + Map, Type> typeMap = TypeReflectHelper.typeVariableMap(desc.getBeanType()); + createProperties(desc, desc.getBeanType(), 0, typeMap); desc.sortProperties(); } @@ -63,10 +69,14 @@ private boolean ignoreField(Field field) { } /** - * 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, Class> genericTypeMap) { + private void createProperties( + DeployBeanDescriptor desc, + Class beanType, + int level, + Map, Type> typeMap + ) { if (beanType.equals(Model.class)) { // ignore all fields on model (_$dbName) return; @@ -76,7 +86,7 @@ private void createProperties(DeployBeanDescriptor desc, Class beanType, i 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 +106,10 @@ private void createProperties(DeployBeanDescriptor desc, Class beanType, i 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 +129,28 @@ private DeployBeanProperty createManyType(DeployBeanDescriptor desc, Class return new DeployBeanPropertyAssocMany<>(desc, targetType, manyType); } - private DeployBeanProperty createProp(DeployBeanDescriptor desc, Field field, Map, Class> genericTypeMap) { - Class propertyType = field.getGenericType() instanceof TypeVariable - ? genericTypeMap.get(field.getGenericType()) - : field.getType(); + private DeployBeanProperty createProp( + DeployBeanDescriptor desc, + Field field, + Map, 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, field.getGenericType()); + 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 +160,25 @@ private DeployBeanProperty createProp(DeployBeanDescriptor desc, Field field, } 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 +202,8 @@ private boolean isTransientField(Field field) { return AnnotationUtil.has(field, Transient.class); } - private DeployBeanProperty createProp(DeployBeanDescriptor desc, Field field, Class beanType, Map, Class> genericTypeMap) { - DeployBeanProperty prop = createProp(desc, field, genericTypeMap); + private DeployBeanProperty createProp(DeployBeanDescriptor desc, Field field, Class beanType, Map, Type> typeMap) { + DeployBeanProperty prop = createProp(desc, field, typeMap); if (prop == null) { // transient annotation on unsupported type return null; @@ -193,75 +219,21 @@ private DeployBeanProperty createProp(DeployBeanDescriptor desc, Field field, * 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, 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, 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; - } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java index 2793fec035..0183427119 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeReflectHelper.java @@ -2,10 +2,20 @@ 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, 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 static Type getValueType(Type collectionType) { 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, 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 @@ private static Class getClass(Type type) { 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; + } + } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeResolver.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeResolver.java index e469291d18..37f8df164d 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeResolver.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/TypeResolver.java @@ -136,7 +136,7 @@ private static Class resolveRawClass(Type genericType, Class subType) { return genericType instanceof Class ? (Class) genericType : Unknown.class; } - private static Map, Type> getTypeVariableMap(final Class targetType) { + static Map, Type> getTypeVariableMap(final Class targetType) { Map, Type> map = new HashMap<>(); // Populate interfaces populateSuperTypeArgs(targetType.getGenericInterfaces(), map); diff --git a/ebean-core/src/test/java/io/ebeaninternal/server/type/TypeReflectHelperTest.java b/ebean-core/src/test/java/io/ebeaninternal/server/type/TypeReflectHelperTest.java index 0f60d5b786..10bea5b2a3 100644 --- a/ebean-core/src/test/java/io/ebeaninternal/server/type/TypeReflectHelperTest.java +++ b/ebean-core/src/test/java/io/ebeaninternal/server/type/TypeReflectHelperTest.java @@ -14,10 +14,13 @@ 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 @@ private static class Some { List 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 → 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 → 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> → element type arg is List; raw = List.class + Field f = Nested.class.getDeclaredField("matrix"); + ParameterizedType outer = (ParameterizedType) f.getGenericType(); + Type inner = outer.getActualTypeArguments()[0]; // List — a ParameterizedType + assertThat(TypeReflectHelper.resolveCollectionTarget(inner)).isEqualTo(List.class); + } + + // --- resolveType + typeVariableMap: single-level generic superclass --- + + @Test + void resolveType_singleLevel_typeVariable() throws NoSuchFieldException { + Map, 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, 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, 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 extends GenericBase + // Before fix: GenericBase.T was unresolved when walking from GenericMiddle → GenericBase + Map, 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, 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, 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 { + @SuppressWarnings("unused") T value; + @SuppressWarnings("unused") List values; + @SuppressWarnings("unused") Set valueSet; + @SuppressWarnings("unused") String name; + } + + /** Concrete subclass - one level: T = String. */ + private static class SingleLevelConcrete extends GenericBase {} + + /** Intermediate generic class - two levels deep: T is still unresolved here. */ + private static class GenericMiddle extends GenericBase {} + + /** Concrete subclass - two levels: T = Long, resolved through GenericMiddle. */ + private static class MultiLevelConcrete extends GenericMiddle {} + + private static class Nested { + @SuppressWarnings("unused") List> matrix; } + private enum TestEnum { A, B, C } + + private static class GenericFixtures { + @SuppressWarnings("unused") List wildEnums; + @SuppressWarnings("unused") List enums; + } + + private static class RichText {} + private static class RichTextConverter extends Direct {} - private static class Direct implements ScalarTypeConverter { + private static class Direct implements ScalarTypeConverter { @Override public M getNullValue() { diff --git a/ebean-querybean/src/test/java/org/example/domain/GenericMiddleModel.java b/ebean-querybean/src/test/java/org/example/domain/GenericMiddleModel.java new file mode 100644 index 0000000000..63d4e6c781 --- /dev/null +++ b/ebean-querybean/src/test/java/org/example/domain/GenericMiddleModel.java @@ -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 extends GenericBaseModel}. + */ +@MappedSuperclass +public abstract class GenericMiddleModel extends GenericBaseModel { +} diff --git a/ebean-querybean/src/test/java/org/example/domain/ProductWithGenericMiddle.java b/ebean-querybean/src/test/java/org/example/domain/ProductWithGenericMiddle.java new file mode 100644 index 0000000000..a999e2755f --- /dev/null +++ b/ebean-querybean/src/test/java/org/example/domain/ProductWithGenericMiddle.java @@ -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 extends GenericBaseModel}. + * + *

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 { + + String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/ebean-querybean/src/test/java/org/querytest/QProductWithGenericTest.java b/ebean-querybean/src/test/java/org/querytest/QProductWithGenericTest.java index d3f19c292a..10ad591217 100644 --- a/ebean-querybean/src/test/java/org/querytest/QProductWithGenericTest.java +++ b/ebean-querybean/src/test/java/org/querytest/QProductWithGenericTest.java @@ -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 @@ void findByStringId() { 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. + * + *

ProductWithGenericMiddle extends GenericMiddleModel<Long> extends GenericBaseModel<Long> + * — 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"); + } }