mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-10-21 06:41:12 +00:00
* 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:
co-authored by
robin.bygrave
parent
c47fbd411b
commit
959951203d
+38
-75
@@ -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<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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user