From 55eba139ec1d07daccc66bcd8f98e93d5c8558d9 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Fri, 23 Jan 2026 19:18:09 -0800 Subject: [PATCH] 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 --- .../boot/context/properties/bind/Binder.java | 12 ++- .../properties/bind/DataObjectBinder.java | 4 +- .../properties/bind/JavaBeanBinder.java | 2 +- .../properties/bind/ValueObjectBinder.java | 10 ++- .../bind/ValueObjectBinderTests.java | 45 +++++++++++ .../MockConfigurationPropertySource.java | 6 +- .../PropertiesPropertySourceLoaderTests.java | 16 ++++ .../pages/features/external-config.adoc | 76 +++++++++++++++++-- .../nonnull/MyProperties.java | 2 +- .../nonnull/MyProperties.kt | 2 +- 10 files changed, 154 insertions(+), 21 deletions(-) rename documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/{ => defaultvalues}/nonnull/MyProperties.java (96%) rename documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/{ => defaultvalues}/nonnull/MyProperties.kt (94%) diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java index ac0298e1c9a..e42afcb502f 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java @@ -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); } diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java index dfd41d8616d..6fb0d26f6bf 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java @@ -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} */ @Nullable T bind(ConfigurationPropertyName name, Bindable target, Context context, - DataObjectPropertyBinder propertyBinder); + DataObjectPropertyBinder propertyBinder, boolean fallbackToDefaultValue); /** * Return a newly created instance or {@code null} if the {@link DataObjectBinder} diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java index 92cdbced79d..33532663571 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java @@ -61,7 +61,7 @@ class JavaBeanBinder implements DataObjectBinder { @Override public @Nullable T bind(ConfigurationPropertyName name, Bindable target, Context context, - DataObjectPropertyBinder propertyBinder) { + DataObjectPropertyBinder propertyBinder, boolean fallbackToDefaultValue) { boolean hasKnownBindableProperties = target.getValue() != null && hasKnownBindableProperties(name, context); Bean bean = Bean.get(target, context, hasKnownBindableProperties); if (bean == null) { diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java index 919c6fc4fd2..200e722e7af 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java @@ -75,7 +75,7 @@ class ValueObjectBinder implements DataObjectBinder { @Override public @Nullable T bind(ConfigurationPropertyName name, Bindable target, Binder.Context context, - DataObjectPropertyBinder propertyBinder) { + DataObjectPropertyBinder propertyBinder, boolean fallbackToDefaultValue) { ValueObject 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 diff --git a/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java index 35acae7bd45..74ec2981f75 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java @@ -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 bindResult = this.binder.bind("foo", Bindable.of(ExampleNestedBean.class)); + assertThat(bindResult.isBound()).isFalse(); + } + @Test void bindToClassWithNoValueForPrimitiveShouldUseDefault() { MockConfigurationPropertySource source = new MockConfigurationPropertySource(); diff --git a/core/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MockConfigurationPropertySource.java b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MockConfigurationPropertySource.java index b1c807746c5..79a2217a80f 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MockConfigurationPropertySource.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MockConfigurationPropertySource.java @@ -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); } diff --git a/core/spring-boot/src/test/java/org/springframework/boot/env/PropertiesPropertySourceLoaderTests.java b/core/spring-boot/src/test/java/org/springframework/boot/env/PropertiesPropertySourceLoaderTests.java index f97ce1a5e82..7d0241129ef 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/env/PropertiesPropertySourceLoaderTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/env/PropertiesPropertySourceLoaderTests.java @@ -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> 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"); + } + } diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc index 55574a12653..5915325fd6b 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc +++ b/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 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 diff --git a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.java b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.java similarity index 96% rename from documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.java rename to documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.java index c1714b67511..29aef9a6aed 100644 --- a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.java +++ b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.java @@ -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; diff --git a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.kt b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.kt similarity index 94% rename from documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.kt rename to documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.kt index 64a0deb59f0..a9e9d4c25b3 100644 --- a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/nonnull/MyProperties.kt +++ b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/externalconfig/typesafeconfigurationproperties/constructorbinding/defaultvalues/nonnull/MyProperties.kt @@ -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