Skip to content

Commit 30dbf58

Browse files
Introduce StringConversionSupport in junit-platform-commons (#3507)
1 parent 33753cf commit 30dbf58

File tree

17 files changed

+708
-40
lines changed

17 files changed

+708
-40
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-M1.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ JUnit repository on GitHub.
2525

2626
==== New Features and Improvements
2727

28-
* ❓
28+
* New `StringConversionSupport` in `junit-platform-commons` to expose
29+
internal conversion logic used by Jupiter's `DefaultArgumentConverter`
2930

3031

3132
[[release-notes-5.11.0-M1-junit-jupiter]]

junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,21 @@
1010

1111
package org.junit.jupiter.params.converter;
1212

13-
import static java.util.Arrays.asList;
14-
import static java.util.Collections.unmodifiableList;
1513
import static org.apiguardian.api.API.Status.INTERNAL;
16-
import static org.junit.platform.commons.util.ReflectionUtils.getWrapperType;
1714

1815
import java.io.File;
1916
import java.math.BigDecimal;
2017
import java.math.BigInteger;
2118
import java.net.URI;
2219
import java.net.URL;
2320
import java.util.Currency;
24-
import java.util.List;
2521
import java.util.Locale;
26-
import java.util.Optional;
2722
import java.util.UUID;
2823

2924
import org.apiguardian.api.API;
3025
import org.junit.jupiter.api.extension.ParameterContext;
26+
import org.junit.platform.commons.support.conversion.ConversionException;
27+
import org.junit.platform.commons.support.conversion.StringConversionSupport;
3128
import org.junit.platform.commons.util.ClassLoaderUtils;
3229
import org.junit.platform.commons.util.ReflectionUtils;
3330

@@ -47,23 +44,13 @@
4744
*
4845
* @since 5.0
4946
* @see org.junit.jupiter.params.converter.ArgumentConverter
47+
* @see org.junit.platform.commons.support.conversion.StringConversionSupport
5048
*/
5149
@API(status = INTERNAL, since = "5.0")
5250
public class DefaultArgumentConverter implements ArgumentConverter {
5351

5452
public static final DefaultArgumentConverter INSTANCE = new DefaultArgumentConverter();
5553

56-
private static final List<StringToObjectConverter> stringToObjectConverters = unmodifiableList(asList( //
57-
new StringToBooleanConverter(), //
58-
new StringToCharacterConverter(), //
59-
new StringToNumberConverter(), //
60-
new StringToClassConverter(), //
61-
new StringToEnumConverter(), //
62-
new StringToJavaTimeConverter(), //
63-
new StringToCommonJavaTypesConverter(), //
64-
new FallbackStringToObjectConverter() //
65-
));
66-
6754
private DefaultArgumentConverter() {
6855
// nothing to initialize
6956
}
@@ -88,34 +75,19 @@ public final Object convert(Object source, Class<?> targetType, ParameterContext
8875
}
8976

9077
if (source instanceof String) {
91-
Class<?> targetTypeToUse = toWrapperType(targetType);
92-
Optional<StringToObjectConverter> converter = stringToObjectConverters.stream().filter(
93-
candidate -> candidate.canConvert(targetTypeToUse)).findFirst();
94-
if (converter.isPresent()) {
95-
Class<?> declaringClass = context.getDeclaringExecutable().getDeclaringClass();
96-
ClassLoader classLoader = ClassLoaderUtils.getClassLoader(declaringClass);
97-
try {
98-
return converter.get().convert((String) source, targetTypeToUse, classLoader);
99-
}
100-
catch (Exception ex) {
101-
if (ex instanceof ArgumentConversionException) {
102-
// simply rethrow it
103-
throw (ArgumentConversionException) ex;
104-
}
105-
// else
106-
throw new ArgumentConversionException(
107-
"Failed to convert String \"" + source + "\" to type " + targetType.getTypeName(), ex);
108-
}
78+
Class<?> declaringClass = context.getDeclaringExecutable().getDeclaringClass();
79+
ClassLoader classLoader = ClassLoaderUtils.getClassLoader(declaringClass);
80+
try {
81+
return StringConversionSupport.convert((String) source, targetType, classLoader);
82+
}
83+
catch (ConversionException ex) {
84+
throw new ArgumentConversionException(ex.getMessage(), ex);
10985
}
11086
}
87+
11188
throw new ArgumentConversionException(
11289
String.format("No built-in converter for source type %s and target type %s",
11390
source.getClass().getTypeName(), targetType.getTypeName()));
11491
}
11592

116-
private static Class<?> toWrapperType(Class<?> targetType) {
117-
Class<?> wrapperType = getWrapperType(targetType);
118-
return wrapperType != null ? wrapperType : targetType;
119-
}
120-
12193
}

junit-jupiter-params/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,24 +157,28 @@ void throwsExceptionOnInvalidStringForPrimitiveTypes() {
157157
.isThrownBy(() -> convert("ab", char.class)) //
158158
.withMessage("Failed to convert String \"ab\" to type char") //
159159
.havingCause() //
160+
.havingCause() //
160161
.withMessage("String must have length of 1: ab");
161162

162163
assertThatExceptionOfType(ArgumentConversionException.class) //
163164
.isThrownBy(() -> convert("tru", boolean.class)) //
164165
.withMessage("Failed to convert String \"tru\" to type boolean") //
165166
.havingCause() //
167+
.havingCause() //
166168
.withMessage("String must be 'true' or 'false' (ignoring case): tru");
167169

168170
assertThatExceptionOfType(ArgumentConversionException.class) //
169171
.isThrownBy(() -> convert("null", boolean.class)) //
170172
.withMessage("Failed to convert String \"null\" to type boolean") //
171173
.havingCause() //
174+
.havingCause() //
172175
.withMessage("String must be 'true' or 'false' (ignoring case): null");
173176

174177
assertThatExceptionOfType(ArgumentConversionException.class) //
175178
.isThrownBy(() -> convert("NULL", boolean.class)) //
176179
.withMessage("Failed to convert String \"NULL\" to type boolean") //
177180
.havingCause() //
181+
.havingCause() //
178182
.withMessage("String must be 'true' or 'false' (ignoring case): NULL");
179183
}
180184

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2015-2023 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.commons.support.conversion;
12+
13+
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
14+
15+
import org.apiguardian.api.API;
16+
import org.junit.platform.commons.JUnitException;
17+
18+
/**
19+
* {@code ConversionException} is an exception that can occur when an
20+
* object is converted to another object.
21+
*
22+
* @since 1.11
23+
*/
24+
@API(status = EXPERIMENTAL, since = "1.11")
25+
public class ConversionException extends JUnitException {
26+
27+
private static final long serialVersionUID = 1L;
28+
29+
public ConversionException(String message) {
30+
super(message);
31+
}
32+
33+
public ConversionException(String message, Throwable cause) {
34+
super(message, cause);
35+
}
36+
37+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
* Copyright 2015-2023 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.commons.support.conversion;
12+
13+
import static org.junit.platform.commons.util.ReflectionUtils.HierarchyTraversalMode.BOTTOM_UP;
14+
import static org.junit.platform.commons.util.ReflectionUtils.findConstructors;
15+
import static org.junit.platform.commons.util.ReflectionUtils.findMethods;
16+
import static org.junit.platform.commons.util.ReflectionUtils.invokeMethod;
17+
import static org.junit.platform.commons.util.ReflectionUtils.isNotPrivate;
18+
import static org.junit.platform.commons.util.ReflectionUtils.isNotStatic;
19+
import static org.junit.platform.commons.util.ReflectionUtils.newInstance;
20+
21+
import java.lang.reflect.Constructor;
22+
import java.lang.reflect.Executable;
23+
import java.lang.reflect.Method;
24+
import java.util.List;
25+
import java.util.concurrent.ConcurrentHashMap;
26+
import java.util.function.Function;
27+
import java.util.function.Predicate;
28+
29+
import org.junit.platform.commons.util.Preconditions;
30+
31+
/**
32+
* {@code FallbackStringToObjectConverter} is a {@link StringToObjectConverter}
33+
* that provides a fallback conversion strategy for converting from a
34+
* {@link String} to a given target type by invoking a static factory method
35+
* or factory constructor defined in the target type.
36+
*
37+
* <h2>Search Algorithm</h2>
38+
*
39+
* <ol>
40+
* <li>Search for a single, non-private static factory method in the target
41+
* type that converts from a String to the target type. Use the factory method
42+
* if present.</li>
43+
* <li>Search for a single, non-private constructor in the target type that
44+
* accepts a String. Use the constructor if present.</li>
45+
* </ol>
46+
*
47+
* <p>If multiple suitable factory methods are discovered they will be ignored.
48+
* If neither a single factory method nor a single constructor is found, this
49+
* converter acts as a no-op.
50+
*
51+
* @since 1.11
52+
* @see StringConversionSupport
53+
*/
54+
class FallbackStringToObjectConverter implements StringToObjectConverter {
55+
56+
/**
57+
* Implementation of the NULL Object Pattern.
58+
*/
59+
private static final Function<String, Object> NULL_EXECUTABLE = source -> source;
60+
61+
/**
62+
* Cache for factory methods and factory constructors.
63+
*
64+
* <p>Searches that do not find a factory method or constructor are tracked
65+
* by the presence of a {@link #NULL_EXECUTABLE} object stored in the map.
66+
* This prevents the framework from repeatedly searching for things which
67+
* are already known not to exist.
68+
*/
69+
private static final ConcurrentHashMap<Class<?>, Function<String, Object>> factoryExecutableCache //
70+
= new ConcurrentHashMap<>(64);
71+
72+
@Override
73+
public boolean canConvert(Class<?> targetType) {
74+
return findFactoryExecutable(targetType) != NULL_EXECUTABLE;
75+
}
76+
77+
@Override
78+
public Object convert(String source, Class<?> targetType) throws Exception {
79+
Function<String, Object> executable = findFactoryExecutable(targetType);
80+
Preconditions.condition(executable != NULL_EXECUTABLE,
81+
"Illegal state: convert() must not be called if canConvert() returned false");
82+
83+
return executable.apply(source);
84+
}
85+
86+
private static Function<String, Object> findFactoryExecutable(Class<?> targetType) {
87+
return factoryExecutableCache.computeIfAbsent(targetType, type -> {
88+
Method factoryMethod = findFactoryMethod(type);
89+
if (factoryMethod != null) {
90+
return source -> invokeMethod(factoryMethod, null, source);
91+
}
92+
Constructor<?> constructor = findFactoryConstructor(type);
93+
if (constructor != null) {
94+
return source -> newInstance(constructor, source);
95+
}
96+
return NULL_EXECUTABLE;
97+
});
98+
}
99+
100+
private static Method findFactoryMethod(Class<?> targetType) {
101+
List<Method> factoryMethods = findMethods(targetType, new IsFactoryMethod(targetType), BOTTOM_UP);
102+
if (factoryMethods.size() == 1) {
103+
return factoryMethods.get(0);
104+
}
105+
return null;
106+
}
107+
108+
private static Constructor<?> findFactoryConstructor(Class<?> targetType) {
109+
List<Constructor<?>> constructors = findConstructors(targetType, new IsFactoryConstructor(targetType));
110+
if (constructors.size() == 1) {
111+
return constructors.get(0);
112+
}
113+
return null;
114+
}
115+
116+
/**
117+
* {@link Predicate} that determines if the {@link Method} supplied to
118+
* {@link #test(Method)} is a non-private static factory method for the
119+
* supplied {@link #targetType}.
120+
*/
121+
static class IsFactoryMethod implements Predicate<Method> {
122+
123+
private final Class<?> targetType;
124+
125+
IsFactoryMethod(Class<?> targetType) {
126+
this.targetType = targetType;
127+
}
128+
129+
@Override
130+
public boolean test(Method method) {
131+
// Please do not collapse the following into a single statement.
132+
if (!method.getReturnType().equals(this.targetType)) {
133+
return false;
134+
}
135+
if (isNotStatic(method)) {
136+
return false;
137+
}
138+
return isNotPrivateAndAcceptsSingleStringArgument(method);
139+
}
140+
141+
}
142+
143+
/**
144+
* {@link Predicate} that determines if the {@link Constructor} supplied to
145+
* {@link #test(Constructor)} is a non-private factory constructor for the
146+
* supplied {@link #targetType}.
147+
*/
148+
static class IsFactoryConstructor implements Predicate<Constructor<?>> {
149+
150+
private final Class<?> targetType;
151+
152+
IsFactoryConstructor(Class<?> targetType) {
153+
this.targetType = targetType;
154+
}
155+
156+
@Override
157+
public boolean test(Constructor<?> constructor) {
158+
// Please do not collapse the following into a single statement.
159+
if (!constructor.getDeclaringClass().equals(this.targetType)) {
160+
return false;
161+
}
162+
return isNotPrivateAndAcceptsSingleStringArgument(constructor);
163+
}
164+
165+
}
166+
167+
private static boolean isNotPrivateAndAcceptsSingleStringArgument(Executable executable) {
168+
return isNotPrivate(executable) //
169+
&& (executable.getParameterCount() == 1) //
170+
&& (executable.getParameterTypes()[0] == String.class);
171+
}
172+
173+
}

0 commit comments

Comments
 (0)