-
-
Couldn't load subscription status.
- Fork 1.6k
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
base: main
Are you sure you want to change the base?
Conversation
|
There is plenty of work to do 🙃 The current highlights:
Any feedback would be highly appreciated! |
c31ca83 to
b19cba8
Compare
b19cba8 to
a7a8aa3
Compare
3304ad5 to
c886b7a
Compare
junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/ArgumentConverter.java
Outdated
Show resolved
Hide resolved
c886b7a to
7ea91b8
Compare
|
Thanks for the draft! 👍 The tests are failing due to:
That's because |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks very promising! 👍
...m-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java
Outdated
Show resolved
Hide resolved
...m-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java
Show resolved
Hide resolved
...m-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java
Outdated
Show resolved
Hide resolved
...ns/src/main/java/org/junit/platform/commons/support/conversion/DefaultConversionService.java
Outdated
Show resolved
Hide resolved
|
|
||
| import org.junit.platform.commons.support.conversion.TypedConversionService; | ||
|
|
||
| // FIXME delete |
There was a problem hiding this comment.
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:
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🙃
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Raised #4544.
When you add that, you'll also have to add it to |
2e17c2b to
0c2faa7
Compare
|
I've been lagging behind with this one but I should be able to spend time on it in the upcoming weekend. |
0c2faa7 to
098c972
Compare
098c972 to
8ed6eb6
Compare
...m-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java
Show resolved
Hide resolved
...-jupiter-params/src/main/java/org/junit/jupiter/params/converter/TypedArgumentConverter.java
Outdated
Show resolved
Hide resolved
8ed6eb6 to
940d018
Compare
|
Would you like to include this in 5.13 too? I should have enough time in the upcoming days to finalize it. |
0a36152 to
e380b8f
Compare
...form-commons/src/main/java/org/junit/platform/commons/support/conversion/TypeDescriptor.java
Outdated
Show resolved
Hide resolved
...ons/src/main/java/org/junit/platform/commons/support/conversion/StringToObjectConverter.java
Outdated
Show resolved
Hide resolved
|
|
||
| @Override | ||
| protected @Nullable Locale convert(@Nullable String source) { | ||
| return source != null ? Locale.forLanguageTag(source) : null; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
...-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/Converter.java
Outdated
Show resolved
Hide resolved
|
@scordio Thanks! I've changed the setting. |
|
Now that Before exploring this in detail, I wanted to check your opinion first. |
8b78923 to
789d5d5
Compare
|
I think that could be useful and worth giving a try. 👍 |
789d5d5 to
4ea8824
Compare
4ea8824 to
17591cc
Compare
f2cdba7 to
42cc962
Compare
| // 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); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
...mons/src/main/java/org/junit/platform/commons/support/conversion/StringToClassConverter.java
Outdated
Show resolved
Hide resolved
...-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/Converter.java
Outdated
Show resolved
Hide resolved
|
@marcphilipp @sbrannen I think this is now ready for another round of reviews! A few points that still require work:
|
fe8883d to
a57f5c9
Compare
...form-commons/src/main/java/org/junit/platform/commons/support/conversion/TypedConverter.java
Outdated
Show resolved
Hide resolved
|
|
||
| @Override | ||
| public final T convert(@Nullable S source, ConversionContext context) { | ||
| return convert(source); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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 🙂
There was a problem hiding this comment.
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?
| * type is a reference type | ||
| * @throws ConversionException if an error occurs during the conversion | ||
| */ | ||
| T convert(@Nullable S source, ConversionContext context) throws ConversionException; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we really allow source to be null here? I think it would simplify things a lot if we didn't, wouldn't it? Maybe we'd restrict ourselves too much if we forbade null here, but maybe TypedConverter or a new NonNullConverter base class could forbid null and make it easier to implement a Converter with that returns @NonNull T?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we'd restrict ourselves too much if we forbade
nullhere
I think so, especially considering this and this logic in DefaultConverter. Part of the null handling could be pulled to ConversionSupport, but I have the feeling it would spread the responsibility of rejecting null in case of primitive types.
As an extreme case, I considered the scenario where users would like to completely replace the built-in logic of DefaultConverter with a custom Converter that always returns true on canConvert. Such a converter might decide that a null source is always converted to a placeholder object. If Converter were to enforce a non-null source, such a use case wouldn't be possible. As I mentioned, this is likely to be unrealistic, but I considered it anyway 🙂
maybe
TypedConverteror a newNonNullConverterbase class could forbidnulland make it easier to implement aConverterwith that returns@NonNull T?
All considered, I think having TypedConverter to enforce a non-null source makes sense, and implementors can always fall back to Converter if they want to have control of null values.
Initially, I wanted to mimic the behavior of TypedArgumentConverter in TypedConverter. However, after digesting #4219 (comment) a bit more, Converter is the actual counterpart of TypedArgumentConverter, and renaming TypedConverter to SimpleConverter would also help to remove the potential ambiguity.
I'll adjust TypedConverter accordingly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 7903c2b.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm unresolving this comment as I still have second thoughts about it.
Should we really allow
sourceto benullhere? I think it would simplify things a lot if we didn't, wouldn't it?
After digesting this point for some time, I reevaluated where to position the responsibility for null conversion. That responsibility currently lies with DefaultConverter, which I moved from the original ConversionSupport implementation, but maybe that was a mistake:
ConversionSupportshould keep thenullconversion responsibility, outside theConverterSPIConverter#convertshould always expect non-null values
The limitations I see are that custom converters don't have control over the null conversion process, and the fact that the Converter contract deviates from the ArgumentConverter contract, the latter expecting null values.
Still, I believe restricting to non-null values would simplify things a lot (and Spring used the same strategy).
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sbrannen Assuming you were involved on the Spring side, any experience you can share?
...m-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java
Show resolved
Hide resolved
| * @param context the context for the conversion; never {@code null} | ||
| * @return {@code true} if the conversion is supported | ||
| */ | ||
| boolean canConvert(ConversionContext context); |
There was a problem hiding this comment.
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() // |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| Converter converter = Stream.concat( // | ||
| StreamSupport.stream(serviceLoader.spliterator(), false), // | ||
| Stream.of(DefaultConverter.INSTANCE)) // |
There was a problem hiding this comment.
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.
| 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) // |
There was a problem hiding this comment.
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.
ebe4b0a to
b8bedaf
Compare
b8bedaf to
e362540
Compare
e362540 to
fbb8448
Compare
| this.type = type; | ||
| } | ||
|
|
||
| public Class<?> getType() { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should consider supporting
Typeinstead of justClass<?>
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 🙃
There was a problem hiding this comment.
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
TypeTokenclass and a newTypeDescriptor.forType(TypeToken)factory method: I think such an enhancement could come at any point in time and not necessarily whenTypeDescriptoris introduced.
Agreed, let's not do it right away but keep it in mind when naming things, for example, here:
| public Class<?> getType() { | |
| public Class<?> getRawClass() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Example:
"1,2,3"assourceandList<Integer>vs.List<Long>astargetType
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.
Overview
ConversionServiceSPI #853StringtoNumberconversion #3605I hereby agree to the terms of the JUnit Contributor License Agreement.
Definition of Done
@APIannotations