From 22619f034bf8b69b4bdc7afdb1dd6de9d5305e11 Mon Sep 17 00:00:00 2001 From: Hyun Lee Date: Mon, 14 Sep 2026 19:36:08 -0500 Subject: [PATCH] Resolve nested bean properties with generic context Retain the declaring property type when creating nested bean wrappers, and use its generic context for property and container conversions. Preserve more specific types supplied by concrete runtime classes. Closes gh-27760 Signed-off-by: Hyun Lee --- .../beans/BeanWrapperImpl.java | 56 ++++- .../beans/BeanWrapperNestedGenericsTests.java | 227 ++++++++++++++++++ 2 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 spring-beans/src/test/java/org/springframework/beans/BeanWrapperNestedGenericsTests.java diff --git a/spring-beans/src/main/java/org/springframework/beans/BeanWrapperImpl.java b/spring-beans/src/main/java/org/springframework/beans/BeanWrapperImpl.java index 09cb89f49b20..d7f7c30ed59c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/BeanWrapperImpl.java +++ b/spring-beans/src/main/java/org/springframework/beans/BeanWrapperImpl.java @@ -18,6 +18,7 @@ import java.beans.PropertyDescriptor; import java.lang.reflect.Method; +import java.util.Optional; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; @@ -49,6 +50,7 @@ * @author Juergen Hoeller * @author Rob Harrop * @author Stephane Nicoll + * @author Hyun Lee * @since 15 April 2001 * @see #registerCustomEditor * @see #setPropertyValues @@ -66,6 +68,11 @@ public class BeanWrapperImpl extends AbstractNestablePropertyAccessor implements */ private @Nullable CachedIntrospectionResults cachedIntrospectionResults; + /** + * Generic context inherited from the property containing this nested bean. + */ + private @Nullable ResolvableType beanType; + /** * Create a new empty BeanWrapperImpl. Wrapped instance needs to be set afterwards. @@ -122,6 +129,17 @@ public BeanWrapperImpl(Object object, String nestedPath, Object rootObject) { */ private BeanWrapperImpl(Object object, String nestedPath, BeanWrapperImpl parent) { super(object, nestedPath, parent); + String propertyName = nestedPath.substring(parent.getNestedPath().length(), nestedPath.length() - 1); + TypeDescriptor descriptor = parent.getPropertyTypeDescriptor(propertyName); + if (descriptor != null) { + ResolvableType type = descriptor.getResolvableType(); + if (descriptor.getType() == Optional.class) { + type = type.getGeneric(); + } + if (type.hasResolvableGenerics()) { + this.beanType = type; + } + } } @@ -134,6 +152,7 @@ private BeanWrapperImpl(Object object, String nestedPath, BeanWrapperImpl parent public void setBeanInstance(Object object) { this.wrappedObject = object; this.rootObject = object; + this.beanType = null; this.typeConverterDelegate = new TypeConverterDelegate(this, this.wrappedObject); setIntrospectionClass(object.getClass()); } @@ -141,6 +160,7 @@ public void setBeanInstance(Object object) { @Override public void setWrappedInstance(Object object, @Nullable String nestedPath, @Nullable Object rootObject) { super.setWrappedInstance(object, nestedPath, rootObject); + this.beanType = null; setIntrospectionClass(getWrappedClass()); } @@ -184,7 +204,7 @@ private CachedIntrospectionResults getCachedIntrospectionResults() { throw new InvalidPropertyException(getRootClass(), getNestedPath() + propertyName, "No property '" + propertyName + "' found"); } - TypeDescriptor td = ((GenericTypeAwarePropertyDescriptor) pd).getTypeDescriptor(); + TypeDescriptor td = new BeanPropertyHandler((GenericTypeAwarePropertyDescriptor) pd).toTypeDescriptor(); return convertForProperty(propertyName, null, value, td); } @@ -233,33 +253,57 @@ public BeanPropertyHandler(GenericTypeAwarePropertyDescriptor pd) { this.pd = pd; } + @Override + public @Nullable Class getPropertyType() { + return (beanType != null ? toTypeDescriptor().getType() : super.getPropertyType()); + } + @Override public TypeDescriptor toTypeDescriptor() { - return this.pd.getTypeDescriptor(); + TypeDescriptor descriptor = this.pd.getTypeDescriptor(); + if (beanType == null) { + return descriptor; + } + ResolvableType type = resolveType(descriptor.getResolvableType()); + return new TypeDescriptor(type, type.resolve(descriptor.getType()), descriptor.getAnnotations()); } @Override public ResolvableType getResolvableType() { - return this.pd.getReadMethodType(); + return resolveType(this.pd.getReadMethodType()); + } + + private ResolvableType resolveType(ResolvableType type) { + if (beanType != null && type.getSource() instanceof MethodParameter parameter) { + ResolvableType owner = beanType.as(parameter.getDeclaringClass()); + if (owner != ResolvableType.NONE) { + ResolvableType resolved = ResolvableType.forMethodParameter(parameter, owner); + // Retain a more specific type supplied by the runtime class. + if (type.isAssignableFrom(resolved)) { + return resolved; + } + } + } + return type; } @Override public TypeDescriptor getMapValueType(int nestingLevel) { return new TypeDescriptor( - this.pd.getReadMethodType().getNested(nestingLevel).asMap().getGeneric(1), + getResolvableType().getNested(nestingLevel).asMap().getGeneric(1), null, this.pd.getTypeDescriptor().getAnnotations()); } @Override public TypeDescriptor getCollectionType(int nestingLevel) { return new TypeDescriptor( - this.pd.getReadMethodType().getNested(nestingLevel).asCollection().getGeneric(), + getResolvableType().getNested(nestingLevel).asCollection().getGeneric(), null, this.pd.getTypeDescriptor().getAnnotations()); } @Override public @Nullable TypeDescriptor nested(int level) { - return this.pd.getTypeDescriptor().nested(level); + return toTypeDescriptor().nested(level); } @Override diff --git a/spring-beans/src/test/java/org/springframework/beans/BeanWrapperNestedGenericsTests.java b/spring-beans/src/test/java/org/springframework/beans/BeanWrapperNestedGenericsTests.java new file mode 100644 index 000000000000..006ecb7bc46f --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/BeanWrapperNestedGenericsTests.java @@ -0,0 +1,227 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.beans; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.core.convert.support.DefaultConversionService; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for generic type resolution through nested bean properties. + * + * @author Hyun Lee + */ +class BeanWrapperNestedGenericsTests { + + @ParameterizedTest + @ValueSource(strings = {"filter.value", "enumFilter.value", "filters[0].value", + "filterMap[key].value", "optionalFilter.value", "nestedFilter.value.value"}) + void nestedGenericEnum(String path) { + BeanWrapper wrapper = new BeanWrapperImpl(new FilterHolder()); + wrapper.setAutoGrowNestedPaths(true); + + wrapper.setPropertyValue(path, "FIRST"); + + assertThat(wrapper.getPropertyValue(path)).isSameAs(State.FIRST); + assertThat(wrapper.getPropertyType(path)).isEqualTo(State.class); + assertThat(wrapper.getPropertyTypeDescriptor(path).getType()).isEqualTo(State.class); + } + + @Test + void nestedGenericArrayAndCollection() { + BeanWrapper wrapper = new BeanWrapperImpl(new FilterHolder()); + wrapper.setAutoGrowNestedPaths(true); + + wrapper.setPropertyValue("filter.values", new String[] {"FIRST", "SECOND"}); + wrapper.setPropertyValue("filter.list[0]", "SECOND"); + wrapper.setPropertyValue("filter.map[FIRST]", "SECOND"); + + assertThat(wrapper.getPropertyValue("filter.values")).isEqualTo(new State[] {State.FIRST, State.SECOND}); + assertThat(wrapper.getPropertyValue("filter.list[0]")).isSameAs(State.SECOND); + assertThat(wrapper.getPropertyValue("filter.map[FIRST]")).isSameAs(State.SECOND); + } + + @Test + void independentGenericDeclarations() { + BeanWrapper wrapper = new BeanWrapperImpl(new FilterHolder()); + wrapper.setAutoGrowNestedPaths(true); + + wrapper.setPropertyValue("filter.value", "FIRST"); + wrapper.setPropertyValue("otherFilter.value", "FIRST"); + + assertThat(wrapper.getPropertyValue("filter.value")).isSameAs(State.FIRST); + assertThat(wrapper.getPropertyValue("otherFilter.value")).isSameAs(OtherState.FIRST); + } + + @Test + void concreteRuntimeSubclass() { + FilterHolder holder = new FilterHolder(); + holder.setFilter(new StateFilter()); + BeanWrapper wrapper = new BeanWrapperImpl(holder); + + wrapper.setPropertyValue("filter.value", "FIRST"); + + assertThat(holder.getFilter().getValue()).isSameAs(State.FIRST); + } + + @Test + void concreteRuntimeSubclassWithWildcardDeclaration() { + FilterHolder holder = new FilterHolder(); + BeanWrapper wrapper = new BeanWrapperImpl(holder); + + wrapper.setPropertyValue("wildcardFilter.value", "FIRST"); + + assertThat(holder.getWildcardFilter().getValue()).isSameAs(State.FIRST); + } + + @Test + void genericRuntimeSubclass() { + FilterHolder holder = new FilterHolder(); + holder.setFilter(new GenericFilter<>()); + BeanWrapper wrapper = new BeanWrapperImpl(holder); + + wrapper.setPropertyValue("filter.value", "FIRST"); + + assertThat(holder.getFilter().getValue()).isSameAs(State.FIRST); + } + + @Test + void nestedGenericEnumWithConversionService() { + FilterHolder holder = new FilterHolder(); + BeanWrapper wrapper = new BeanWrapperImpl(holder); + wrapper.setConversionService(new DefaultConversionService()); + + wrapper.setPropertyValue("enumFilter.value", "FIRST"); + + assertThat(holder.getEnumFilter().getValue()).isSameAs(State.FIRST); + } + + + enum State { FIRST, SECOND } + + enum OtherState { FIRST } + + static class Filter { + + private T value; + + private T[] values; + + private final List list = new ArrayList<>(); + + private final Map map = new HashMap<>(); + + public T getValue() { + return this.value; + } + + public void setValue(T value) { + this.value = value; + } + + public T[] getValues() { + return this.values; + } + + public void setValues(T[] values) { + this.values = values; + } + + public Map getMap() { + return this.map; + } + + public List getList() { + return this.list; + } + } + + static class EnumFilter> extends Filter { + } + + static class GenericFilter extends Filter { + } + + static class StateFilter extends Filter { + } + + static class FilterHolder { + + private Filter filter; + + private final EnumFilter enumFilter = new EnumFilter<>(); + + private final Filter> wildcardFilter = new StateFilter(); + + private final Filter otherFilter = new Filter<>(); + + private final List> filters = new ArrayList<>(); + + private final Map> filterMap = new HashMap<>(); + + private final Optional> optionalFilter = Optional.of(new Filter<>()); + + private final Filter> nestedFilter = new Filter<>(); + + public Filter getFilter() { + return this.filter; + } + + public void setFilter(Filter filter) { + this.filter = filter; + } + + public Filter> getWildcardFilter() { + return this.wildcardFilter; + } + + public EnumFilter getEnumFilter() { + return this.enumFilter; + } + + public Filter getOtherFilter() { + return this.otherFilter; + } + + public List> getFilters() { + return this.filters; + } + + public Map> getFilterMap() { + return this.filterMap; + } + + public Optional> getOptionalFilter() { + return this.optionalFilter; + } + + public Filter> getNestedFilter() { + return this.nestedFilter; + } + } + +}