Skip to content

Conversation

scordio
Copy link
Contributor

@scordio scordio commented Dec 23, 2024

Overview


I hereby agree to the terms of the JUnit Contributor License Agreement.


Definition of Done

@scordio
Copy link
Contributor Author

scordio commented Dec 23, 2024

There is plenty of work to do 🙃

The current highlights:

Any feedback would be highly appreciated!

@scordio scordio force-pushed the conversion-service branch 3 times, most recently from 3304ad5 to c886b7a Compare December 31, 2024 11:26
@marcphilipp
Copy link
Member

Thanks for the draft! 👍

The tests are failing due to:

org.junit.platform.commons.support.conversion.ConversionService: module org.junit.platform.commons does not declare uses

That's because junit-platform-commons/src/module/org.junit.platform.commons/module-info.java is missing

uses org.junit.platform.commons.support.conversion.ConversionService;

Copy link
Member

@marcphilipp marcphilipp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very promising! 👍


import org.junit.platform.commons.support.conversion.TypedConversionService;

// FIXME delete
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would make a good test case, though. We have existing tests that register services for tests using an extra class loader:

https://github.com/junit-team/junit5/blob/16c6f72c1c728c015e35cb739ea75884f19f990c/platform-tests/src/test/java/org/junit/platform/launcher/core/LauncherFactoryTests.java#L353-L366

We could generalize and move that method to a test utility class (e.g. in junit-jupiter-api/src/testFixtures) so it can be reused here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another possible integration test could be inspired by #3605.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could generalize and move that method to a test utility class (e.g. in junit-jupiter-api/src/testFixtures) so it can be reused here.

@marcphilipp fine if I do it in a separate PR? Mostly to keep the size of this one under control 🙃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raised #4544.

@marcphilipp
Copy link
Member

That's because junit-platform-commons/src/module/org.junit.platform.commons/module-info.java is missing

uses org.junit.platform.commons.support.conversion.ConversionService;

When you add that, you'll also have to add it to platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt to adjust the integration test.

@scordio scordio force-pushed the conversion-service branch 2 times, most recently from 2e17c2b to 0c2faa7 Compare January 2, 2025 16:35
@scordio
Copy link
Contributor Author

scordio commented Jan 15, 2025

I've been lagging behind with this one but I should be able to spend time on it in the upcoming weekend.

@scordio
Copy link
Contributor Author

scordio commented Apr 21, 2025

Would you like to include this in 5.13 too? I should have enough time in the upcoming days to finalize it.

@scordio scordio force-pushed the conversion-service branch 3 times, most recently from 0a36152 to e380b8f Compare April 27, 2025 11:33

