Browse Source

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
pull/34682/head
Sam Brannen 9 months ago
parent
commit
8379ac772a
  1. 3
      spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java
  2. 1
      spring-core/src/main/java/org/springframework/core/convert/support/ObjectToOptionalConverter.java
  3. 68
      spring-core/src/main/java/org/springframework/core/convert/support/OptionalToObjectConverter.java
  4. 77
      spring-core/src/test/java/org/springframework/core/convert/converter/DefaultConversionServiceTests.java
  5. 41
      spring-expression/src/test/java/org/springframework/expression/spel/ExpressionWithConversionTests.java

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

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -99,6 +99,7 @@ public class DefaultConversionService extends GenericConversionService { @@ -99,6 +99,7 @@ public class DefaultConversionService extends GenericConversionService {
converterRegistry.addConverter(new IdToEntityConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new FallbackObjectToStringConverter());
converterRegistry.addConverter(new ObjectToOptionalConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new OptionalToObjectConverter((ConversionService) converterRegistry));
}
/**

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

@ -36,6 +36,7 @@ import org.springframework.util.CollectionUtils; @@ -36,6 +36,7 @@ import org.springframework.util.CollectionUtils;
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @since 4.1
* @see OptionalToObjectConverter
*/
final class ObjectToOptionalConverter implements ConditionalGenericConverter {

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

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.convert.support;
import java.util.Optional;
import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.converter.ConditionalGenericConverter;
/**
* Convert an {@link Optional} to an {@link Object} by unwrapping the {@code Optional},
* using the {@link ConversionService} to convert the object contained in the
* {@code Optional} (potentially {@code null}) to the target type.
*
* @author Sam Brannen
* @since 7.0
* @see ObjectToOptionalConverter
*/
final class OptionalToObjectConverter implements ConditionalGenericConverter {
private final ConversionService conversionService;
OptionalToObjectConverter(ConversionService conversionService) {
this.conversionService = conversionService;
}
@Override
public Set<ConvertiblePair> getConvertibleTypes() {
return Set.of(new ConvertiblePair(Optional.class, Object.class));
}
@Override
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
return ConversionUtils.canConvertElements(sourceType.getElementTypeDescriptor(), targetType, this.conversionService);
}
@Override
public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
if (source == null) {
return null;
}
Optional<?> optional = (Optional<?>) source;
Object unwrappedSource = optional.orElse(null);
TypeDescriptor unwrappedSourceType = TypeDescriptor.forObject(unwrappedSource);
return this.conversionService.convert(unwrappedSource, unwrappedSourceType, targetType);
}
}

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

