From 4244dd933a81155c3274e8fc71129bb351fa4d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Sv=C4=9Btl=C3=ADk?= <16480390+osvetlik@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:15:12 +0100 Subject: [PATCH 1/2] Bind `Optional` value object parameters as empty rather than `null` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update `ValueObjectBinder` so that `Optional` parameters are bound as `Option.empty()` rather than `null`. See gh-49152 Signed-off-by: Ondřej Světlík <16480390+osvetlik@users.noreply.github.com> --- .../properties/bind/BindConverter.java | 8 +++++- .../properties/bind/ValueObjectBinder.java | 7 ++++- .../bind/ValueObjectBinderTests.java | 27 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java index 29ac4e3fb23..764b6fa7ffd 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java @@ -105,7 +105,13 @@ final class BindConverter { new ResolvableTypeDescriptor(targetType, targetAnnotations)); } - private @Nullable Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + @SuppressWarnings("unchecked") + @Nullable T convertNullValue(ResolvableType targetType, Annotation... targetAnnotations) { + return (T) convert(null, null, new ResolvableTypeDescriptor(targetType, targetAnnotations)); + } + + private @Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, + TypeDescriptor targetType) { ConversionException failure = null; for (ConversionService delegate : this.delegates) { try { 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 bdaffd38958..667ecfe0837 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 @@ -132,7 +132,12 @@ class ValueObjectBinder implements DataObjectBinder { return convertDefaultValue(context.getConverter(), defaultValue, type, annotations); } } - return null; + return convertNullValue(context, type); + } + + private @Nullable T convertNullValue(Binder.Context context, ResolvableType type, Annotation... annotations) { + BindConverter converter = context.getConverter(); + return converter.convertNullValue(type, annotations); } private @Nullable T convertDefaultValue(BindConverter converter, String[] defaultValue, ResolvableType type, 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 2f1d0bd3b4b..dff3c83fb39 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 @@ -326,6 +326,25 @@ class ValueObjectBinderTests { assertThat(bound.getOptionalValue()).isEmpty(); } + @Test + void bindWhenOptionalParameterWithNoValueForPrimitiveShouldReturnEmptyInstance() { + RecordPropertiesWithOptional bound = this.binder.bindOrCreate("foo", + Bindable.of(RecordPropertiesWithOptional.class)); + assertThat(bound.property1()).isEmpty(); + } + + @Test + void bindWhenOptionalParameterInNestedWithNoValueForPrimitiveShouldReturnEmptyInstance() { + MockConfigurationPropertySource source = new MockConfigurationPropertySource(); + source.put("foo.nested.property2", "str"); + this.sources.add(source); + RecordPropertiesWithOptionalInNestedRecord bound = this.binder.bindOrCreate("foo", + Bindable.of(RecordPropertiesWithOptionalInNestedRecord.class)); + assertThat(bound.nested()).isNotNull(); + assertThat(bound.nested().property1()).isNotNull().isEmpty(); + assertThat(bound.nested().property2()).isEqualTo("str"); + } + @Test void bindWhenEnumParameterWithEmptyDefaultValueShouldThrowException() { assertThatExceptionOfType(BindException.class) @@ -902,6 +921,14 @@ class ValueObjectBinderTests { @DefaultValue("default-value-2") String property2) { } + record RecordPropertiesWithOptional(Optional property1) { + } + + record RecordPropertiesWithOptionalInNestedRecord(NestedRecord nested) { + record NestedRecord(Optional property1, String property2) { + } + } + static class NonExtractableParameterName { private @Nullable String value; From 764b5588877b5f6bf2cb87990fd7e4edf0f62c50 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Sun, 8 Feb 2026 09:47:50 -0800 Subject: [PATCH 2/2] Polish "Bind `Optional` value object parameters as empty rather than `null`" See gh-49152 --- .../context/properties/bind/BindConverter.java | 15 +++++---------- .../properties/bind/ValueObjectBinder.java | 8 ++------ .../properties/bind/ValueObjectBinderTests.java | 1 + 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java index 764b6fa7ffd..a6fd1c7c826 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java @@ -98,16 +98,8 @@ final class BindConverter { @SuppressWarnings("unchecked") @Nullable T convert(@Nullable Object source, ResolvableType targetType, Annotation... targetAnnotations) { - if (source == null) { - return null; - } - return (T) convert(source, TypeDescriptor.forObject(source), - new ResolvableTypeDescriptor(targetType, targetAnnotations)); - } - - @SuppressWarnings("unchecked") - @Nullable T convertNullValue(ResolvableType targetType, Annotation... targetAnnotations) { - return (T) convert(null, null, new ResolvableTypeDescriptor(targetType, targetAnnotations)); + TypeDescriptor sourceType = (source != null) ? TypeDescriptor.forObject(source) : null; + return (T) convert(source, sourceType, new ResolvableTypeDescriptor(targetType, targetAnnotations)); } private @Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, @@ -125,6 +117,9 @@ final class BindConverter { } } } + if (source == null) { + return null; + } throw (failure != null) ? failure : new ConverterNotFoundException(sourceType, targetType); } 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 667ecfe0837..919c6fc4fd2 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 @@ -61,6 +61,7 @@ import org.springframework.util.Assert; * @author Stephane Nicoll * @author Phillip Webb * @author Scott Frederick + * @author Ondřej Světlík */ class ValueObjectBinder implements DataObjectBinder { @@ -132,12 +133,7 @@ class ValueObjectBinder implements DataObjectBinder { return convertDefaultValue(context.getConverter(), defaultValue, type, annotations); } } - return convertNullValue(context, type); - } - - private @Nullable T convertNullValue(Binder.Context context, ResolvableType type, Annotation... annotations) { - BindConverter converter = context.getConverter(); - return converter.convertNullValue(type, annotations); + return context.getConverter().convert(null, type); } private @Nullable T convertDefaultValue(BindConverter converter, String[] defaultValue, ResolvableType type, 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 dff3c83fb39..7b1661b3a48 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 @@ -50,6 +50,7 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; * @author Phillip Webb * @author Pavel Anisimov * @author Yanming Zhou + * @author Ondřej Světlík */ class ValueObjectBinderTests {