@Override
protected @Nullable Locale convert(@Nullable String source) {
return source != null ? Locale.forLanguageTag(source) : null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class should return false from canConvert when source is null, shouldn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible that you commented before I refactored the Converter hierarchy, so what I'm about to say might not align with what you had in mind 🙂

Currently, LocaleConverter extends TypedConverter, and I assume the latter should maintain support for null values, similar to TypedArgumentConverter.

However, for the sake of this test, LocaleConverter could also extend Converter directly and have a more fine-grained canConvert implementation, given that the underlying Locale.forLanguageTag API is not supposed to accept null values.

Anyway, I think this test lost its value as DefaultConverter uses Locale.forLanguageTag natively (#4751), so I'll probably rewrite it differently or replace it with a more meaningful one.

@scordio
Copy link
Contributor Author

scordio commented May 26, 2025

@marcphilipp
Copy link
Member

@scordio Thanks! I've changed the setting.

@scordio
Copy link
Contributor Author

scordio commented Jun 1, 2025

Now that Converter has type parameters, would it make sense to implement that in TypedArgumentConverter so that the existing TypedArgumentConverter implementations could also be used with the new SPI?

Before exploring this in detail, I wanted to check your opinion first.

@scordio scordio force-pushed the conversion-service branch from 8b78923 to 789d5d5 Compare June 1, 2025 16:15
@marcphilipp
Copy link
Member

I think that could be useful and worth giving a try. 👍

@scordio scordio force-pushed the conversion-service branch from 789d5d5 to 4ea8824 Compare June 19, 2025 16:55
@scordio scordio force-pushed the conversion-service branch from 4ea8824 to 17591cc Compare July 5, 2025 08:06
@scordio scordio force-pushed the conversion-service branch 5 times, most recently from f2cdba7 to 42cc962 Compare July 22, 2025 06:19
Comment on lines +81 to +85
// FIXME [NullAway] parameter type of referenced method is @NonNull, but parameter in functional interface method java.util.function.Function.apply(T) is @Nullable
@SuppressWarnings("NullAway")
public Optional<Class<?>> getWrapperType() {
return Optional.ofNullable(type).map(ReflectionUtils::getWrapperType);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a NullAway bug. I'll compose a small reproducer and raise an issue with the project.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that is weird. How does NullAway know about the nullability of the Function type parameters of Optional.map? Maybe it doesn't?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still playing with it to understand it better. For example, the error is slightly different with a lambda:

public Optional<Class<?>> getWrapperType() {
	return Optional.ofNullable(type).map(value -> ReflectionUtils.getWrapperType(value));
}
warning: [NullAway] passing @Nullable parameter 'value' where @NonNull is required
		return Optional.ofNullable(type).map(value -> ReflectionUtils.getWrapperType(value));
		                                                                             ^
    (see http://t.uber.com/nullaway )

I would have expected NullAway to infer that value is non-nullable, but maybe it has no way to do that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks to me like it thinks Optional.map is typed as

<U> Optional<U> map(Function<? super @Nullable T, ? extends @Nullable U> mapper)

when it should be

<U> Optional<U> map(Function<? super @NonNull T, ? extends @Nullable U> mapper)

@scordio
Copy link
Contributor Author

scordio commented Jul 22, 2025

@marcphilipp @sbrannen I think this is now ready for another round of reviews!

A few points that still require work:

  • Better test coverage (unit and integration tests)
  • Documentation (Javadoc and user guide)
  • The unresolved comments above

@scordio scordio force-pushed the conversion-service branch from fe8883d to a57f5c9 Compare July 22, 2025 10:50

@Override
public final T convert(@Nullable S source, ConversionContext context) {
return convert(source);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this extra indirection worth it?

Copy link
Contributor Author

@scordio scordio Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The advantage I see is that classes extending this one will have a simpler surface, without ConversionContext appearing in the code.

Also, after the updates mentioned at #4219 (comment), this will prevent null source.

* @see Converter
*/
@API(status = EXPERIMENTAL, since = "6.0")
public record ConversionContext(TypeDescriptor sourceType, TypeDescriptor targetType, ClassLoader classLoader) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the constructors need to be public so they can be used in unit tests for Converter implementations, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept them public for any code invoking ConversionSupport.convert(), which is not only DefaultArgumentConverter but potentially user code as ConversionSupport is a public API.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we pass source here as well? In other words, could a Converter only be able to convert S only partially (for example, excluding null)?

Copy link
Contributor Author

@scordio scordio Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially tried this direction but ended up with class cast exceptions due to Converter having type parameters, so I eventually removed it.

I also checked how Spring does it in ConditionalConverter, and the strategy seemed to be the same, so I thought this was a common pitfall 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I still think it would be preferable if canConvert could return false for null instead of convert throwing an exception (if the source type is not TypeDescriptor.NONE. I can see the problem with the ClassCastExceptions and don't have a great idea how to achieve this. One option could be to pass @Nullable Object source (instead of S) to canConvert. Another would be to introduce an extra canConvertNull method. WDYT?

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I still think it would be preferable if canConvert could return false for null instead of convert throwing an exception (if the source type is not TypeDescriptor.NONE. I can see the problem with the ClassCastExceptions and don't have a great idea how to achieve this. One option could be to pass @Nullable Object source (instead of S) to canConvert. Another would be to introduce an extra canConvertNull method. WDYT?

StreamSupport.stream(serviceLoader.spliterator(), false), //
Stream.of(DefaultConverter.INSTANCE)) //
.filter(candidate -> candidate.canConvert(context)) //
.findFirst() //
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have to solve this now, but I'm fully expecting us to get requests for some kind of ordering/precedence support in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe an int order() default method in Converter could be done already now?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's hold off doing that for now to keep the PR size down.


@Override
public final boolean canConvert(ConversionContext context) {
// FIXME adjust for subtypes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What did you have in mind here? Allow subtypes of S and supertypes of T?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. I just haven't had the time to provide proper testing coverage here yet 🙂 that's next on the list.

Comment on lines +80 to +82
Converter converter = Stream.concat( //
StreamSupport.stream(serviceLoader.spliterator(), false), //
Stream.of(DefaultConverter.INSTANCE)) //
Copy link
Member

@marcphilipp marcphilipp Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether this is better. From its Javadoc and a quick look at the code the Iterator backing ServiceLoader.spliterator is also lazy. I'll leave it up to you.

Suggested change
Converter converter = Stream.concat( //
StreamSupport.stream(serviceLoader.spliterator(), false), //
Stream.of(DefaultConverter.INSTANCE)) //
Converter converter = Stream.<Supplier<Converter>> concat( //
serviceLoader.stream(), //
Stream.of(() -> DefaultConverter.INSTANCE)) //
.map(Supplier::get) //

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I recall correctly, I originally drew inspiration from other parts of JUnit here.

I'll double-check both options.

@scordio scordio force-pushed the conversion-service branch from e362540 to fbb8448 Compare August 17, 2025 08:39
this.type = type;
}

public Class<?> getType() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The discussion in #5004 made me think that we should consider supporting Type instead of just Class<?>, i.e. making this class more like Guava's TypeToken or Spring's ParameterizedTypeReference.

Thoughts?

Copy link
Contributor Author

@scordio scordio Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should consider supporting Type instead of just Class<?>

Yes, I also think it would be more future-proof. I mentioned something similar at #4219 (comment), but I can see now I didn't make it prominent enough.

Re TypeToken, there's a related PR introducing a similar class.

The current shape of TypeDescriptor probably makes it closer to Spring's ResolvableType... or, let's say, I was heavily inspired by it!

Do I understand it correctly that you're considering introducing a type token? Is there a use case related to this PR that you imagine would benefit from it, or is this mostly related to the discussion in #5004?

If we were to follow the Spring design, there could be a dedicated TypeToken class and a new TypeDescriptor.forType(TypeToken) factory method: I think such an enhancement could come at any point in time and not necessarily when TypeDescriptor is introduced.

(here is a slightly related discussion about type tokens in AssertJ, which we eventually decided to postpone, waiting for user demand)

Or maybe I misunderstood what you have in mind 🙃

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a use case related to this PR that you imagine would benefit from it, or is this mostly related to the discussion in #5004?

For example, a String to List<T> converter, could inspect the type arguments of List and decide how to convert the individual elements.

Example: "1,2,3" as source and List<Integer> vs. List<Long> as targetType

If we were to follow the Spring design, there could be a dedicated TypeToken class and a new TypeDescriptor.forType(TypeToken) factory method: I think such an enhancement could come at any point in time and not necessarily when TypeDescriptor is introduced.

Agreed, let's not do it right away but keep it in mind when naming things, for example, here:

Suggested change
public Class<?> getType() {
public Class<?> getRawClass() {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example: "1,2,3" as source and List<Integer> vs. List<Long> as targetType

I got a similar use case covered in scordio/junit-converters#4 (see the corresponding test case here).

However, that's at the level of ArgumentConverter where Field or Parameter is directly available and can be fed into the Spring classes to perform the conversion.

I'll keep this use case in mind and see how the Spring integration can work with the changes from this PR, especially to determine whether the current TypeDescriptor APIs are sufficient for this purpose.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Introduce generic ConversionService SPI
4 participants