@ -48,6 +48,7 @@ import org.junit.jupiter.api.Nested; @@ -48,6 +48,7 @@ import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.core.convert.ConverterNotFoundException;
import org.springframework.core.convert.TypeDescriptor;
@ -56,6 +57,7 @@ import org.springframework.util.ClassUtils; @@ -56,6 +57,7 @@ import org.springframework.util.ClassUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.byLessThan;
import static org.assertj.core.api.Assertions.entry;
/**
@ -978,6 +980,19 @@ class DefaultConversionServiceTests { @@ -978,6 +980,19 @@ class DefaultConversionServiceTests {
assertThat(conversionService.convert(null, rawOptionalType, TypeDescriptor.valueOf(Object.class))).isNull();
}
@Test // gh-34544
void convertEmptyOptionalToNull() {
Optional<Object> empty = Optional.empty();
assertThat(conversionService.convert(empty, Object.class)).isNull();
assertThat(conversionService.convert(empty, String.class)).isNull();
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(Object.class))).isNull();
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(String.class))).isNull();
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(Integer[].class))).isNull();
assertThat(conversionService.convert(empty, rawOptionalType, TypeDescriptor.valueOf(List.class))).isNull();
}
@Test
void convertEmptyOptionalToOptional() {
assertThat((Object) conversionService.convert(Optional.empty(), Optional.class)).isSameAs(Optional.empty());
@ -985,6 +1000,68 @@ class DefaultConversionServiceTests { @@ -985,6 +1000,68 @@ class DefaultConversionServiceTests {
.isSameAs(Optional.empty());
}
@Test // gh-34544
@SuppressWarnings("unchecked")
void convertOptionalToOptionalWithoutConversionOfContainedObject() {
assertThat(conversionService.convert(Optional.of(42), Optional.class)).contains(42);
assertThat(conversionService.convert(Optional.of("enigma"), Optional.class)).contains("enigma");
assertThat((Optional<String>) conversionService.convert(Optional.of("enigma"), rawOptionalType, rawOptionalType))
.contains("enigma");
}
@Test // gh-34544
@SuppressWarnings("unchecked")
void convertOptionalToOptionalWithConversionOfContainedObject() {
TypeDescriptor integerOptionalType =
new TypeDescriptor(ResolvableType.forClassWithGenerics(Optional.class, Integer.class), null, null);
TypeDescriptor stringOptionalType =
new TypeDescriptor(ResolvableType.forClassWithGenerics(Optional.class, String.class), null, null);
assertThat((Optional<String>) conversionService.convert(Optional.of(42), integerOptionalType, stringOptionalType))
.contains("42");
}
@Test // gh-34544
@SuppressWarnings("unchecked")
void convertOptionalToObjectWithoutConversionOfContainedObject() {
assertThat(conversionService.convert(Optional.of("enigma"), String.class)).isEqualTo("enigma");
assertThat(conversionService.convert(Optional.of(42), Integer.class)).isEqualTo(42);
assertThat(conversionService.convert(Optional.of(new int[] {1, 2, 3}), int[].class)).containsExactly(1, 2, 3);
assertThat(conversionService.convert(Optional.of(new Integer[] {1, 2, 3}), Integer[].class)).containsExactly(1, 2, 3);
assertThat(conversionService.convert(Optional.of(List.of(1, 2, 3)), List.class)).containsExactly(1, 2, 3);
}
@Test // gh-34544
@SuppressWarnings("unchecked")
void convertOptionalToObjectWithConversionOfContainedObject() {
assertThat(conversionService.convert(Optional.of(42), String.class)).isEqualTo("42");
assertThat(conversionService.convert(Optional.of(3.14F), Double.class)).isCloseTo(3.14, byLessThan(0.001));
assertThat(conversionService.convert(Optional.of(new int[] {1, 2, 3}), Integer[].class)).containsExactly(1, 2, 3);
assertThat(conversionService.convert(Optional.of(List.of(1, 2, 3)), Set.class)).containsExactly(1, 2, 3);
}
@Test // gh-34544
@SuppressWarnings("unchecked")
void convertNestedOptionalsToObject() {
assertThat(conversionService.convert(Optional.of(Optional.of("unwrap me twice")), String.class))
.isEqualTo("unwrap me twice");
}
@Test // gh-34544
@SuppressWarnings("unchecked")
void convertOptionalToObjectViaTypeDescriptorForMethodParameter() {
Method method = ClassUtils.getMethod(getClass(), "handleList", List.class);
MethodParameter parameter = new MethodParameter(method, 0);
TypeDescriptor descriptor = new TypeDescriptor(parameter);
Optional<List<Integer>> source = Optional.of(List.of(1, 2, 3));
assertThat((List<Integer>) conversionService.convert(source, rawOptionalType, descriptor)).containsExactly(1, 2, 3);
}
public void handleList(List<Integer> value) {
}
public void handleOptionalList(Optional<List<Integer>> value) {
}
}

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

@ -18,8 +18,10 @@ package org.springframework.expression.spel; @@ -18,8 +18,10 @@ package org.springframework.expression.spel;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
@ -38,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThat; @@ -38,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*
* @author Andy Clement
* @author Dave Syer
* @author Sam Brannen
*/
class ExpressionWithConversionTests extends AbstractExpressionTests {
@ -152,6 +155,27 @@ class ExpressionWithConversionTests extends AbstractExpressionTests { @@ -152,6 +155,27 @@ class ExpressionWithConversionTests extends AbstractExpressionTests {
assertThat(baz.value).isEqualTo("quux");
}
@Test // gh-34544
void convertOptionalToContainedTargetForMethodInvocations() {
StandardEvaluationContext context = new StandardEvaluationContext(new JediService());
// Verify findByName('Yoda') returns an Optional.
Expression expression = parser.parseExpression("findByName('Yoda') instanceof T(java.util.Optional)");
assertThat(expression.getValue(context, Boolean.class)).isTrue();
// Verify we can pass a Jedi directly to greet().
expression = parser.parseExpression("greet(findByName('Yoda').get())");
assertThat(expression.getValue(context, String.class)).isEqualTo("Hello, Yoda");
// Verify that an Optional<Jedi> will be unwrapped to a Jedi to pass to greet().
expression = parser.parseExpression("greet(findByName('Yoda'))");
assertThat(expression.getValue(context, String.class)).isEqualTo("Hello, Yoda");
// Verify that an empty Optional will be converted to null to pass to greet().
expression = parser.parseExpression("greet(findByName(''))");
assertThat(expression.getValue(context, String.class)).isEqualTo("Hello, null");
}
public static class Foo {
@ -180,4 +204,21 @@ class ExpressionWithConversionTests extends AbstractExpressionTests { @@ -180,4 +204,21 @@ class ExpressionWithConversionTests extends AbstractExpressionTests {
}
}
record Jedi(String name) {
}
static class JediService {
public Optional<Jedi> findByName(String name) {
if (name.isEmpty()) {
return Optional.empty();
}
return Optional.of(new Jedi(name));
}
public String greet(@Nullable Jedi jedi) {
return "Hello, " + (jedi != null ? jedi.name() : null);
}
}
}

Loading…
Cancel
Save