Support empty @DefaultValue annotations on aggregates and optional

Update `ValueObjectBinder` to allow an empty `@DefaultValue` to be
used on map, collection, arrays and optional types.

Closes gh-32559
pull/32747/head
Phillip Webb 2 years ago
parent 8a93abfaaa
commit efc431bdc4

@ -1,5 +1,5 @@
/* /*
* Copyright 2012-2020 the original author or authors. * Copyright 2012-2022 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@
package org.springframework.boot.context.properties.bind; package org.springframework.boot.context.properties.bind;
import java.lang.annotation.Annotation; import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier; import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter; import java.lang.reflect.Parameter;
@ -25,6 +26,7 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import kotlin.reflect.KFunction; import kotlin.reflect.KFunction;
import kotlin.reflect.KParameter; import kotlin.reflect.KParameter;
@ -32,6 +34,7 @@ import kotlin.reflect.jvm.ReflectJvmMapping;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
import org.springframework.core.CollectionFactory;
import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.KotlinDetector; import org.springframework.core.KotlinDetector;
import org.springframework.core.MethodParameter; import org.springframework.core.MethodParameter;
@ -101,7 +104,7 @@ class ValueObjectBinder implements DataObjectBinder {
if (annotation instanceof DefaultValue) { if (annotation instanceof DefaultValue) {
String[] defaultValue = ((DefaultValue) annotation).value(); String[] defaultValue = ((DefaultValue) annotation).value();
if (defaultValue.length == 0) { if (defaultValue.length == 0) {
return getNewInstanceIfPossible(context, type); return getNewDefaultValueInstanceIfPossible(context, type);
} }
return convertDefaultValue(context.getConverter(), defaultValue, type, annotations); return convertDefaultValue(context.getConverter(), defaultValue, type, annotations);
} }
@ -124,7 +127,7 @@ class ValueObjectBinder implements DataObjectBinder {
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private <T> T getNewInstanceIfPossible(Binder.Context context, ResolvableType type) { private <T> T getNewDefaultValueInstanceIfPossible(Binder.Context context, ResolvableType type) {
Class<T> resolved = (Class<T>) type.resolve(); Class<T> resolved = (Class<T>) type.resolve();
Assert.state(resolved == null || isEmptyDefaultValueAllowed(resolved), Assert.state(resolved == null || isEmptyDefaultValueAllowed(resolved),
() -> "Parameter of type " + type + " must have a non-empty default value."); () -> "Parameter of type " + type + " must have a non-empty default value.");
@ -132,14 +135,27 @@ class ValueObjectBinder implements DataObjectBinder {
if (instance != null) { if (instance != null) {
return instance; return instance;
} }
return (resolved != null) ? BeanUtils.instantiateClass(resolved) : null; if (resolved != null) {
if (Optional.class == resolved) {
return (T) Optional.empty();
}
if (Collection.class.isAssignableFrom(resolved)) {
return (T) CollectionFactory.createCollection(resolved, 0);
}
if (Map.class.isAssignableFrom(resolved)) {
return (T) CollectionFactory.createMap(resolved, 0);
}
if (resolved.isArray()) {
return (T) Array.newInstance(resolved.getComponentType(), 0);
}
return BeanUtils.instantiateClass(resolved);
}
return null;
} }
private boolean isEmptyDefaultValueAllowed(Class<?> type) { private boolean isEmptyDefaultValueAllowed(Class<?> type) {
if (type.isPrimitive() || type.isEnum() || isAggregate(type) || type.getName().startsWith("java.lang")) { return (Optional.class == type || isAggregate(type))
return false; || !(type.isPrimitive() || type.isEnum() || type.getName().startsWith("java.lang"));
}
return true;
} }
private boolean isAggregate(Class<?> type) { private boolean isAggregate(Class<?> type) {

@ -29,6 +29,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
@ -853,6 +854,17 @@ class ConfigurationPropertiesTests {
assertThat(bean.getBar()).isEqualTo(0); assertThat(bean.getBar()).isEqualTo(0);
} }
@Test
void loadWhenBindingToConstructorParametersWithEmptyDefaultValueShouldBind() {
load(ConstructorParameterEmptyDefaultValueConfiguration.class);
ConstructorParameterEmptyDefaultValueProperties bean = this.context
.getBean(ConstructorParameterEmptyDefaultValueProperties.class);
assertThat(bean.getSet()).isEmpty();
assertThat(bean.getMap()).isEmpty();
assertThat(bean.getArray()).isEmpty();
assertThat(bean.getOptional()).isEmpty();
}
@Test @Test
void loadWhenBindingToConstructorParametersWithDefaultDataUnitShouldBind() { void loadWhenBindingToConstructorParametersWithDefaultDataUnitShouldBind() {
load(ConstructorParameterWithUnitConfiguration.class); load(ConstructorParameterWithUnitConfiguration.class);
@ -2145,6 +2157,45 @@ class ConfigurationPropertiesTests {
} }
@ConstructorBinding
@ConfigurationProperties(prefix = "test")
static class ConstructorParameterEmptyDefaultValueProperties {
private final Set<String> set;
private final Map<String, String> map;
private final int[] array;
private final Optional<String> optional;
ConstructorParameterEmptyDefaultValueProperties(@DefaultValue Set<String> set,
@DefaultValue Map<String, String> map, @DefaultValue int[] array,
@DefaultValue Optional<String> optional) {
this.set = set;
this.map = map;
this.array = array;
this.optional = optional;
}
Set<String> getSet() {
return this.set;
}
Map<String, String> getMap() {
return this.map;
}
int[] getArray() {
return this.array;
}
Optional<String> getOptional() {
return this.optional;
}
}
@ConstructorBinding @ConstructorBinding
@ConfigurationProperties(prefix = "test") @ConfigurationProperties(prefix = "test")
static class ConstructorParameterWithUnitProperties { static class ConstructorParameterWithUnitProperties {
@ -2225,6 +2276,11 @@ class ConfigurationPropertiesTests {
} }
@EnableConfigurationProperties(ConstructorParameterEmptyDefaultValueProperties.class)
static class ConstructorParameterEmptyDefaultValueConfiguration {
}
@EnableConfigurationProperties(ConstructorParameterWithUnitProperties.class) @EnableConfigurationProperties(ConstructorParameterWithUnitProperties.class)
static class ConstructorParameterWithUnitConfiguration { static class ConstructorParameterWithUnitConfiguration {

@ -31,6 +31,7 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledForJreRange; import org.junit.jupiter.api.condition.EnabledForJreRange;
@ -293,29 +294,31 @@ class ValueObjectBinderTests {
} }
@Test @Test
void bindWhenCollectionParameterWithEmptyDefaultValueShouldThrowException() { void bindWhenCollectionParameterWithEmptyDefaultValueShouldReturnEmptyInstance() {
assertThatExceptionOfType(BindException.class) NestedConstructorBeanWithEmptyDefaultValueForCollectionTypes bound = this.binder.bindOrCreate("foo",
.isThrownBy(() -> this.binder.bindOrCreate("foo", Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForCollectionTypes.class));
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForCollectionTypes.class))) assertThat(bound.getListValue()).isEmpty();
.withStackTraceContaining(
"Parameter of type java.util.List<java.lang.String> must have a non-empty default value.");
} }
@Test @Test
void bindWhenMapParametersWithEmptyDefaultValueShouldThrowException() { void bindWhenMapParametersWithEmptyDefaultValueShouldReturnEmptyInstance() {
assertThatExceptionOfType(BindException.class) NestedConstructorBeanWithEmptyDefaultValueForMapTypes bound = this.binder.bindOrCreate("foo",
.isThrownBy(() -> this.binder.bindOrCreate("foo", Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForMapTypes.class));
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForMapTypes.class))) assertThat(bound.getMapValue()).isEmpty();
.withStackTraceContaining(
"Parameter of type java.util.Map<java.lang.String, java.lang.String> must have a non-empty default value.");
} }
@Test @Test
void bindWhenArrayParameterWithEmptyDefaultValueShouldThrowException() { void bindWhenArrayParameterWithEmptyDefaultValueShouldReturnEmptyInstance() {
assertThatExceptionOfType(BindException.class) NestedConstructorBeanWithEmptyDefaultValueForArrayTypes bound = this.binder.bindOrCreate("foo",
.isThrownBy(() -> this.binder.bindOrCreate("foo", Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForArrayTypes.class));
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForArrayTypes.class))) assertThat(bound.getArrayValue()).isEmpty();
.withStackTraceContaining("Parameter of type java.lang.String[] must have a non-empty default value."); }
@Test
void bindWhenOptionalParameterWithEmptyDefaultValueShouldReturnEmptyInstance() {
NestedConstructorBeanWithEmptyDefaultValueForOptionalTypes bound = this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForOptionalTypes.class));
assertThat(bound.getOptionalValue()).isEmpty();
} }
@Test @Test
@ -753,8 +756,7 @@ class ValueObjectBinderTests {
private final String[] arrayValue; private final String[] arrayValue;
NestedConstructorBeanWithEmptyDefaultValueForArrayTypes(@DefaultValue String[] arrayValue, NestedConstructorBeanWithEmptyDefaultValueForArrayTypes(@DefaultValue String[] arrayValue) {
@DefaultValue Integer intValue) {
this.arrayValue = arrayValue; this.arrayValue = arrayValue;
} }
@ -764,6 +766,20 @@ class ValueObjectBinderTests {
} }
static class NestedConstructorBeanWithEmptyDefaultValueForOptionalTypes {
private final Optional<String> optionalValue;
NestedConstructorBeanWithEmptyDefaultValueForOptionalTypes(@DefaultValue Optional<String> optionalValue) {
this.optionalValue = optionalValue;
}
Optional<String> getOptionalValue() {
return this.optionalValue;
}
}
static class NestedConstructorBeanWithEmptyDefaultValueForEnumTypes { static class NestedConstructorBeanWithEmptyDefaultValueForEnumTypes {
private Foo foo; private Foo foo;

Loading…
Cancel
Save