Support sealed parameter implementation hints#12117
Conversation
gnodet
left a comment
There was a problem hiding this comment.
Claude Code on behalf of Guillaume Nodet
Review: GH-11378 Support sealed parameter implementation hints
Overview
This PR adds sealed-class awareness to Maven 4's plugin parameter configuration. When a plugin parameter type is a sealed interface/class, users can now use simple class names (e.g., implementation="LocalArtifact") instead of fully qualified names in their POM configuration. The implementation wraps the existing Plexus getClassForImplementationHint call — on failure, it falls back to scanning Class.getPermittedSubclasses() for a match. Includes unit tests and a Maven 4 plugin integration test.
Verdict
Looks good overall — minor issues to address. The approach is sound: backward-compatible, minimally invasive, and properly scoped.
Issues
Minor — Missing cause in ambiguity exception
In getPermittedSubclass(), the "not found" error path includes the original cause exception, but the "ambiguous" path does not. This loses diagnostic context:
// "not found" — includes cause
throw new ComponentConfigurationException(configuration,
"Cannot find permitted subclass '...'", cause);
// "ambiguous" — missing cause
throw new ComponentConfigurationException(configuration,
"Implementation hint '...' is ambiguous...");Should add cause to the ambiguity exception for consistency.
Minor — Odd control flow: sort before isEmpty check
if (matches.size() == 1) {
return matches.get(0);
}
matches.sort(Comparator.comparing(Class::getName));
if (matches.isEmpty()) {
throw ...;
}Sorting an empty list is harmless, but checking isEmpty() before sorting reads more naturally and makes the intent clearer.
Minor — No test for the ambiguity case
The unit tests cover simple name resolution, fully qualified name, and missing class — but there's no test for the ambiguity path (two permitted subclasses with the same simple name). This code path exists and has specific error formatting with sorted class names, so it should be verified.
Nit — Brittle error message assertion
testSealedTypeImplementationHintMustMatchPermittedSubclass asserts e.getMessage() against an exact string. If BeanConfigurationException wraps the cause message differently across versions, this could break. Consider using a contains check instead.
Suggestions (non-blocking)
-
Recursive sealed hierarchies:
getPermittedSubclasses()only returns direct permits. If a hierarchy has intermediate sealed types (A permits BwhereBis sealed andB permits C),Cwon't be found fromA. Fine for initial implementation but worth noting for future work. -
IT test plugin pom.xml uses
<maven.version>4.1.0-SNAPSHOT</maven.version>— correct for development against master but will need updating if the SNAPSHOT version changes before merge. -
The issue mentions several follow-up features (
@SealedTypeHintannotation,defaultImplementationon@Parameter, XSD generation). These are clearly out of scope for this PR, which is the right call.
gnodet
left a comment
There was a problem hiding this comment.
Claude Code on behalf of Guillaume Nodet — inline suggestions below.
gnodet
left a comment
There was a problem hiding this comment.
Claude Code on behalf of Guillaume Nodet — inline suggestions.
| if (matches.size() == 1) { | ||
| return matches.get(0); | ||
| } | ||
| matches.sort(Comparator.comparing(Class::getName)); | ||
|
|
||
| if (matches.isEmpty()) { | ||
| throw new ComponentConfigurationException( | ||
| configuration, | ||
| "Cannot find permitted subclass '" + implementation + "' for sealed type " + type.getName(), | ||
| cause); | ||
| } |
There was a problem hiding this comment.
Minor: sorting before the isEmpty() check is harmless but reads oddly. Moving isEmpty() first makes the intent clearer:
| if (matches.size() == 1) { | |
| return matches.get(0); | |
| } | |
| matches.sort(Comparator.comparing(Class::getName)); | |
| if (matches.isEmpty()) { | |
| throw new ComponentConfigurationException( | |
| configuration, | |
| "Cannot find permitted subclass '" + implementation + "' for sealed type " + type.getName(), | |
| cause); | |
| } | |
| if (matches.size() == 1) { | |
| return matches.get(0); | |
| } | |
| if (matches.isEmpty()) { | |
| throw new ComponentConfigurationException( | |
| configuration, | |
| "Cannot find permitted subclass '" + implementation + "' for sealed type " + type.getName(), | |
| cause); | |
| } | |
| matches.sort(Comparator.comparing(Class::getName)); |
| throw new ComponentConfigurationException( | ||
| configuration, | ||
| "Implementation hint '" + implementation + "' is ambiguous for sealed type " + type.getName() + ": " | ||
| + matches.stream().map(Class::getName).toList()); |
There was a problem hiding this comment.
Minor: the "not found" path includes cause but the ambiguity path does not. Should be consistent so diagnostic context isn't lost:
| throw new ComponentConfigurationException( | |
| configuration, | |
| "Implementation hint '" + implementation + "' is ambiguous for sealed type " + type.getName() + ": " | |
| + matches.stream().map(Class::getName).toList()); | |
| throw new ComponentConfigurationException( | |
| configuration, | |
| "Implementation hint '" + implementation + "' is ambiguous for sealed type " + type.getName() + ": " | |
| + matches.stream().map(Class::getName).toList(), | |
| cause); |
gnodet
left a comment
There was a problem hiding this comment.
Claude Code on behalf of Guillaume Nodet — additional inline suggestions with code blocks.
| import static org.junit.jupiter.api.Assertions.assertInstanceOf; | ||
| import static org.junit.jupiter.api.Assertions.assertThrows; |
There was a problem hiding this comment.
Add assertTrue import for the assertion change below:
| import static org.junit.jupiter.api.Assertions.assertInstanceOf; | |
| import static org.junit.jupiter.api.Assertions.assertThrows; | |
| import static org.junit.jupiter.api.Assertions.assertInstanceOf; | |
| import static org.junit.jupiter.api.Assertions.assertThrows; | |
| import static org.junit.jupiter.api.Assertions.assertTrue; |
| assertEquals( | ||
| "Cannot find permitted subclass 'MissingArtifact' for sealed type " + SealedArtifact.class.getName(), | ||
| e.getMessage()); |
There was a problem hiding this comment.
Using assertEquals on the full message can be brittle if BeanConfigurationException changes how it wraps the cause. A contains check is more resilient:
| assertEquals( | |
| "Cannot find permitted subclass 'MissingArtifact' for sealed type " + SealedArtifact.class.getName(), | |
| e.getMessage()); | |
| assertTrue( | |
| e.getMessage().contains("Cannot find permitted subclass 'MissingArtifact' for sealed type " | |
| + SealedArtifact.class.getName())); |
| } | ||
|
|
||
| static class SomeBean { | ||
|
|
||
| File file; | ||
| } | ||
|
|
||
| static class SealedBean { | ||
|
|
||
| SealedArtifact artifact; | ||
| } | ||
|
|
||
| public sealed interface SealedArtifact permits LocalArtifact, RemoteArtifact {} | ||
|
|
||
| public static final class LocalArtifact implements SealedArtifact { | ||
|
|
||
| String name; | ||
| } | ||
|
|
||
| public static final class RemoteArtifact implements SealedArtifact { | ||
|
|
||
| String url; | ||
| } | ||
| } |
There was a problem hiding this comment.
Consider adding a test for the ambiguity code path (two permitted subclasses sharing the same simple name). This also requires adding the helper types:
| } | |
| static class SomeBean { | |
| File file; | |
| } | |
| static class SealedBean { | |
| SealedArtifact artifact; | |
| } | |
| public sealed interface SealedArtifact permits LocalArtifact, RemoteArtifact {} | |
| public static final class LocalArtifact implements SealedArtifact { | |
| String name; | |
| } | |
| public static final class RemoteArtifact implements SealedArtifact { | |
| String url; | |
| } | |
| } | |
| } | |
| @Test | |
| void testSealedTypeAmbiguousSimpleNameThrowsError() { | |
| AmbiguousBean bean = new AmbiguousBean(); | |
| Xpp3Dom config = toConfig("<value implementation=\"Ambiguous\"/>"); | |
| DefaultBeanConfigurationRequest request = new DefaultBeanConfigurationRequest(); | |
| request.setBean(bean).setConfiguration(config); | |
| BeanConfigurationException e = | |
| assertThrows(BeanConfigurationException.class, () -> configurator.configureBean(request)); | |
| assertTrue(e.getMessage().contains("is ambiguous for sealed type")); | |
| } | |
| static class SomeBean { | |
| File file; | |
| } | |
| static class SealedBean { | |
| SealedArtifact artifact; | |
| } | |
| static class AmbiguousBean { | |
| AmbiguousSealedType value; | |
| } | |
| public sealed interface SealedArtifact permits LocalArtifact, RemoteArtifact {} | |
| public sealed interface AmbiguousSealedType permits Holder1.Ambiguous, Holder2.Ambiguous {} | |
| public static final class LocalArtifact implements SealedArtifact { | |
| String name; | |
| } | |
| public static final class RemoteArtifact implements SealedArtifact { | |
| String url; | |
| } | |
| public static final class Holder1 { | |
| public static final class Ambiguous implements AmbiguousSealedType {} | |
| } | |
| public static final class Holder2 { | |
| public static final class Ambiguous implements AmbiguousSealedType {} | |
| } | |
| } |
Fixes #11378
Summary
When a plugin parameter type is a sealed interface/class, users currently must provide the fully qualified class name in the
implementationattribute. This PR allows using the simple class name instead, by scanningClass.getPermittedSubclasses()when standard Plexus class resolution fails.For example, users can now write:
instead of:
Fully qualified names continue to work as before.
Changes
resolveClassForImplementationHint()wrapper inEnhancedConfigurationConverterthat falls back to sealed type resolution when standard Plexus resolution failsDefaultBeanConfiguratorTestcovering simple name, fully qualified name, and missing subclass scenarios