Skip to content

Commit

Permalink
Add support for initializing nested object when nothing bound
Browse files Browse the repository at this point in the history
When using constructor binding, if no properties are bound to
a nested property, the top-level instance will be created with a
null value for the nested property.
This commit introduces support for an empty `@DefaultValue` which
indicates that an instance of the nested property must be created
even if nothing is bound to it. It honors any `@DefaultValue`
annotations that the nested property might have in its constructor.

Closes gh-18917
  • Loading branch information
mbhave committed Apr 20, 2020
1 parent df26e24 commit af6d538
Show file tree
Hide file tree
Showing 4 changed files with 287 additions and 13 deletions.
Expand Up @@ -912,6 +912,37 @@ This means that the binder will expect to find a constructor with the parameters
Nested members of a `@ConstructorBinding` class (such as `Security` in the example above) will also be bound via their constructor.

Default values can be specified using `@DefaultValue` and the same conversion service will be applied to coerce the `String` value to the target type of a missing property.
By default, if no properties are bound to `Security`, the `AcmeProperties` instance will contain a `null` value for `security`.
If you wish you return a non-null instance of `Security` even when no properties are bound to it, you can use an empty `@DefaultValue` annotation to do so:

[source,java,indent=0]
----
package com.example;
import java.net.InetAddress;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConstructorBinding
@ConfigurationProperties("acme")
public class AcmeProperties {
private final boolean enabled;
private final InetAddress remoteAddress;
private final Security security;
public AcmeProperties(boolean enabled, InetAddress remoteAddress, @DefaultValue Security security) {
this.enabled = enabled;
this.remoteAddress = remoteAddress;
this.security = security;
}
}
----


NOTE: To use constructor binding the class must be enabled using `@EnableConfigurationProperties` or configuration property scanning.
You cannot use constructor binding with beans that are created by the regular Spring mechanisms (e.g. `@Component` beans, beans created via `@Bean` methods or beans loaded using `@Import`)
Expand Down
Expand Up @@ -23,7 +23,8 @@

