Skip to content

Commit 8379ac7

Browse files
committed
Introduce OptionalToObjectConverter
We have had an ObjectToOptionalConverter since Spring Framework 4.1; however, prior to this commit we did not have a standard Converter for the inverse (Optional to Object). To address that, this commit introduces an OptionalToObjectConverter that unwraps an Optional, using the ConversionService to convert the object contained in the Optional (potentially null) to the target type. This allows for conversions such as the following. - Optional.empty() -> null - Optional.of(42) with Integer target -> 42 - Optional.of(42) with String target -> "42" - Optional.of(42) with Optional<String> target -> Optional.of("42") The OptionalToObjectConverter is also registered by default in DefaultConversionService, alongside the existing ObjectToOptionalConverter. See gh-20433 Closes gh-34544
1 parent b8c2780 commit 8379ac7

File tree

5 files changed

+189
-1
lines changed

5 files changed

+189
-1
lines changed

spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -99,6 +99,7 @@ public static void addDefaultConverters(ConverterRegistry converterRegistry) {
9999
converterRegistry.addConverter(new IdToEntityConverter((ConversionService) converterRegistry));
100100
converterRegistry.addConverter(new FallbackObjectToStringConverter());
101101
converterRegistry.addConverter(new ObjectToOptionalConverter((ConversionService) converterRegistry));
102+
converterRegistry.addConverter(new OptionalToObjectConverter((ConversionService) converterRegistry));
102103
}
103104

104105
/**

spring-core/src/main/java/org/springframework/core/convert/support/ObjectToOptionalConverter.java

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
* @author Rossen Stoyanchev
3737
* @author Juergen Hoeller
3838
* @since 4.1
39+
* @see OptionalToObjectConverter
3940
*/
4041
final class ObjectToOptionalConverter implements ConditionalGenericConverter {
4142

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.core.convert.support;
18+
19+
import java.util.Optional;
20+
import java.util.Set;
21+
22+
import org.jspecify.annotations.Nullable;
23+
24+
import org.springframework.core.convert.ConversionService;
25+
import org.springframework.core.convert.TypeDescriptor;
26+
import org.springframework.core.convert.converter.ConditionalGenericConverter;
27+
28+
/**
29+
* Convert an {@link Optional} to an {@link Object} by unwrapping the {@code Optional},
30+
* using the {@link ConversionService} to convert the object contained in the
31+
* {@code Optional} (potentially {@code null}) to the target type.
32+
*
33+
* @author Sam Brannen
34+
* @since 7.0
35+
* @see ObjectToOptionalConverter
36+
*/
37+
final class OptionalToObjectConverter implements ConditionalGenericConverter {
38+
39+
private final ConversionService conversionService;
40+
41+
42+
OptionalToObjectConverter(ConversionService conversionService) {
43+
this.conversionService = conversionService;
44+
}
45+
46+
47+
@Override
48+
public Set<ConvertiblePair> getConvertibleTypes() {
49+
return Set.of(new ConvertiblePair(Optional.class, Object.class));
50+
}
51+
52+
@Override
53+
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
54+
return ConversionUtils.canConvertElements(sourceType.getElementTypeDescriptor(), targetType, this.conversionService);
55+
}
56+
57+
@Override
58+
public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
59+
if (source == null) {
60+
return null;
61+
}
62+
Optional<?> optional = (Optional<?>) source;
63+
Object unwrappedSource = optional.orElse(null);
64+
TypeDescriptor unwrappedSourceType = TypeDescriptor.forObject(unwrappedSource);
65+
return this.conversionService.convert(unwrappedSource, unwrappedSourceType, targetType);
66+
}
67+
68+
}

spring-core/src/test/java/org/springframework/core/convert/converter/DefaultConversionServiceTests.java

+77
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import org.junit.jupiter.api.Test;
4949

5050
import org.springframework.core.MethodParameter;
51+
import org.springframework.core.ResolvableType;
5152
import org.springframework.core.convert.ConversionFailedException;
5253
import org.springframework.core.convert.ConverterNotFoundException;
5354
import org.springframework.core.convert.TypeDescriptor;
@@ -56,6 +57,7 @@
5657

