Browse Source

Support binding of default properties when an empty property is defined

Update the `Binder` so that empty properties are treated as an indicator
that default value binding should be attempted. This update allow bound
objects to differentiate between a completely missing property vs one
that is present but doesn't have any values.

For example, given the following value object:

	@ConfigurationProperties("my")
	public record My(Name name, int age) {

		public record Name(String first, String last) {
		}

	}

The following binding scenarios are supported:

1) Full binding

	my.name.first=Spring
	my.name.last=Boot
	my.age=4

Binds to `new My(new Name("Spring", "Boot"), 4)`
(works the same as Spring Boot 4.0)

2) Missing Properties

	my.age=4

Binds to `new My(null, 4)`
(works the same as Spring Boot 4.0)

3) Default Properties

	my.name=
	my.age=4

Binds to `new My(new Name(null, null), 4)`
(previously would throw a converter exception)

Closes gh-48920
pull/49622/merge
Phillip Webb 2 months ago
parent
commit
55eba139ec
  1. 12
      core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java
  2. 4
      core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java
  3. 2
      core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java
  4. 10
      core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java
  5. 45
      core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java
  6. 6
      core/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MockConfigurationPropertySource.java
  7. 16
      core/spring-boot/src/test/java/org/springframework/boot/env/PropertiesPropertySourceLoaderTests.java
  8. 76
      documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc
  9. 2
      documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.java
  10. 2
      documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.kt

12
core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java

