Skip to content

Introduce Converter in junit-platform-commons #4219

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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 @@ -21,6 +21,7 @@
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.converter.DefaultArgumentConverter;
import org.junit.platform.commons.support.conversion.TypeDescriptor;
import org.junit.platform.commons.util.ClassUtils;
import org.junit.platform.commons.util.Preconditions;

Expand All @@ -47,7 +48,7 @@ public static DefaultArgumentsAccessor create(ExtensionContext context, int invo

BiFunction<@Nullable Object, Class<?>, @Nullable Object> converter = (source,
targetType) -> new DefaultArgumentConverter(context) //
.convert(source, targetType, classLoader);
.convert(source, TypeDescriptor.forClass(targetType), classLoader);
return new DefaultArgumentsAccessor(converter, invocationIndex, arguments);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.junit.jupiter.params.support.FieldContext;
import org.junit.platform.commons.support.conversion.ConversionException;
import org.junit.platform.commons.support.conversion.ConversionSupport;
import org.junit.platform.commons.support.conversion.TypeDescriptor;
import org.junit.platform.commons.util.ReflectionUtils;

/**
Expand All @@ -43,7 +44,7 @@
* {@link File}, {@link BigDecimal}, {@link BigInteger}, {@link Currency},
* {@link Locale}, {@link URI}, {@link URL}, {@link UUID}, etc.
*
* <p>If the source and target types are identical the source object will not
* <p>If the source and target types are identical, the source object will not
* be modified.
*
* @since 5.0
Expand Down Expand Up @@ -82,48 +83,42 @@ public DefaultArgumentConverter(ExtensionContext context) {

@Override
public final @Nullable Object convert(@Nullable Object source, ParameterContext context) {
Class<?> targetType = context.getParameter().getType();
ClassLoader classLoader = getClassLoader(context.getDeclaringExecutable().getDeclaringClass());
return convert(source, targetType, classLoader);
return convert(source, TypeDescriptor.forParameter(context.getParameter()), classLoader);
}

@Override
public final @Nullable Object convert(@Nullable Object source, FieldContext context)
throws ArgumentConversionException {

Class<?> targetType = context.getField().getType();
ClassLoader classLoader = getClassLoader(context.getField().getDeclaringClass());
return convert(source, targetType, classLoader);
return convert(source, TypeDescriptor.forField(context.getField()), classLoader);
}

public final @Nullable Object convert(@Nullable Object source, Class<?> targetType, ClassLoader classLoader) {
public final @Nullable Object convert(@Nullable Object source, TypeDescriptor targetType, ClassLoader classLoader) {
if (source == null) {
if (targetType.isPrimitive()) {
throw new ArgumentConversionException(
"Cannot convert null to primitive value of type " + targetType.getTypeName());
"Cannot convert null to primitive value of type " + targetType.getType().getTypeName());
}
return null;
}

if (ReflectionUtils.isAssignableTo(source, targetType)) {
if (ReflectionUtils.isAssignableTo(source, targetType.getType())) {
return source;
}

if (source instanceof String string) {
if (targetType == Locale.class && getLocaleConversionFormat() == LocaleConversionFormat.BCP_47) {
return Locale.forLanguageTag(string);
}

try {
return convert(string, targetType, classLoader);
}
catch (ConversionException ex) {
throw new ArgumentConversionException(ex.getMessage(), ex);
}
if (source instanceof String //
&& targetType.getType() == Locale.class //
&& getLocaleConversionFormat() == LocaleConversionFormat.BCP_47) {
return Locale.forLanguageTag((String) source);
}

throw new ArgumentConversionException("No built-in converter for source type %s and target type %s".formatted(
source.getClass().getTypeName(), targetType.getTypeName()));
try {
return delegateConversion(source, targetType, classLoader);
}
catch (ConversionException ex) {
throw new ArgumentConversionException(ex.getMessage(), ex);
}
}

private LocaleConversionFormat getLocaleConversionFormat() {
Expand All @@ -132,7 +127,7 @@ private LocaleConversionFormat getLocaleConversionFormat() {
}

@Nullable
Object convert(@Nullable String source, Class<?> targetType, ClassLoader classLoader) {
Object delegateConversion(@Nullable Object source, TypeDescriptor targetType, ClassLoader classLoader) {
return ConversionSupport.convert(source, targetType, classLoader);
}

Expand Down
1 change: 1 addition & 0 deletions junit-platform-commons/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,6 @@
org.junit.platform.suite.engine,
org.junit.platform.testkit,
org.junit.vintage.engine;
uses org.junit.platform.commons.support.conversion.Converter;
uses org.junit.platform.commons.support.scanning.ClasspathScanner;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2015-2025 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.jspecify.annotations.Nullable;

/**
* {@code ConversionContext} encapsulates the <em>context</em> in which the
* current conversion is being executed.
*
* <p>{@link Converter Converters} are provided an instance of
* {@code ConversionContext} to perform their work.
*
* @param sourceType
* @param targetType
* @param classLoader
*
* @since 6.0
* @see Converter
*/
@API(status = EXPERIMENTAL, since = "6.0")
public record ConversionContext(TypeDescriptor sourceType, TypeDescriptor targetType, ClassLoader classLoader) {

/**
*
* @param source
* @param targetType
* @param classLoader
*/
public ConversionContext(@Nullable Object source, TypeDescriptor targetType, ClassLoader classLoader) {
this(TypeDescriptor.forInstance(source), targetType, classLoader);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@

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

import static org.apiguardian.api.API.Status.DEPRECATED;
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.junit.platform.commons.util.ReflectionUtils.getWrapperType;

import java.util.List;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import org.apiguardian.api.API;
import org.jspecify.annotations.Nullable;
import org.junit.platform.commons.util.ClassLoaderUtils;

/**
* {@code ConversionSupport} provides static utility methods for converting a
Expand All @@ -29,17 +29,6 @@
@API(status = EXPERIMENTAL, since = "1.11")
public final class ConversionSupport {

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

private ConversionSupport() {
/* no-op */
}
Expand All @@ -48,43 +37,6 @@ private ConversionSupport() {
* Convert the supplied source {@code String} into an instance of the specified
* target type.
*
* <p>If the target type is {@code String}, the source {@code String} will not
* be modified.
*
* <p>Some forms of conversion require a {@link ClassLoader}. If none is
* provided, the {@linkplain ClassLoaderUtils#getDefaultClassLoader() default
* ClassLoader} will be used.
*
* <p>This method is able to convert strings into primitive types and their
* corresponding wrapper types ({@link Boolean}, {@link Character}, {@link Byte},
* {@link Short}, {@link Integer}, {@link Long}, {@link Float}, and
* {@link Double}), enum constants, date and time types from the
* {@code java.time} package, as well as common Java types such as {@link Class},
* {@link java.io.File}, {@link java.nio.file.Path}, {@link java.nio.charset.Charset},
* {@link java.math.BigDecimal}, {@link java.math.BigInteger},
* {@link java.util.Currency}, {@link java.util.Locale}, {@link java.util.UUID},
* {@link java.net.URI}, and {@link java.net.URL}.
*
* <p>If the target type is not covered by any of the above, a convention-based
* conversion strategy will be used to convert the source {@code String} into the
* given target type by invoking a static factory method or factory constructor
* defined in the target type. The search algorithm used in this strategy is
* outlined below.
*
* <h4>Search Algorithm</h4>
*
* <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, the
* convention-based conversion strategy will not apply.
*
* @param source the source {@code String} to convert; may be {@code null}
* but only if the target type is a reference type
* @param targetType the target type the source should be converted into;
Expand All @@ -96,49 +48,49 @@ private ConversionSupport() {
* type is a reference type
*
* @since 1.11
* @see DefaultConverter
* @deprecated Use {@link #convert(Object, TypeDescriptor, ClassLoader)} instead.
*/
@SuppressWarnings("unchecked")
@Deprecated
@API(status = DEPRECATED, since = "6.0")
public static <T> @Nullable T convert(@Nullable String source, Class<T> targetType,
@Nullable ClassLoader classLoader) {
if (source == null) {
if (targetType.isPrimitive()) {
throw new ConversionException(
"Cannot convert null to primitive value of type " + targetType.getTypeName());
}
return null;
}

if (String.class.equals(targetType)) {
return (T) source;
}
return convert(source, TypeDescriptor.forClass(targetType), classLoader);
}

Class<?> targetTypeToUse = toWrapperType(targetType);
Optional<StringToObjectConverter> converter = stringToObjectConverters.stream().filter(
candidate -> candidate.canConvertTo(targetTypeToUse)).findFirst();
if (converter.isPresent()) {
try {
ClassLoader classLoaderToUse = classLoader != null ? classLoader
: ClassLoaderUtils.getDefaultClassLoader();
return (T) converter.get().convert(source, targetTypeToUse, classLoaderToUse);
}
catch (Exception ex) {
if (ex instanceof ConversionException conversionException) {
// simply rethrow it
throw conversionException;
}
// else
throw new ConversionException(
"Failed to convert String \"%s\" to type %s".formatted(source, targetType.getTypeName()), ex);
}
}
/**
* Convert the supplied source object into an instance of the specified
* target type.
*
* @param source the source object to convert; may be {@code null}
* but only if the target type is a reference type
* @param targetType the target type the source should be converted into;
* never {@code null}
* @param classLoader the {@code ClassLoader} to use; may be {@code null} to
* use the default {@code ClassLoader}
* @param <T> the type of the target
* @return the converted object; may be {@code null} but only if the target
* type is a reference type
*
* @since 6.0
*/
@API(status = EXPERIMENTAL, since = "6.0")
@SuppressWarnings({ "unchecked", "rawtypes" })
public static <T> @Nullable T convert(@Nullable Object source, TypeDescriptor targetType,
@Nullable ClassLoader classLoader) {
ConversionContext context = new ConversionContext(source, targetType, classLoader);
ServiceLoader<Converter> serviceLoader = ServiceLoader.load(Converter.class, context.classLoader());

throw new ConversionException(
"No built-in converter for source type java.lang.String and target type " + targetType.getTypeName());
}
Converter converter = Stream.concat( //
StreamSupport.stream(serviceLoader.spliterator(), false), //
Stream.of(DefaultConverter.INSTANCE)) //
.filter(candidate -> candidate.canConvert(context)) //
.findFirst() //
.orElseThrow(() -> new ConversionException(
"No registered or built-in converter for source '%s' and target type %s".formatted( //
source, targetType.getTypeName())));

private static Class<?> toWrapperType(Class<?> targetType) {
Class<?> wrapperType = getWrapperType(targetType);
return wrapperType != null ? wrapperType : targetType;
return (T) converter.convert(source, context);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright 2015-2025 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.jspecify.annotations.Nullable;

/**
* {@code Converter} is an abstraction that allows an input object to
* be converted to an instance of a different class.
*
* <p>Implementations are loaded via the {@link java.util.ServiceLoader} and must
* follow the service provider requirements. They should not make any assumptions
* regarding when they are instantiated or how often they are called. Since
* instances may potentially be cached and called from different threads, they
* should be thread-safe.
*
* <p>Extend {@link TypedConverter} if your implementation always converts
* from a given source type into a given target type and does not need access to
* the {@link ClassLoader} to perform the conversion.
*
* @param <S>
* @param <T>
*
* @since 6.0
* @see ConversionSupport
* @see TypedConverter
*/
@API(status = EXPERIMENTAL, since = "6.0")
public interface Converter<S, T> {

/**
* Determine if the supplied conversion context is supported.
*
* @param context the context for the conversion; never {@code null}
* @return {@code true} if the conversion is supported
*/
boolean canConvert(ConversionContext context);

/**
* Convert the supplied source object according to the supplied conversion context.
* <p>This method will only be invoked if {@link #canConvert(ConversionContext)}
* returned {@code true} for the same context.
*
* @param source the source object to convert; may be {@code null}
* but only if the target type is a reference type
* @param context the context for the conversion; never {@code null}
* @return the converted object; may be {@code null} but only if the target
* type is a reference type
* @throws ConversionException if an error occurs during the conversion
*/
@Nullable
T convert(@Nullable S source, ConversionContext context) throws ConversionException;

}
Loading
Loading