Repository navigation
feat: auto-detect generated builders in MapStruct via BuilderProvider SPI (#300) - #312
igel-devin-ai wants to merge 25 commits into
Conversation
f181c5f to
014940e
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| * @param referencedType the type element being referenced | ||
| * @return the resolved builder, or empty if no generated builder is registered for the type | ||
| */ | ||
| public Optional<ResolvedBuilder> resolveGeneratedBuilder(TypeElement referencedType) { |
There was a problem hiding this comment.
Why do we need a return type of resolvedBuilder should publishedbuiöder not hold more information? If not why not extending the publishedbuilder?
There was a problem hiding this comment.
ResolvedBuilder is the resolver's own currency — resolve() and the rest of the resolution API produce and consume it, so the SPI descriptor embeds it rather than a parallel structure. PublishedBuilder already carries everything the contract doesn't know: the bean TypeName and the resolved setterSuffix — the latter only exists in the generation plan's per-element config, which is why the two compose exactly at registration. Extending PublishedBuilder further would duplicate fields already reachable through the contract.
There was a problem hiding this comment.
Where is it used and please check, if the usage in mapstruct-integration-components could be replaced! In simpleBuilders components outside from mapstruct integration this should not be used!?
There was a problem hiding this comment.
Usages were exactly two: resolve() reusing it internally, and BuilderProcessor publishing it to the SPI. No mapstruct component calls it — but they did consume ResolvedBuilder through PublishedBuilder. Both are now addressed: resolve() inlines the lookup again so the method exists only for the SPI publish, and PublishedBuilder flattened to plain fields (beanType, builderType, creationMethodName, buildMethodName, setterSuffix) — mapstruct components no longer touch ResolvedBuilder or BuilderInstantiation at all; registerBuilder still takes the ResolvedBuilder the resolver produced and flattens it internally.
| /** | ||
| * Whether {@code observed} belongs to the compilation this holder currently describes — {@code | ||
| * false} while it still carries a previous run's leftovers. javac creates one {@link Elements} | ||
| * per compilation and shares it between all processors, so reference identity is a reliable | ||
| * staleness check that never requires the SPI adapters to write anything. | ||
| */ | ||
| public static boolean isCurrentCompilation(Elements observed) { | ||
| return observed != null && observed == compilationElements.get(); | ||
| } |
There was a problem hiding this comment.
Do we still need that? Mapstruct could run on different compilation, this should not make any difference!?
There was a problem hiding this comment.
Still needed — it is exactly what makes 'MapStruct on a different compilation' a no-op. Without it the holder's stale leftovers are still visible: a registry entry from a previous compile on the same JVM (Gradle daemon, incremental builds) could claim a bean by FQN, and a stale non-final state would defer every bean into MapStruct's last-round error. The Elements identity check is the read-only way to tell 'this holder describes my compile'; SPIs never write anything. It now only guards the registry/state reads — the option check no longer uses it.
| if (isCurrentCompilation(observed) && state != State.INIT) { | ||
| Boolean published = integrationEnabled; | ||
| if (published != null) { | ||
| return published; | ||
| } | ||
| } |
There was a problem hiding this comment.
Why needing this? Could that not be removed and just returned what is below this?
There was a problem hiding this comment.
Removed — and with it the whole published switch (initCompilation's Boolean and the Elements parameter are gone). You were right to push back on my earlier claim: I verified in MappingProcessor's bytecode that getOptions() on the SPI env only contains options declared via AdditionalSupportedOptionsProvider SPIs — so -A genuinely did NOT reach us before. The new MapStructAdditionalSupportedOptionsProvider declares both spellings, so -Asimplebuilder.usingMapStructIntegration now reaches the SPIs directly and the check is a pure current-compile read: -D, then -Asimplebuilder.*, then the bare -A option (mirroring CompilerArgumentsReader). The disabled-integration test proves the -A path end-to-end.
| mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) | ||
| .compile(personDto(), personDtoMapper()); |
There was a problem hiding this comment.
Could the builderprocessor and mappingprocessor not assigned in compile method? We need this multiple times, right!?
There was a problem hiding this comment.
Done — mapStructCompiler() with no args now supplies the usual (BuilderProcessor, MappingProcessor) pair; only the order-swap test passes processors explicitly.
| */ | ||
| class MapStructSpiProbeTest { | ||
|
|
||
| private static JavaFileObject personDto() { |
There was a problem hiding this comment.
Move private static function to bottom of testclass
There was a problem hiding this comment.
Done — all five source factories moved to the bottom of the test class.
b4c4777 to
9528201
Compare
Registers SimpleBuildersBuilderProvider via META-INF/services so MapStruct auto-detects @SimpleBuilder-generated builders when both processors share the annotation processor path. Resolution locates the builder via the @BuilderImplementation annotation (handles custom package/suffix configuration), falls back to the <Bean><suffix> name convention, and defers a round via TypeHierarchyErroneousException when the builder is not generated yet. BuilderProcessor.process() now returns false: it claims all annotation types (*) and returning true starved other processors - MapStruct's MappingProcessor never saw @Mapper. Includes an example PersonDtoMapper plus an integration test asserting the generated mapper uses PersonDtoBuilder.create() and build(). The suppressGeneratorTimestamp/VersionInfoComment processor args keep the committed generated mapper deterministic for the regen check. Implements java-helpers#300.
Adds SimpleBuildersAccessorNamingStrategy (a DefaultAccessorNamingStrategy subclass registered via META-INF/services): for methods declared on a simple-builders builder, only the direct property setter - <field> <setterSuffix> taking a single argument of the field type - stays a write accessor. add2*, *Update, conditional(...) and the Supplier/Consumer/ format overloads return OTHER, eliminating phantom 'Unmapped target property' warnings. Per-bean naming configuration is resolved from the bean's @SimpleBuilder/@SimpleBuilder.Template options mirrors plus the -Asimplebuilder.* processor options; all other types keep the default strategy behavior. Shared annotation-mirror helpers move to package-private AnnotationSupport, now also used by SimpleBuildersBuilderProvider.
MapStructBuilderProvider and MapStructAccessorNamingStrategy make the SPI purpose visible without reading the implementation.
MapStruct does not forward foreign annotation processor options to SPI environments, so the switch follows the simplebuilder.* convention and prefers the JVM system property: -Dsimplebuilder.usingMapStructIntegration =false restores stock MapStruct behaviour (builder candidates stay hidden and the naming strategy defers to the default). The same option() helper now also makes builderSuffix/setterSuffix reachable inside the SPI.
findBuilderInfo runs for every type MapStruct inspects, per mapper and per round, with no caching on MapStruct's side. Resolve builders by conventional name first (a getTypeElement per candidate package/suffix) and run the @BuilderImplementation package scan only as a last resort for beans marked for generation. Naming candidates are resolved in a single options pass, positive BuilderInfo results are cached per bean, and annotation values are read via getElementValues (explicit values only) instead of materializing defaults. Also: the builder candidates now include the global -D builderSuffix so a custom suffix cannot strand a marked bean in endless deferral; the creation method prefers create, then of, then alphabetical order; the naming strategy's name fallback only resolves marked beans (a bean literally named <X>Builder no longer hides its own methods); option resolution is shared via AnnotationSupport.systemOption.
…nned builder names MapStruct never forwards foreign annotation processor options to SPI environments, so the BuilderProcessor resolves simplebuilder.usingMapStructIntegration itself (same -D over -A precedence as every option) and publishes it together with the bean to builder qualified names it plans to emit to the SPIs sharing the classloader. MapStructIntegration holds that per-compilation state and is reset on every processor init so stale entries cannot leak across Gradle daemon compilations. Both SPIs consult the registry first, giving exact answers for SimpleBuilderFor targets and custom packageName layouts, and fall back to the conventional name lookup for builders generated in earlier runs. The package scan is dropped: the generator always emits the conventional name, so the scan only served exotic hand-written builders.
Gate the conventional-name lookup on @BuilderImplementation(forClass = <bean>): the marker has CLASS retention, so it is readable on builders generated in earlier compilations, while foreign types that merely match the conventional name are never claimed. The naming strategy drops its marker-free name fallback for the same reason — a builder is attributed to this generator only through the registry or the marker on the type itself. Builders emitted with usingBuilderImplementationAnnotation=DISABLED keep working through the registry in the run that produces them.
No extra detection at all: a type is attributed to this generator only when BuilderProcessor published it for the current compilation, so foreign builders and builders from earlier runs can never be claimed or shadow other solutions. The naming strategy's bean resolution and the provider's lookup both become pure registry reads; the now-unused annotation-based detection helpers are removed.
javac initializes each processor lazily in its turn, so MapStruct's SPIs may run before BuilderProcessor.init() of the same compilation — and see leftover state of a previous compile in a reused JVM (daemon/incremental builds). MapStructIntegration now carries INIT/PROCESSING/FINISHED: the published switch and registry count only once our processor initialized, a stale FINISHED observed at SPI init is reset to INIT for the new compile, and marked beans defer while the registry may still fill — a missing marked bean after FINISHED falls back gracefully instead of erroring in the last round. A regression test covers reversed processor ordering.
The bridge the SPI adapters share with the processor — compilation lifecycle, published integration switch and the bean-to-builder registry — is usable by future SPI integrations beyond MapStruct, so it moves out of the mapstruct package next to the option machinery.
The status reports where simple-builders' own builder generation stands and the registry only lists builders BuilderProcessor plans to emit — make both scopes visible in the name. Javadoc states the lifecycle is transitioned by BuilderProcessor alone; SPI adapters only observe, with spiInitialized merely aging out a stale FINISHED.
Review follow-up: the registry now carries PublishedBuilder descriptors (ResolvedBuilder + configured setterSuffix) resolved by BuilderProcessor at registration time. The provider resolves create/build by the descriptor's named contract instead of scanning candidates; the naming strategy gets bean and setterSuffix from the descriptor and stops reading bean annotation mirrors — AnnotationSupport shrinks away, its only remaining use being the deferral marker inlined in the provider. SimpleBuildersSpiIntegration moves to the processor package with package-private mutators (only BuilderProcessor transitions state), Optional lookups keyed both directions, and the switch renamed to integrationEnabled. Registered-but-unresolved types defer only while the compilation is not FINISHED. Adds a unit test for the lifecycle, registry and switch precedence.
- registerBuilder takes (beanType, builderType, setterSuffix) and builds the ResolvedBuilder internally under the standard generated-builder contract; BuilderProcessor reuses its computed builderTypeName and maps the bean via JavaLangMapper.mapToTypeName - initCompilation parameter renamed to integrationEnabled - New MapStructSpiProbeTest: a probe processor ahead of BuilderProcessor drives both SPIs inside javac — deferral for marked unregistered and template-annotated beans, null for foreign/ignored beans, BuilderInfo + same-instance cache hit once emitted, noType lookup, and naming classification of generated builder methods (SETTER vs OTHER)
New fourth state TARGETS_REGISTERED marks the end of BuilderProcessor's first processing round. A registry miss before it defers the mapper for any bean — this covers @SimpleBuilderFor targets, which carry no marker of their own, with no annotation scanning. Afterwards only beans marked for generation defer while their entry may still arrive in a later round; foreign and opted-out beans resolve to null immediately. Probe and lifecycle tests cover both windows.
Restrict TypeHierarchyErroneousException deferral to beans marked for generation while INIT/PROCESSING holds; unmarked beans return null in every state so foreign types are never claimed or delayed. At FINISHED a marked bean without a registered builder reports a stderr warning (SPI environments expose no Messager). Published-but-unemitted builder types keep deferring until FINISHED. Move the parameterless method scan into JavaLangAnalyser as findMethodWithoutParameters and use its findAnnotation helpers for the marker and template checks. Reword the integration-switch comment to not resemble code and suppress java:S3516 on process(), which must always return false to leave annotations unclaimed. Restructure the SPI tests to the repo conventions: test sources as private static helper methods, a createCompiler(Processor...) overload in ProcessorTestUtils preserving invocation order, an assertNoWarningContaining helper, and a scoped-out marked bean covering the TARGETS_REGISTERED/FINISHED miss paths.
SPI adapters now only read the holder: staleness detection moves to isCurrentCompilation(Elements) — javac hands all processors of one compilation the same Elements instance, so a mismatch means the holder still describes a previous run. This replaces spiInitialized(). - targetsRegistered() transitions unconditionally - registerBuilder takes the ResolvedBuilder (real instance from resolveGeneratedBuilder, new API extracted in BuilderScopeResolver which now deduplicates the in-round contract construction) - isMapstructGenerationEnabled(elementUtils, options) replaces isIntegrationDisabled; stale observers fall back to -D/-A - isSimpleBuildersFinishedForIntegration() helper replaces state comparisons at deferral points - Naming strategy defers via TypeHierarchyErroneousException when a published bean type is not emitted yet - Bean field scanning moved into JavaLangAnalyser.findFields (non-static incl. inherited)
- compilationElements held in AtomicReference (java:S3077 — volatile on a non-thread-safe type is not enough) - finished-state warning goes through System.Logger (java:S106) — SPI environments still expose no Messager - null-guard annotation type element before meta-annotation lookup (javabugs:S2259)
A registry miss for a marked bean now defers via\nTypeHierarchyErroneousException throughout the non-final phase (the\nTARGETS_REGISTERED fallback is dropped — a bean may still be registered\nin a later round or with an element another processor emits); once\nFINISHED the miss is definitive and reported as a warning. The skipped\nprobe compile runs behind BuilderProcessor so FINISHED is observable and\nits warning exercised end-to-end.
TARGETS_REGISTERED behaved identically to PROCESSING in every check\nsince marked beans defer through all non-final states; the lifecycle\nsimplifies to INIT -> PROCESSING -> FINISHED. FINISHED stays: it is the\nonly point where 'never registered' is provable — later rounds still\nregister targets, and never-planned marked beans need warn+fallback\ninstead of a last-round error.
… registry is final BuilderProcessor now generates in the first round carrying its annotations and marks the registry final when that round ends, so the SPI lookup can treat every miss before it as "maybe later": any bean defers via TypeHierarchyErroneousException, which also covers SimpleBuilderFor targets that carry no bean-side marker. The marker check remains only to warn for marked beans missing once the registry is final. Elements first appearing in later rounds get no builder; documented in CONFIGURATION.
…r-claim contract The extra 'generated' member duplicated what the SPI state already knows: FINISHED means the generating round ran. process() also gains javadoc explaining why it must always return false — claiming is all-or-nothing over the wildcard supported set required for template discovery.
…ilder, tidy tests MapStruct forwards only declared options into SPI environments: the new MapStructAdditionalSupportedOptionsProvider declares both option spellings so -Asimplebuilder.usingMapStructIntegration reaches the SPIs without any processor-published state. isMapstructGenerationEnabled now reads -D then -A (prefixed, then bare) like CompilerArgumentsReader and needs no compilation check. PublishedBuilder flattens to bean/builder type plus creation and build method names, so SPI adapters no longer touch ResolvedBuilder; resolveGeneratedBuilder's only outside caller is the SPI publish in BuilderProcessor and resolve() goes back to inlining it. Test cleanups: default processor pair inside the compile helper, source factories moved to the bottom of the probe test.
5d0aacf to
252d607
Compare
…he finished check The MapStruct SPIs resolve simplebuilder.usingMapStructIntegration from the options map MapStruct hands them — through static CompilerArgumentsReader overloads taking an explicit options map, sharing the -D > -A > bare precedence with the processor. SimpleBuildersSpiIntegration is back to pure registry plus lifecycle state. isSimpleBuildersFinishedForIntegration(elements) now answers FINISHED only for the compilation the given Elements belongs to, so no caller can observe a previous run's final state.
builderFor, builderByName and isSimpleBuildersFinishedForIntegration now take the caller's Elements and answer empty/not-finished for foreign compilations — the staleness guard lives inside the API instead of at each call site. isCurrentCompilation and state() are internal again (package-private), the strategy's early compilation check drops because an empty registry already yields default classification, and the finished check's javadoc documents why the guard exists: the classloader outlives the compilation, so a previous run's FINISHED plus registry must never be trusted before this run's init.
- JavaLangAnalyser.findFields removed: Elements.getAllMembers already covers inherited fields; the naming strategy uses ElementFilter on it with a static-field filter directly - BuilderProcessor: duplicate never-claim comment removed (method javadoc carries the contract) - SimpleBuildersSpiIntegration: stale reference to package-private isCurrentCompilation in the class javadoc fixed - docs/CONFIGURATION.md: usingMapStructIntegration section rewritten for the SPI-side option read via AdditionalSupportedOptionsProvider
|




Summary
Fixes #300.
When
simple-builders-processorandmapstruct-processorshare the annotation processor path, MapStruct now automatically uses the generated builders — the same drop-in integration Jackson already has. Two SPI adapters are registered via@AutoServiceinside the processor jar:MapStructBuilderProvider(org.mapstruct.ap.spi.BuilderProvider) suppliescreate()/build()for beans whose builders the processor plans.MapStructAccessorNamingStrategy(org.mapstruct.ap.spi.AccessorNamingStrategy, extendingDefaultAccessorNamingStrategy) classifies generated helper methods (add2*,*Update,conditional,Supplier/Consumer/format/varargs overloads) asOTHER, so only the direct property setter counts as a write accessor — no ambiguous-overload dependency and no phantomUnmapped target propertywarnings.How beans are paired with builders
Exclusively through the registry
BuilderProcessorpublishes. While planning generation, the processor records aPublishedBuilderdescriptor per bean —TypeNames of bean and builder, theResolvedBuildercontract (StaticFactoryCall("create")+buildMethodName), and the resolvedsetterSuffix— inSimpleBuildersSpiIntegration(a static holder in theprocessorpackage; mutators are package-private so onlyBuilderProcessorwrites state). The publishedResolvedBuilderis the real oneBuilderScopeResolverproduces (via the newresolveGeneratedBuilder, whichresolve()reuses) — the registry never fabricates or trims contract information. Both SPIs only consult those descriptors: the provider resolvescreate/buildby the published names instead of scanning candidates, the naming strategy reads bean type andsetterSuffixoff the descriptor instead of bean annotation mirrors. No name conventions, no annotation-derived claiming — a type is claimed only if it is a builder this processor is emitting in the current compilation; foreign builders (Lombok, Immutables, hand-written) can never be claimed or shadowed.Compilation lifecycle
BuilderProcessorgenerates in exactly one round — the first round carrying simple-builders annotations — and marks the registry final when that round ends (processingOverstays as the catch-all for compiles with no content). The SPI state is thereforeINIT→PROCESSING→FINISHED, whereFINISHEDalready means "the generating round ran".PROCESSINGis published byBuilderProcessor.init(),FINISHEDat the end of the generating round (orprocessingOverfor content-free compiles).builderFor,builderByName,isSimpleBuildersFinishedForIntegration) takes the caller'sElements— javac hands every processor of one compilation the same instance and a different one per compilation, so the holder answers empty/not-finished for foreign compilations (reused JVMs: Gradle daemon, in-process javac). The staleness guard lives inside the API;isCurrentCompilationstays an internal detail.FINISHEDdefers the mapper viaTypeHierarchyErroneousExceptionfor every bean — no bean-side marker is needed, so@SimpleBuilderFortargets resolve too.FINISHED, a miss is definitive: a bean marked for generation (@SimpleBuilder/template, not@Ignore4BuilderGeneration) that was never registered (skipped by planning: outsidebuilderGenerationPackages,usingExistingBuilders, …) is reported as a warning and maps without a builder; unmarked beans getnull— nothing foreign is ever claimed. The marker check only serves that warning, never deferral or claiming.-A/-Dswitch is read by the SPIs themselves from their own options — a pure current-compile read needing no staleness guard.Consequences by design:
docs/CONFIGURATION.md.Configuration
-Asimplebuilder.usingMapStructIntegration=DISABLEDswitches the whole integration off (fallback to setter/ctor mapping). The SPIs resolve the switch themselves from the options map MapStruct hands them — MapStruct only forwards declared options, so the shippedMapStructAdditionalSupportedOptionsProviderdeclares both spellings — through staticCompilerArgumentsReaderoverloads sharing the usual-D>-A> bare precedence with the processor.SimpleBuildersSpiIntegrationstays pure registry plus lifecycle state. Documented indocs/CONFIGURATION.mdlike the Jackson options.Verification
MapStructSpiIntegrationTestcompiles a DTO +@Mapperin-process with both processors and asserts the generatedMapperImplusesPersonDtoBuilder.create()….build()with zero unmapped-target warnings; dedicated cases cover reversed processor ordering and the-A…=DISABLEDswitch end-to-end.MapStructSpiProbeTestdrives both SPIs from a probe processor ahead ofBuilderProcessorinside javac — deferral for every bean while the registry is unpublished, published-but-unemitted deferral, null for foreign/opted-out beans once final, a scoped-out marked bean deferred then warned atFINISHED,SETTER/OTHERclassification,getNoType— covering paths MapStruct 1.6.3 itself never invokes.SimpleBuildersSpiIntegrationTestcovers the lifecycle, theElements-identity staleness rules and the precedence rules directly.Depends on the setter-last ordering merged as
b31a55c(#325) for last-wins robustness of the direct setter.Written by Devin