@ -49,6 +49,7 @@ import org.springframework.format.support.DefaultFormattingConversionService; @@ -49,6 +49,7 @@ import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.ObjectUtils;
/**
* A container object which Binds objects from one or more
@ -435,14 +436,16 @@ public class Binder { @@ -435,14 +436,16 @@ public class Binder {
}
catch (ConverterNotFoundException ex) {
// We might still be able to bind it using the recursive binders
Object instance = bindDataObject(name, target, handler, context, allowRecursiveBinding);
boolean fallbackToDefaultValue = ObjectUtils.isEmpty(property.getValue());
Object instance = bindDataObject(name, target, handler, context, allowRecursiveBinding,
fallbackToDefaultValue);
if (instance != null) {
return instance;
}
throw ex;
}
}
return bindDataObject(name, target, handler, context, allowRecursiveBinding);
return bindDataObject(name, target, handler, context, allowRecursiveBinding, false);
}
private @Nullable AggregateBinder<?> getAggregateBinder(Bindable<?> target, Context context) {
@ -493,7 +496,7 @@ public class Binder { @@ -493,7 +496,7 @@ public class Binder {
}
private @Nullable Object bindDataObject(ConfigurationPropertyName name, Bindable<?> target, BindHandler handler,
Context context, boolean allowRecursiveBinding) {
Context context, boolean allowRecursiveBinding, boolean fallbackToDefaultValue) {
if (isUnbindableBean(name, target, context)) {
return null;
}
@ -505,7 +508,8 @@ public class Binder { @@ -505,7 +508,8 @@ public class Binder {
DataObjectPropertyBinder propertyBinder = (propertyName, propertyTarget) -> bind(name.append(propertyName),
propertyTarget, handler, context, false, false);
Supplier<@Nullable Object> supplier = () -> fromDataObjectBinders(bindMethod,
(dataObjectBinder) -> dataObjectBinder.bind(name, target, context, propertyBinder));
(dataObjectBinder) -> dataObjectBinder.bind(name, target, context, propertyBinder,
fallbackToDefaultValue));
return context.withDataObject(type, supplier);
}

4
core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java

@ -40,10 +40,12 @@ interface DataObjectBinder { @@ -40,10 +40,12 @@ interface DataObjectBinder {
* @param target the bindable to bind
* @param context the bind context
* @param propertyBinder property binder
* @param fallbackToDefaultValue if an attempt should be made to return a new default
* value when no values are bound
* @return a bound instance or {@code null}
*/
<T> @Nullable T bind(ConfigurationPropertyName name, Bindable<T> target, Context context,
DataObjectPropertyBinder propertyBinder);
DataObjectPropertyBinder propertyBinder, boolean fallbackToDefaultValue);
/**
* Return a newly created instance or {@code null} if the {@link DataObjectBinder}

2
core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java

@ -61,7 +61,7 @@ class JavaBeanBinder implements DataObjectBinder { @@ -61,7 +61,7 @@ class JavaBeanBinder implements DataObjectBinder {
@Override
public <T> @Nullable T bind(ConfigurationPropertyName name, Bindable<T> target, Context context,
DataObjectPropertyBinder propertyBinder) {
DataObjectPropertyBinder propertyBinder, boolean fallbackToDefaultValue) {
boolean hasKnownBindableProperties = target.getValue() != null && hasKnownBindableProperties(name, context);
Bean<T> bean = Bean.get(target, context, hasKnownBindableProperties);
if (bean == null) {

10
core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java

@ -75,7 +75,7 @@ class ValueObjectBinder implements DataObjectBinder { @@ -75,7 +75,7 @@ class ValueObjectBinder implements DataObjectBinder {
@Override
public <T> @Nullable T bind(ConfigurationPropertyName name, Bindable<T> target, Binder.Context context,
DataObjectPropertyBinder propertyBinder) {
DataObjectPropertyBinder propertyBinder, boolean fallbackToDefaultValue) {
ValueObject<T> valueObject = ValueObject.get(target, context, this.constructorProvider, Discoverer.LENIENT);
if (valueObject == null) {
return null;
@ -94,7 +94,13 @@ class ValueObjectBinder implements DataObjectBinder { @@ -94,7 +94,13 @@ class ValueObjectBinder implements DataObjectBinder {
}
context.clearConfigurationProperty();
context.popConstructorBoundTypes();
return bound ? valueObject.instantiate(args) : null;
if (bound) {
return valueObject.instantiate(args);
}
if (fallbackToDefaultValue) {
return getNewDefaultValueInstanceIfPossible(context, target.getType());
}
return null;
}
@Override

45
core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java

@ -161,6 +161,51 @@ class ValueObjectBinderTests { @@ -161,6 +161,51 @@ class ValueObjectBinderTests {
assertThat(bean.getValueBean().getEnumValue()).isNull();
}
@Test
void bindToClassWhenNoValuesShouldNotBind() {
MockConfigurationPropertySource source = new MockConfigurationPropertySource();
this.sources.add(source);
assertThat(this.binder.bind("foo", Bindable.of(ExampleNestedBean.class)).isBound()).isFalse();
}
@Test
void bindToClassWhenParentOfEmptyStringAndSubValuesShouldFail() {
MockConfigurationPropertySource source = new MockConfigurationPropertySource();
source.put("foo.value-bean", "");
source.put("foo.value-bean.int-value", "123");
source.put("foo.value-bean.long-value", "34");
source.put("foo.value-bean.string-value", "foo");
this.sources.add(source);
ExampleNestedBean bean = this.binder.bind("foo", Bindable.of(ExampleNestedBean.class)).get();
assertThat(bean.getValueBean().getIntValue()).isEqualTo(123);
assertThat(bean.getValueBean().getLongValue()).isEqualTo(34);
assertThat(bean.getValueBean().isBooleanValue()).isFalse();
assertThat(bean.getValueBean().getStringValue()).isEqualTo("foo");
assertThat(bean.getValueBean().getEnumValue()).isNull();
}
@Test
void bindToClassWhenParentOfEmptyStringAndNoSubValuesShouldFail() {
MockConfigurationPropertySource source = new MockConfigurationPropertySource();
source.put("foo.value-bean", "");
this.sources.add(source);
ExampleNestedBean bean = this.binder.bind("foo", Bindable.of(ExampleNestedBean.class)).get();
assertThat(bean.getValueBean().getIntValue()).isEqualTo(0);
assertThat(bean.getValueBean().getLongValue()).isEqualTo(0);
assertThat(bean.getValueBean().isBooleanValue()).isFalse();
assertThat(bean.getValueBean().getStringValue()).isNull();
assertThat(bean.getValueBean().getEnumValue()).isNull();
}
@Test
void bindToClassWhenParentOfNullAndNoSubValuesShouldNotBind() {
MockConfigurationPropertySource source = new MockConfigurationPropertySource();
source.put("foo.value-bean", null);
this.sources.add(source);
BindResult<ExampleNestedBean> bindResult = this.binder.bind("foo", Bindable.of(ExampleNestedBean.class));
assertThat(bindResult.isBound()).isFalse();
}
@Test
void bindToClassWithNoValueForPrimitiveShouldUseDefault() {
MockConfigurationPropertySource source = new MockConfigurationPropertySource();

6
core/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MockConfigurationPropertySource.java

@ -52,15 +52,15 @@ public class MockConfigurationPropertySource implements IterableConfigurationPro @@ -52,15 +52,15 @@ public class MockConfigurationPropertySource implements IterableConfigurationPro
configs.forEach(this::put);
}
public void put(String name, String value) {
public void put(String name, @Nullable String value) {
put(ConfigurationPropertyName.of(name), value);
}
public void put(ConfigurationPropertyName name, String value) {
public void put(ConfigurationPropertyName name, @Nullable String value) {
put(name, OriginTrackedValue.of(value));
}
private void put(ConfigurationPropertyName name, OriginTrackedValue value) {
private void put(ConfigurationPropertyName name, @Nullable OriginTrackedValue value) {
this.map.put(name, value);
}

16
core/spring-boot/src/test/java/org/springframework/boot/env/PropertiesPropertySourceLoaderTests.java vendored

@ -131,4 +131,20 @@ class PropertiesPropertySourceLoaderTests { @@ -131,4 +131,20 @@ class PropertiesPropertySourceLoaderTests {
assertThat(source.getProperty("test")).isEqualTo("xml");
}
@Test
@WithResource(name = "test.properties", content = """
one=1
none
zero=
two=2
""")
void loadWithEmptyValues() throws Exception {
List<PropertySource<?>> loaded = this.loader.load("test.properties", new ClassPathResource("test.properties"));
PropertySource<?> source = loaded.get(0);
assertThat(source.getProperty("one")).isEqualTo("1");
assertThat(source.getProperty("none")).isEqualTo("");
assertThat(source.getProperty("zero")).isEqualTo("");
assertThat(source.getProperty("two")).isEqualTo("2");
}
}

76
documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc

@ -866,14 +866,6 @@ Unless your record has multiple constructors, there is no need to use javadoc:or @@ -866,14 +866,6 @@ Unless your record has multiple constructors, there is no need to use javadoc:or
Nested members of a constructor bound class (such as `Security` in the example above) will also be bound through their constructor.
Default values can be specified using javadoc:org.springframework.boot.context.properties.bind.DefaultValue[format=annotation] on constructor parameters and record components.
The conversion service will be applied to coerce the annotation's javadoc:java.lang.String[] value to the target type of a missing property.
Referring to the previous example, if no properties are bound to `Security`, the `MyProperties` instance will contain a `null` value for `security`.
To make it contain a non-null instance of `Security` even when no properties are bound to it (when using Kotlin, this will require the `username` and `password` parameters of `Security` to be declared as nullable as they do not have default values), use an empty javadoc:org.springframework.boot.context.properties.bind.DefaultValue[format=annotation] annotation:
include-code::nonnull/MyProperties[tag=*]
NOTE: To use constructor binding the class must be enabled using javadoc:org.springframework.boot.context.properties.EnableConfigurationProperties[format=annotation] or configuration property scanning.
You cannot use constructor binding with beans that are created by the regular Spring mechanisms (for example javadoc:org.springframework.stereotype.Component[format=annotation] beans, beans created by using javadoc:org.springframework.context.annotation.Bean[format=annotation] methods or beans loaded by using javadoc:org.springframework.context.annotation.Import[format=annotation])
@ -888,6 +880,74 @@ TIP: To use a reserved keyword in the name of a property, such as `my.service.im @@ -888,6 +880,74 @@ TIP: To use a reserved keyword in the name of a property, such as `my.service.im
[[features.external-config.typesafe-configuration-properties.constructor-binding.default-values]]
==== Default Values
Default values can be specified using javadoc:org.springframework.boot.context.properties.bind.DefaultValue[format=annotation] on constructor parameters and record components.
The conversion service will be applied to coerce the annotation's javadoc:java.lang.String[] value to the target type of a missing property.
In the `MyProperties` example above, you can see the nested `Security` class uses `@DefaultValue("USER")` for the `roles` parameter.
This means that if `security` properties are defined, but `roles` is not, the default of `"USER"` will be bound.
For example, the following properties:
[configprops%novalidate,yaml]
----
my:
service:
enabled: true
security:
username: admin
----
Will be bound as `new MyProperties(true, null, new Security("admin", null, List.of("USER")))`
If the `security` property is not present at all, then the `Security` instance will be `null`.
For example, the following properties:
[configprops%novalidate,yaml]
----
my:
service:
enabled: true
----
Will be bound as `new MyProperties(true, null, null)`
[NOTE]
====
You can define an empty `security` property if you want to trigger binding with fully default `Security` instance.
For YAML, you can use the following syntax:
[,yaml]
----
my:
service:
enabled: true
security: {}
----
With `Properties` you can use:
[,properties]
----
my.service.enabled=true
my.service.security=
----
Will be bound as `new MyProperties(true, null, new Security(null, null, List.of("USER")))`
====
If you want to always bind a a non-null instance of `Security`, even when properties are missing, you can use use an empty javadoc:org.springframework.boot.context.properties.bind.DefaultValue[format=annotation] annotation:
include-code::nonnull/MyProperties[tag=*]
TIP: When using Kotlin, you will need to declare the `username` and `password` parameters as nullable since they do not have default values
[[features.external-config.typesafe-configuration-properties.enabling-annotated-types]]
=== Enabling @ConfigurationProperties-annotated Types

2
documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.java → documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.java

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.docs.features.externalconfig.typesafeconfigurationproperties.constructorbinding.nonnull;
package org.springframework.boot.docs.features.externalconfig.typesafeconfigurationproperties.constructorbinding.defaultvalues.nonnull;
import java.net.InetAddress;
import java.util.List;

2
documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.kt → documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.docs.features.externalconfig.typesafeconfigurationproperties.constructorbinding.nonnull
package org.springframework.boot.docs.features.externalconfig.typesafeconfigurationproperties.constructorbinding.defaultvalues.nonnull
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.DefaultValue
Loading…
Cancel
Save