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] 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;