Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce ConversionSupport in junit-platform-commons #3507

Merged
merged 5 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ JUnit repository on GitHub.

==== New Features and Improvements

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


[[release-notes-5.11.0-M1-junit-jupiter]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,21 @@

package org.junit.jupiter.params.converter;

import static java.util.Arrays.asList;
import static java.util.Collections.unmodifiableList;
import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.platform.commons.util.ReflectionUtils.getWrapperType;

import java.io.File;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URI;
import java.net.URL;
import java.util.Currency;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;

import org.apiguardian.api.API;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.platform.commons.support.conversion.ConversionException;
import org.junit.platform.commons.support.conversion.StringConversionSupport;
import org.junit.platform.commons.util.ClassLoaderUtils;
import org.junit.platform.commons.util.ReflectionUtils;

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

public static final DefaultArgumentConverter INSTANCE = new DefaultArgumentConverter();

private static final List<StringToObjectConverter> stringToObjectConverters = unmodifiableList(asList( //
new StringToBooleanConverter(), //
new StringToCharacterConverter(), //
new StringToNumberConverter(), //
new StringToClassConverter(), //
new StringToEnumConverter(), //
new StringToJavaTimeConverter(), //
new StringToCommonJavaTypesConverter(), //
new FallbackStringToObjectConverter() //
));

private DefaultArgumentConverter() {
// nothing to initialize
}
Expand All @@ -88,34 +75,19 @@ public final Object convert(Object source, Class<?> targetType, ParameterContext
}

if (source instanceof String) {
Class<?> targetTypeToUse = toWrapperType(targetType);
Optional<StringToObjectConverter> converter = stringToObjectConverters.stream().filter(
candidate -> candidate.canConvert(targetTypeToUse)).findFirst();
if (converter.isPresent()) {
Class<?> declaringClass = context.getDeclaringExecutable().getDeclaringClass();
ClassLoader classLoader = ClassLoaderUtils.getClassLoader(declaringClass);
try {
return converter.get().convert((String) source, targetTypeToUse, classLoader);
}
catch (Exception ex) {
if (ex instanceof ArgumentConversionException) {
// simply rethrow it
throw (ArgumentConversionException) ex;
}
// else
throw new ArgumentConversionException(
"Failed to convert String \"" + source + "\" to type " + targetType.getTypeName(), ex);
}
Class<?> declaringClass = context.getDeclaringExecutable().getDeclaringClass();
ClassLoader classLoader = ClassLoaderUtils.getClassLoader(declaringClass);
try {
return StringConversionSupport.convert((String) source, targetType, classLoader);
}
catch (ConversionException ex) {
throw new ArgumentConversionException(ex.getMessage(), ex);
}
}

throw new ArgumentConversionException(
String.format("No built-in converter for source type %s and target type %s",
source.getClass().getTypeName(), targetType.getTypeName()));
}

private static Class<?> toWrapperType(Class<?> targetType) {
Class<?> wrapperType = getWrapperType(targetType);
return wrapperType != null ? wrapperType : targetType;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -157,24 +157,28 @@ void throwsExceptionOnInvalidStringForPrimitiveTypes() {
.isThrownBy(() -> convert("ab", char.class)) //
.withMessage("Failed to convert String \"ab\" to type char") //
.havingCause() //
.havingCause() //
.withMessage("String must have length of 1: ab");

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

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

assertThatExceptionOfType(ArgumentConversionException.class) //
.isThrownBy(() -> convert("NULL", boolean.class)) //
.withMessage("Failed to convert String \"NULL\" to type boolean") //
.havingCause() //
.havingCause() //
.withMessage("String must be 'true' or 'false' (ignoring case): NULL");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2015-2023 the original author or authors.
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License v2.0 which
* accompanies this distribution and is available at
*
* https://www.eclipse.org/legal/epl-v20.html
*/

package org.junit.platform.commons.support.conversion;

import static org.apiguardian.api.API.Status.EXPERIMENTAL;

import org.apiguardian.api.API;
import org.junit.platform.commons.JUnitException;

/**
* {@code ConversionException} is an exception that can occur when an
* object is converted to another object.
*
* @since 1.11
*/
@API(status = EXPERIMENTAL, since = "1.11")
public class ConversionException extends JUnitException {

private static final long serialVersionUID = 1L;

public ConversionException(String message) {
super(message);
}

public ConversionException(String message, Throwable cause) {
super(message, cause);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Copyright 2015-2023 the original author or authors.
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License v2.0 which
* accompanies this distribution and is available at
*
* https://www.eclipse.org/legal/epl-v20.html
*/

package org.junit.platform.commons.support.conversion;

import static org.junit.platform.commons.util.ReflectionUtils.HierarchyTraversalMode.BOTTOM_UP;
import static org.junit.platform.commons.util.ReflectionUtils.findConstructors;
import static org.junit.platform.commons.util.ReflectionUtils.findMethods;
import static org.junit.platform.commons.util.ReflectionUtils.invokeMethod;
import static org.junit.platform.commons.util.ReflectionUtils.isNotPrivate;
import static org.junit.platform.commons.util.ReflectionUtils.isNotStatic;
import static org.junit.platform.commons.util.ReflectionUtils.newInstance;

import java.lang.reflect.Constructor;
import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;

import org.junit.platform.commons.util.Preconditions;

/**
* {@code FallbackStringToObjectConverter} is a {@link StringToObjectConverter}
* that provides a fallback conversion strategy for converting from a
* {@link String} to a given target type by invoking a static factory method
* or factory constructor defined in the target type.
*
* <h2>Search Algorithm</h2>
*
* <ol>
* <li>Search for a single, non-private static factory method in the target
* type that converts from a String to the target type. Use the factory method
* if present.</li>
* <li>Search for a single, non-private constructor in the target type that
* accepts a String. Use the constructor if present.</li>
* </ol>
*
* <p>If multiple suitable factory methods are discovered they will be ignored.
* If neither a single factory method nor a single constructor is found, this
* converter acts as a no-op.
*
* @since 1.11
* @see StringConversionSupport
*/
class FallbackStringToObjectConverter implements StringToObjectConverter {

/**
* Implementation of the NULL Object Pattern.
*/
private static final Function<String, Object> NULL_EXECUTABLE = source -> source;

/**
* Cache for factory methods and factory constructors.
*
* <p>Searches that do not find a factory method or constructor are tracked
* by the presence of a {@link #NULL_EXECUTABLE} object stored in the map.
* This prevents the framework from repeatedly searching for things which
* are already known not to exist.
*/
private static final ConcurrentHashMap<Class<?>, Function<String, Object>> factoryExecutableCache //
= new ConcurrentHashMap<>(64);

@Override
public boolean canConvert(Class<?> targetType) {
return findFactoryExecutable(targetType) != NULL_EXECUTABLE;
}

@Override
public Object convert(String source, Class<?> targetType) throws Exception {
Function<String, Object> executable = findFactoryExecutable(targetType);
Preconditions.condition(executable != NULL_EXECUTABLE,
"Illegal state: convert() must not be called if canConvert() returned false");

return executable.apply(source);
}

private static Function<String, Object> findFactoryExecutable(Class<?> targetType) {
return factoryExecutableCache.computeIfAbsent(targetType, type -> {
Method factoryMethod = findFactoryMethod(type);
if (factoryMethod != null) {
return source -> invokeMethod(factoryMethod, null, source);
}
Constructor<?> constructor = findFactoryConstructor(type);
if (constructor != null) {
return source -> newInstance(constructor, source);
}
return NULL_EXECUTABLE;
});
}

private static Method findFactoryMethod(Class<?> targetType) {
List<Method> factoryMethods = findMethods(targetType, new IsFactoryMethod(targetType), BOTTOM_UP);
if (factoryMethods.size() == 1) {
return factoryMethods.get(0);
}
return null;
}

private static Constructor<?> findFactoryConstructor(Class<?> targetType) {
List<Constructor<?>> constructors = findConstructors(targetType, new IsFactoryConstructor(targetType));
if (constructors.size() == 1) {
return constructors.get(0);
}
return null;
}

/**
* {@link Predicate} that determines if the {@link Method} supplied to
* {@link #test(Method)} is a non-private static factory method for the
* supplied {@link #targetType}.
*/
static class IsFactoryMethod implements Predicate<Method> {

private final Class<?> targetType;

IsFactoryMethod(Class<?> targetType) {
this.targetType = targetType;
}

@Override
public boolean test(Method method) {
// Please do not collapse the following into a single statement.
if (!method.getReturnType().equals(this.targetType)) {
return false;
}
if (isNotStatic(method)) {
return false;
}
return isNotPrivateAndAcceptsSingleStringArgument(method);
}

}

/**
* {@link Predicate} that determines if the {@link Constructor} supplied to
* {@link #test(Constructor)} is a non-private factory constructor for the
* supplied {@link #targetType}.
*/
static class IsFactoryConstructor implements Predicate<Constructor<?>> {

private final Class<?> targetType;

IsFactoryConstructor(Class<?> targetType) {
this.targetType = targetType;
}

@Override
public boolean test(Constructor<?> constructor) {
// Please do not collapse the following into a single statement.
if (!constructor.getDeclaringClass().equals(this.targetType)) {
return false;
}
return isNotPrivateAndAcceptsSingleStringArgument(constructor);
}

}

private static boolean isNotPrivateAndAcceptsSingleStringArgument(Executable executable) {
return isNotPrivate(executable) //
&& (executable.getParameterCount() == 1) //
&& (executable.getParameterTypes()[0] == String.class);
}

}
Loading
Loading