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; + } + } + +}