mirror of
https://github.com/spring-projects/spring-boot.git
synced 2026-09-28 21:19:02 +00:00
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
This commit is contained in:
+8
-4
@@ -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 {
|
||||
}
|
||||
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 {
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+8
-2
@@ -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 {
|
||||
}
|
||||
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
@@ -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();
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
core/spring-boot/src/test/java/org/springframework/boot/env/PropertiesPropertySourceLoaderTests.java
Vendored
+16
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+68
-8
@@ -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
|
||||
|
||||
|
||||
|
||||
[[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
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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
|
||||
Reference in New Issue
Block a user