/**
* Annotation that can be used to specify the default value when binding to an immutable
* property.
* property. This annotation can also be used with nested properties to indicate that a
* value should always be bound (rather than binding {@code null}).
*
* @author Madhura Bhave
* @since 2.2.0
Expand All @@ -38,6 +39,6 @@
* array-based properties.
* @return the default value of the property.
*/
String[] value();
String[] value() default {};

}
Expand Up @@ -20,8 +20,10 @@
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import kotlin.reflect.KFunction;
import kotlin.reflect.KParameter;
Expand Down Expand Up @@ -65,7 +67,7 @@ public <T> T bind(ConfigurationPropertyName name, Bindable<T> target, Binder.Con
for (ConstructorParameter parameter : parameters) {
Object arg = parameter.bind(propertyBinder);
bound = bound || arg != null;
arg = (arg != null) ? arg : parameter.getDefaultValue(context.getConverter());
arg = (arg != null) ? arg : getDefaultValue(context, parameter);
args.add(arg);
}
context.clearConfigurationProperty();
Expand All @@ -82,11 +84,49 @@ public <T> T create(Bindable<T> target, Binder.Context context) {
List<ConstructorParameter> parameters = valueObject.getConstructorParameters();
List<Object> args = new ArrayList<>(parameters.size());
for (ConstructorParameter parameter : parameters) {
args.add(parameter.getDefaultValue(context.getConverter()));
args.add(getDefaultValue(context, parameter));
}
return valueObject.instantiate(args);
}

private <T> T getDefaultValue(Binder.Context context, ConstructorParameter parameter) {
ResolvableType type = parameter.getType();
Annotation[] annotations = parameter.getAnnotations();
for (Annotation annotation : annotations) {
if (annotation instanceof DefaultValue) {
DefaultValue defaultValue = (DefaultValue) annotation;
if (defaultValue.value().length == 0) {
return getNewInstanceIfPossible(context, type);
}
return context.getConverter().convert(defaultValue.value(), type, annotations);
}
}
return null;
}

@SuppressWarnings("unchecked")
private <T> T getNewInstanceIfPossible(Binder.Context context, ResolvableType type) {
Class<T> resolved = (Class<T>) type.resolve();
Assert.state(resolved == null || isEmptyDefaultValueAllowed(resolved),
() -> "Parameter of type " + type + " must have a non-empty default value.");
T instance = create(Bindable.of(type), context);
if (instance != null) {
return instance;
}
return (resolved != null) ? BeanUtils.instantiateClass(resolved) : null;
}

private boolean isEmptyDefaultValueAllowed(Class<?> type) {
if (type.isPrimitive() || type.isEnum() || isAggregate(type) || type.getName().startsWith("java.lang")) {
return false;
}
return true;
}

private boolean isAggregate(Class<?> type) {
return type.isArray() || Map.class.isAssignableFrom(type) || Collection.class.isAssignableFrom(type);
}

/**
* The value object being bound.
*
Expand Down Expand Up @@ -228,19 +268,18 @@ private static class ConstructorParameter {
this.annotations = annotations;
}

Object getDefaultValue(BindConverter converter) {
for (Annotation annotation : this.annotations) {
if (annotation instanceof DefaultValue) {
return converter.convert(((DefaultValue) annotation).value(), this.type, this.annotations);
}
}
return null;
}

Object bind(DataObjectPropertyBinder propertyBinder) {
return propertyBinder.bindProperty(this.name, Bindable.of(this.type).withAnnotations(this.annotations));
}

Annotation[] getAnnotations() {
return this.annotations;
}

ResolvableType getType() {
return this.type;
}

}

}
Expand Up @@ -248,6 +248,65 @@ void bindToClassShouldBindWithGenerics() {
assertThat(bean.getValue().get("bar")).isEqualTo("baz");
}

@Test
void bindWhenParametersWithDefaultValueShouldReturnNonNullValues() {
NestedConstructorBeanWithDefaultValue bound = this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithDefaultValue.class));
assertThat(bound.getNestedImmutable().getFoo()).isEqualTo("hello");
assertThat(bound.getNestedJavaBean()).isNotNull();
}

@Test
void bindWhenJavaLangParameterWithEmptyDefaultValueShouldThrowException() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForJavaLangTypes.class)))
.withStackTraceContaining("Parameter of type java.lang.String must have a non-empty default value.");
}

@Test
void bindWhenCollectionParameterWithEmptyDefaultValueShouldThrowException() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForCollectionTypes.class)))
.withStackTraceContaining(
"Parameter of type java.util.List<java.lang.String> must have a non-empty default value.");
}

@Test
void bindWhenMapParametersWithEmptyDefaultValueShouldThrowException() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForMapTypes.class)))
.withStackTraceContaining(
"Parameter of type java.util.Map<java.lang.String, java.lang.String> must have a non-empty default value.");
}

@Test
void bindWhenArrayParameterWithEmptyDefaultValueShouldThrowException() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForArrayTypes.class)))
.withStackTraceContaining("Parameter of type java.lang.String[] must have a non-empty default value.");
}

@Test
void bindWhenEnumParameterWithEmptyDefaultValueShouldThrowException() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForEnumTypes.class)))
.withStackTraceContaining(
"Parameter of type org.springframework.boot.context.properties.bind.ValueObjectBinderTests$NestedConstructorBeanWithEmptyDefaultValueForEnumTypes$Foo must have a non-empty default value.");
}

@Test
void bindWhenPrimitiveParameterWithEmptyDefaultValueShouldThrowException() {
assertThatExceptionOfType(BindException.class)
.isThrownBy(() -> this.binder.bindOrCreate("foo",
Bindable.of(NestedConstructorBeanWithEmptyDefaultValueForPrimitiveTypes.class)))
.withStackTraceContaining("Parameter of type int must have a non-empty default value.");
}

private void noConfigurationProperty(BindException ex) {
assertThat(ex.getProperty()).isNull();
}
Expand Down Expand Up @@ -481,4 +540,148 @@ T getValue() {

}

static class NestedConstructorBeanWithDefaultValue {

private final NestedImmutable nestedImmutable;

private final NestedJavaBean nestedJavaBean;

NestedConstructorBeanWithDefaultValue(@DefaultValue NestedImmutable nestedImmutable,
@DefaultValue NestedJavaBean nestedJavaBean) {
this.nestedImmutable = nestedImmutable;
this.nestedJavaBean = nestedJavaBean;
}

NestedImmutable getNestedImmutable() {
return this.nestedImmutable;
}

NestedJavaBean getNestedJavaBean() {
return this.nestedJavaBean;
}

}

static class NestedImmutable {

private final String foo;

private final String bar;

NestedImmutable(@DefaultValue("hello") String foo, String bar) {
this.foo = foo;
this.bar = bar;
}

String getFoo() {
return this.foo;
}

String getBar() {
return this.bar;
}

}

static class NestedJavaBean {

private String value;

String getValue() {
return this.value;
}

}

static class NestedConstructorBeanWithEmptyDefaultValueForJavaLangTypes {

private final String stringValue;

NestedConstructorBeanWithEmptyDefaultValueForJavaLangTypes(@DefaultValue String stringValue) {
this.stringValue = stringValue;
}

String getStringValue() {
return this.stringValue;
}

}

static class NestedConstructorBeanWithEmptyDefaultValueForCollectionTypes {

private final List<String> listValue;

NestedConstructorBeanWithEmptyDefaultValueForCollectionTypes(@DefaultValue List<String> listValue) {
this.listValue = listValue;
}

List<String> getListValue() {
return this.listValue;
}

}

static class NestedConstructorBeanWithEmptyDefaultValueForMapTypes {

private final Map<String, String> mapValue;

NestedConstructorBeanWithEmptyDefaultValueForMapTypes(@DefaultValue Map<String, String> mapValue) {
this.mapValue = mapValue;
}

Map<String, String> getMapValue() {
return this.mapValue;
}

}

static class NestedConstructorBeanWithEmptyDefaultValueForArrayTypes {

private final String[] arrayValue;

NestedConstructorBeanWithEmptyDefaultValueForArrayTypes(@DefaultValue String[] arrayValue,
@DefaultValue Integer intValue) {
this.arrayValue = arrayValue;
}

String[] getArrayValue() {
return this.arrayValue;
}

}

static class NestedConstructorBeanWithEmptyDefaultValueForEnumTypes {

private Foo foo;

NestedConstructorBeanWithEmptyDefaultValueForEnumTypes(@DefaultValue Foo foo) {
this.foo = foo;
}

Foo getFoo() {
return this.foo;
}

enum Foo {

BAR, BAZ

}

}

static class NestedConstructorBeanWithEmptyDefaultValueForPrimitiveTypes {

private int intValue;

NestedConstructorBeanWithEmptyDefaultValueForPrimitiveTypes(@DefaultValue int intValue) {
this.intValue = intValue;
}

int getIntValue() {
return this.intValue;
}

}

}

0 comments on commit af6d538

Please sign in to comment.