Repository navigation
feat: auto-detect generated builders in MapStruct via BuilderProvider SPI (#300) - #312
Merged
AndreasIgel merged 31 commits intoOct 11, 2026
Conversation
devin-ai-integration
Bot
force-pushed
the
devin/1790806270-mapstruct-builder-provider
branch
10 times, most recently
from
October 4, 2026 11:32
f181c5f to
014940e
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
AndreasIgel
reviewed
Oct 4, 2026
AndreasIgel
reviewed
Oct 4, 2026
AndreasIgel
reviewed
Oct 4, 2026
AndreasIgel
reviewed
Oct 4, 2026
This was referenced Oct 5, 2026
AndreasIgel
reviewed
Oct 5, 2026
devin-ai-integration
Bot
force-pushed
the
devin/1790806270-mapstruct-builder-provider
branch
from
October 5, 2026 06:30
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.
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.
devin-ai-integration
Bot
force-pushed
the
devin/1790806270-mapstruct-builder-provider
branch
from
October 5, 2026 06:50
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
- java:S1192: extract GENERATED_FACTORY_METHOD_NAME for the generated builder's factory method name, used by the generated-path ResolvedBuilder construction and the factory-name preference list - javabugs:S2259: Objects.requireNonNull on isBuilderGenerationTarget's beanElement — findAnnotation's null tolerance marked the argument as possibly-null for the later getAnnotationMirrors() access
… sources setterSuffix is prepended by the generators (withName), not appended — the naming strategy built <field><suffix> so any configured suffix hid every write accessor. Share the derivation via a context-free generateBuilderMethodName(field, prefix) overload. The expected direct setters also derived from bean fields, but builder fields come from the bean's setter methods and the selected constructor's parameters: a field-only bean emits no setter, and a setter-only property would have been misclassified OTHER. Mirror BuilderDefinitionCreator via Elements overloads of findAllPossibleSettersOfClass (same ordering, first wins) and findConstructorForBuilder in JavaLangAnalyser. Probe test gains a setterSuffix='with' bean asserting withName stays the single SETTER while every prefixed helper resolves to OTHER.
This was referenced Oct 11, 2026
Every integration case now exercises the demanding order — MapStruct's lookup reaches the SPI before the generating round ran, so the deferral path is the baseline rather than a single dedicated case. The explicit ordering test flips to cover the BuilderProcessor-first order, keeping both invocation orders verified.
The example compile now exercises the demanding processor order in the real Maven build too — MapStruct's SPI lookups defer until the builder registry is published, matching the integration test's new default. The committed PersonDtoMapperImpl still binds PersonDtoBuilder.create() and is regenerated byte-identical.
The builder's own TrackedValue<X> fields are the property set, so the expected direct setters are read off the builder element instead of re-deriving them from the bean's setters and selected constructor. Drops the bean resolution (and the published-but-unemitted deferral, which the provider already covers) and reverts the now-unused Elements overloads in JavaLangAnalyser.
The skipped compile now includes a bean marked only via a template annotation (@SimpleMinimalBuilder), exercising the finished-state meta-annotation marker check. A foreign class's own setter is probed to stay SETTER, covering the unpublished-builder path of the naming strategy.
|
devin-ai-integration Bot
added a commit
to igel-devin-ai/simple-builders-devin-fork
that referenced
this pull request
Oct 11, 2026
…#343) The merged java-helpers#312 files carry a shortened variant of the MIT text; the new license check flagged them in CI. license:format rewrites them to the canonical header.
AndreasIgel
pushed a commit
that referenced
this pull request
Oct 12, 2026
* build: add license-header check to the build (#343) Adds com.mycila:license-maven-plugin (5.1.2) with a single check execution, wired per module like PrimeFaces. Header text lives in conf/header.txt; a COPYRIGHT_LINE header section accepts the existing copyright-year variants (2025, 2025-2026, without "(c)") while new files get the canonical line via license:format. Includes cover src/main/java/** and src/test/java/** per module; the generated example-builder tree and target/ are excluded. Missing or corrupted headers (including two files carrying Apache 2.0 headers) were normalized by license:format. * build: reuse LICENSE.txt as license header source (#343) LICENSE.txt is byte-identical to the header text, so point the plugin at it and drop conf/header.txt. The header-section key is the literal copyright line itself, keeping per-file year variants valid. * build: normalize license headers on MapStruct SPI files (#343) The merged #312 files carry a shortened variant of the MIT text; the new license check flagged them in CI. license:format rewrites them to the canonical header. * build: add license-header check to performance-test module (#343) The module is activated only by the performance-test profile and has no committed Java sources; its DTOs are generated into src/main/java/.../ performance_test/library/** by generate_classes.py and builder output lands in generated-performance-builder/. Both generated roots are excluded so generated files are never flagged. * ci: run license check on performance-test module in default pipeline (#343) The module only enters the reactor under the performance-test profile, which CI never enables. A dedicated license:check step with the profile active covers it without building the module. * build: use uniform 2025-2026 copyright range, drop redundant headerSection config (#343) - normalize all source headers and LICENSE.txt to Copyright (c) 2025-2026 - remove headerSections from all pom.xmls: strict literal match against LICENSE.txt enforces the uniform range; the loose ensureMatch regex and 2026 defaultValue would have let or written stale years - delete accidentally added empty .mvn/maven.config --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Andreas Igel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 — its expected name is derived with the generators' owngenerateBuilderMethodName(setterSuffixapplied as a prefix, e.g.withName), and the expected property set mirrorsBuilderDefinitionCreator(bean setter methods plus the selected constructor's parameters) — 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, the creation/build method names, and the resolvedsetterSuffix— inSimpleBuildersSpiIntegration(a static holder in theprocessorpackage; mutators are package-private so onlyBuilderProcessorwrites state). The published contract comes fromBuilderScopeResolver.resolveGeneratedBuilder— the sameStaticFactoryCall("create")+ constructor-copy contractresolve()trusts for generated builders — flattened to names so SPIs never touch the resolution model: 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