5758
import static org.assertj.core.api.Assertions.assertThat;
5859
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
60+
import static org.assertj.core.api.Assertions.byLessThan;
5961
import static org.assertj.core.api.Assertions.entry;
6062

6163
/**
@@ -978,13 +980,88 @@ void convertNullOptionalToNull() {
978980
assertThat(conversionService.convert(null, rawOptionalType, TypeDescriptor.valueOf(Object.class))).isNull();
979981
}
980982

983+
@Test // gh-34544
984+
void convertEmptyOptionalToNull() {
985+
Optional<Object> empty = Optional.empty();
986+
987+
assertThat(conversionService.convert(empty, Object.class)).isNull();
988+
assertThat(conversionService.convert(empty, String.class)).isNull();
989+
990+
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(Object.class))).isNull();
991+
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(String.class))).isNull();
992+
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(Integer[].class))).isNull();
993+
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(List.class))).isNull();
994+
}
995+
981996
@Test
982997
void convertEmptyOptionalToOptional() {
983998
assertThat((Object) conversionService.convert(Optional.empty(), Optional.class)).isSameAs(Optional.empty());
984999
assertThat(conversionService.convert(Optional.empty(), TypeDescriptor.valueOf(Object.class), rawOptionalType))
9851000
.isSameAs(Optional.empty());
9861001
}
9871002

1003+
@Test // gh-34544
1004+
@SuppressWarnings("unchecked")
1005+
void convertOptionalToOptionalWithoutConversionOfContainedObject() {
1006+
assertThat(conversionService.convert(Optional.of(42), Optional.class)).contains(42);
1007+
1008+
assertThat(conversionService.convert(Optional.of("enigma"), Optional.class)).contains("enigma");
1009+
assertThat((Optional<String>) conversionService.convert(Optional.of("enigma"), rawOptionalType, rawOptionalType))
1010+
.contains("enigma");
1011+
}
1012+
1013+
@Test // gh-34544
1014+
@SuppressWarnings("unchecked")
1015+
void convertOptionalToOptionalWithConversionOfContainedObject() {
1016+
TypeDescriptor integerOptionalType =
1017+
new TypeDescriptor(ResolvableType.forClassWithGenerics(Optional.class, Integer.class), null, null);
1018+
TypeDescriptor stringOptionalType =
1019+
new TypeDescriptor(ResolvableType.forClassWithGenerics(Optional.class, String.class), null, null);
1020+
1021+
assertThat((Optional<String>) conversionService.convert(Optional.of(42), integerOptionalType, stringOptionalType))
1022+
.contains("42");
1023+
}
1024+
1025+
@Test // gh-34544
1026+
@SuppressWarnings("unchecked")
1027+
void convertOptionalToObjectWithoutConversionOfContainedObject() {
1028+
assertThat(conversionService.convert(Optional.of("enigma"), String.class)).isEqualTo("enigma");
1029+
assertThat(conversionService.convert(Optional.of(42), Integer.class)).isEqualTo(42);
1030+
assertThat(conversionService.convert(Optional.of(new int[] {1, 2, 3}), int[].class)).containsExactly(1, 2, 3);
1031+
assertThat(conversionService.convert(Optional.of(new Integer[] {1, 2, 3}), Integer[].class)).containsExactly(1, 2, 3);
1032+
assertThat(conversionService.convert(Optional.of(List.of(1, 2, 3)), List.class)).containsExactly(1, 2, 3);
1033+
}
1034+
1035+
@Test // gh-34544
1036+
@SuppressWarnings("unchecked")
1037+
void convertOptionalToObjectWithConversionOfContainedObject() {
1038+
assertThat(conversionService.convert(Optional.of(42), String.class)).isEqualTo("42");
1039+
assertThat(conversionService.convert(Optional.of(3.14F), Double.class)).isCloseTo(3.14, byLessThan(0.001));
1040+
assertThat(conversionService.convert(Optional.of(new int[] {1, 2, 3}), Integer[].class)).containsExactly(1, 2, 3);
1041+
assertThat(conversionService.convert(Optional.of(List.of(1, 2, 3)), Set.class)).containsExactly(1, 2, 3);
1042+
}
1043+
1044+
@Test // gh-34544
1045+
@SuppressWarnings("unchecked")
1046+
void convertNestedOptionalsToObject() {
1047+
assertThat(conversionService.convert(Optional.of(Optional.of("unwrap me twice")), String.class))
1048+
.isEqualTo("unwrap me twice");
1049+
}
1050+
1051+
@Test // gh-34544
1052+
@SuppressWarnings("unchecked")
1053+
void convertOptionalToObjectViaTypeDescriptorForMethodParameter() {
1054+
Method method = ClassUtils.getMethod(getClass(), "handleList", List.class);
1055+
MethodParameter parameter = new MethodParameter(method, 0);
1056+
TypeDescriptor descriptor = new TypeDescriptor(parameter);
1057+
1058+
Optional<List<Integer>> source = Optional.of(List.of(1, 2, 3));
1059+
assertThat((List<Integer>) conversionService.convert(source, rawOptionalType, descriptor)).containsExactly(1, 2, 3);
1060+
}
1061+
1062+
public void handleList(List<Integer> value) {
1063+
}
1064+
9881065
public void handleOptionalList(Optional<List<Integer>> value) {
9891066
}
9901067
}

spring-expression/src/test/java/org/springframework/expression/spel/ExpressionWithConversionTests.java

+41
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818

1919
import java.util.Collection;
2020
import java.util.List;
21+
import java.util.Optional;
2122
import java.util.Set;
2223

24+
import org.jspecify.annotations.Nullable;
2325
import org.junit.jupiter.api.Test;
2426

2527
import org.springframework.core.MethodParameter;
@@ -38,6 +40,7 @@
3840
*
3941
* @author Andy Clement
4042
* @author Dave Syer
43+
* @author Sam Brannen
4144
*/
4245
class ExpressionWithConversionTests extends AbstractExpressionTests {
4346

@@ -152,6 +155,27 @@ void convert() {
152155
assertThat(baz.value).isEqualTo("quux");
153156
}
154157

158+
@Test // gh-34544
159+
void convertOptionalToContainedTargetForMethodInvocations() {
160+
StandardEvaluationContext context = new StandardEvaluationContext(new JediService());
161+
162+
// Verify findByName('Yoda') returns an Optional.
163+
Expression expression = parser.parseExpression("findByName('Yoda') instanceof T(java.util.Optional)");
164+
assertThat(expression.getValue(context, Boolean.class)).isTrue();
165+
166+
// Verify we can pass a Jedi directly to greet().
167+
expression = parser.parseExpression("greet(findByName('Yoda').get())");
168+
assertThat(expression.getValue(context, String.class)).isEqualTo("Hello, Yoda");
169+
170+
// Verify that an Optional<Jedi> will be unwrapped to a Jedi to pass to greet().
171+
expression = parser.parseExpression("greet(findByName('Yoda'))");
172+
assertThat(expression.getValue(context, String.class)).isEqualTo("Hello, Yoda");
173+
174+
// Verify that an empty Optional will be converted to null to pass to greet().
175+
expression = parser.parseExpression("greet(findByName(''))");
176+
assertThat(expression.getValue(context, String.class)).isEqualTo("Hello, null");
177+
}
178+
155179

156180
public static class Foo {
157181

@@ -180,4 +204,21 @@ public Collection<?> getFoosAsObjects() {
180204
}
181205
}
182206

207+
record Jedi(String name) {
208+
}
209+
210+
static class JediService {
211+
212+
public Optional<Jedi> findByName(String name) {
213+
if (name.isEmpty()) {
214+
return Optional.empty();
215+
}
216+
return Optional.of(new Jedi(name));
217+
}
218+
219+
public String greet(@Nullable Jedi jedi) {
220+
return "Hello, " + (jedi != null ? jedi.name() : null);
221+
}
222+
}
223+
183224
}

0 commit comments

Comments
 (0)