Skip to content

Commit

Permalink
Consider return type of static methods in ObjectToObjectConverter
Browse files Browse the repository at this point in the history
Prior to this commit, ObjectToObjectConverter considered the return
type of non-static `to[targetType.simpleName]()` methods but did not
consider the return type of static `valueOf(sourceType)`,
`of(sourceType)`, and `from(sourceType)` methods.

This led to scenarios in which `canConvert()` returned `true`, but a
subsequent `convert()` invocation resulted in a
ConverterNotFoundException, which violates the contract of the
converter.

This commit addresses this issue by taking into account the return type
of a static valueOf/of/from factory method when determining if the
ObjectToObjectConverter supports a particular conversion. Whereas the
existing check in `determineToMethod()` ensures that the method return
type is assignable to the `targetType`, the new check in
`determineFactoryMethod()` leniently ensures that the method return
type and `targetType` are "related" (i.e., reside in the same type
hierarchy).

Closes gh-28609
  • Loading branch information
sbrannen committed Jun 13, 2022
1 parent c278d8c commit 452f1b8
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 5 deletions.
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2017 the original author or authors.
* Copyright 2002-2022 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.
Expand Down Expand Up @@ -41,15 +41,21 @@
* <h3>Conversion Algorithm</h3>
* <ol>
* <li>Invoke a non-static {@code to[targetType.simpleName]()} method on the
* source object that has a return type equal to {@code targetType}, if such
* source object that has a return type assignable to {@code targetType}, if such
* a method exists. For example, {@code org.example.Bar Foo#toBar()} is a
* method that follows this convention.
* <li>Otherwise invoke a <em>static</em> {@code valueOf(sourceType)} or Java
* 8 style <em>static</em> {@code of(sourceType)} or {@code from(sourceType)}
* method on the {@code targetType}, if such a method exists.
* method on the {@code targetType} that has a return type <em>related</em> to
* {@code targetType}, if such a method exists. For example, a static
* {@code Foo.of(sourceType)} method that returns a {@code Foo},
* {@code SuperFooType}, or {@code SubFooType} is a method that follows this
* convention. {@link java.time.ZoneId#of(String)} is a concrete example of
* such a static factory method which returns a subtype of {@code ZoneId}.
* <li>Otherwise invoke a constructor on the {@code targetType} that accepts
* a single {@code sourceType} argument, if such a constructor exists.
* <li>Otherwise throw a {@link ConversionFailedException}.
* <li>Otherwise throw a {@link ConversionFailedException} or
* {@link IllegalStateException}.
* </ol>
*
* <p><strong>Warning</strong>: this converter does <em>not</em> support the
Expand Down Expand Up @@ -193,7 +199,18 @@ private static Method determineFactoryMethod(Class<?> targetClass, Class<?> sour
method = ClassUtils.getStaticMethod(targetClass, "from", sourceClass);
}
}
return method;

return (method != null && areRelatedTypes(targetClass, method.getReturnType()) ? method : null);
}

/**
* Determine if the two types reside in the same type hierarchy (i.e., type 1
* is assignable to type 2 or vice versa).
* @since 5.3.21
* @see ClassUtils#isAssignable(Class, Class)
*/
private static boolean areRelatedTypes(Class<?> type1, Class<?> type2) {
return (ClassUtils.isAssignable(type1, type2) || ClassUtils.isAssignable(type2, type1));
}

@Nullable
Expand Down
Expand Up @@ -824,6 +824,9 @@ void convertObjectToStringWithValueOfMethodPresentUsingToString() {
assertThat(ISBN.toStringCount).as("toString() invocations").isEqualTo(1);
}

/**
* @see org.springframework.core.convert.support.ObjectToObjectConverterTests
*/
@Test
void convertObjectToObjectUsingValueOfMethod() {
ISBN.reset();
Expand Down
@@ -0,0 +1,123 @@
/*
* Copyright 2002-2022 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 org.junit.jupiter.api.Test;

import org.springframework.core.convert.ConverterNotFoundException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;

/**
* Unit tests for {@link ObjectToObjectConverter}.
*
* @author Sam Brannen
* @author Phil Webb
* @since 5.3.21
* @see org.springframework.core.convert.converter.DefaultConversionServiceTests#convertObjectToObjectUsingValueOfMethod()
*/
class ObjectToObjectConverterTests {

private final GenericConversionService conversionService = new GenericConversionService() {{
addConverter(new ObjectToObjectConverter());
}};


/**
* This test effectively verifies that the {@link ObjectToObjectConverter}
* was properly registered with the {@link GenericConversionService}.
*/
@Test
void nonStaticToTargetTypeSimpleNameMethodWithMatchingReturnType() {
assertThat(conversionService.canConvert(Source.class, Data.class))
.as("can convert Source to Data").isTrue();
Data data = conversionService.convert(new Source("test"), Data.class);
assertThat(data).asString().isEqualTo("test");
}

@Test
void nonStaticToTargetTypeSimpleNameMethodWithDifferentReturnType() {
assertThat(conversionService.canConvert(Text.class, Data.class))
.as("can convert Text to Data").isFalse();
assertThat(conversionService.canConvert(Text.class, Optional.class))
.as("can convert Text to Optional").isFalse();
assertThatExceptionOfType(ConverterNotFoundException.class)
.as("convert Text to Data")
.isThrownBy(() -> conversionService.convert(new Text("test"), Data.class));
}

@Test
void staticValueOfFactoryMethodWithDifferentReturnType() {
assertThat(conversionService.canConvert(String.class, Data.class))
.as("can convert String to Data").isFalse();
assertThatExceptionOfType(ConverterNotFoundException.class)
.as("convert String to Data")
.isThrownBy(() -> conversionService.convert("test", Data.class));
}


static class Source {

private final String value;

private Source(String value) {
this.value = value;
}

public Data toData() {
return new Data(this.value);
}

}

static class Text {

private final String value;

private Text(String value) {
this.value = value;
}

public Optional<Data> toData() {
return Optional.of(new Data(this.value));
}

}

static class Data {

private final String value;

private Data(String value) {
this.value = value;
}

@Override
public String toString() {
return this.value;
}

public static Optional<Data> valueOf(String string) {
return (string != null) ? Optional.of(new Data(string)) : Optional.empty();
}

}

}

0 comments on commit 452f1b8

Please sign in to comment.