Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -744,13 +744,16 @@ comma-separated, each listed package includes all of its subpackages, and matchi
case.

The processor constructs the candidate builder name using `builderUsageSuffix`
(or `builderSuffix` if not configured) and verifies the builder contract: a
constructor accepting the referenced type, a no-arg constructor, and a no-arg
`build()` method returning it — each accessible from the generated builder's
package. Any class with the expected name and a matching contract qualifies, allowing
references to builders generated with custom template annotations, external tools,
or different suffixes. If the candidate builder cannot be found, the field falls
back to a plain setter.
(or `builderSuffix` if not configured) and verifies the builder contract: a way
to create an empty builder (a no-arg constructor or a static parameterless
factory like `create()`), a way to create one seeded with the value (a
constructor accepting the referenced type or a static factory like `create(T)`
or `of(T)`), and a no-arg `build()` method returning it — each accessible from
the generated builder's package. Generated code calls a factory instead of
`new` when the builder offers one. Any class with the expected name and a
matching contract qualifies, allowing references to builders generated with
custom template annotations, external tools, or different suffixes. If the
candidate builder cannot be found, the field falls back to a plain setter.

Packages listed in `builderGenerationPackages` are automatically included in the usage
scope — their builders are generated in the same compilation and don't need to be listed
Expand Down Expand Up @@ -1024,12 +1027,15 @@ If empty, `builderSuffix` is used instead. This allows referencing builders that
with a different suffix (e.g. by another module using `"Factory"` as suffix) without changing
the suffix used for own builder generation.

The candidate class must provide a constructor accepting the referenced type, a no-arg
constructor, and a no-arg `build()` method returning it — each accessible from the generated
builder's package. The contract check is annotation-agnostic, so builders
generated with custom template annotations or external tools are supported. If the
candidate class does not exist or does not satisfy this contract, the field falls back
to a plain setter.
The candidate class must provide a way to create an empty builder (a no-arg
constructor or a static parameterless factory like `create()`), a way to create
one seeded with the value (a constructor accepting the referenced type or a
static factory like `create(T)` or `of(T)`), and a no-arg `build()` method
returning it — each accessible from the generated builder's package. Generated
code calls a factory instead of `new` when the builder offers one. The contract
check is annotation-agnostic, so builders generated with custom template
annotations or external tools are supported. If the candidate class does not
exist or does not satisfy this contract, the field falls back to a plain setter.

**Example**:
```java
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ public PersonDtoBuilder mannschaft(MannschaftDto mannschaft) {
public PersonDtoBuilder mannschaft(Consumer<MannschaftDtoBuilder> mannschaftBuilderConsumer) {
MannschaftDtoBuilder builder = this.mannschaft.isSet()
? new MannschaftDtoBuilder(this.mannschaft.value())
: new MannschaftDtoBuilder();
: MannschaftDtoBuilder.create();
mannschaftBuilderConsumer.accept(builder);
this.mannschaft = changedValue(builder.build());
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ public ScopedOwnerDtoBuilder trusted(TrustedHelperDto trusted) {
public ScopedOwnerDtoBuilder trusted(Consumer<TrustedHelperDtoBuilder> trustedBuilderConsumer) {
TrustedHelperDtoBuilder builder = this.trusted.isSet()
? new TrustedHelperDtoBuilder(this.trusted.value())
: new TrustedHelperDtoBuilder();
: TrustedHelperDtoBuilder.create();
trustedBuilderConsumer.accept(builder);
this.trusted = changedValue(builder.build());
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,18 @@
*/
package org.javahelpers.simple.builders.processor.analysis;

import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration;
import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
import org.javahelpers.simple.builders.processor.model.core.PackageScopes;
import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation;
import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder;
import org.javahelpers.simple.builders.processor.model.type.TypeName;
import org.javahelpers.simple.builders.processor.processing.ProcessingContext;

Expand All @@ -55,10 +59,13 @@ public final class BuilderScopeResolver {
*/
private record ResolutionInputs(BuilderConfiguration configuration, String builderPackage) {}

// Factory method names preferred when a builder offers several candidates
private static final List<String> PREFERRED_FACTORY_NAMES = List.of("create", "of");

private final ProcessingContext context;
private ResolutionInputs cachedResolutionInputs;
private PackageScopes usagePackages = PackageScopes.unscoped();
private final Map<String, Optional<TypeName>> resolvedBuilderTypes = new HashMap<>();
private final Map<String, Optional<ResolvedBuilder>> resolvedBuilderTypes = new HashMap<>();
private final GeneratedBuilders generatedBuilders = new GeneratedBuilders();

/**
Expand Down Expand Up @@ -91,18 +98,20 @@ public BuilderScopeResolver(ProcessingContext context) {
* immediately — trusted without a classpath lookup or contract check.
* <li>Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix}
* (falling back to {@code builderSuffix} if not configured). The candidate is looked up on
* the classpath and returned if it satisfies the builder contract: a constructor accepting
* the referenced type, a no-arg constructor, and a no-arg {@code build()} method returning
* it - each accessible from the generated builder's package. The contract check is
* annotation-agnostic, so builders generated with custom template annotations, external
* the classpath and returned if it satisfies the builder contract: an instantiation path
* for an empty builder (no-arg constructor or static factory like {@code create()}), an
* instantiation path seeded with the value (constructor accepting the referenced type or
* static factory like {@code create(T)}/{@code of(T)}), and a no-arg {@code build()} method
* returning it - each accessible from the generated builder's package. The contract check
* is annotation-agnostic, so builders generated with custom template annotations, external
* tools, or different suffixes are supported. The referenced type must not be opted out
* with {@code @Ignore4BuilderGeneration}.
* </ol>
*
* @param referencedType the type element being referenced as a field or collection element
* @return the builder type to reference, or empty if no builder should be referenced
* @return the resolved builder to reference, or empty if no builder should be referenced
*/
public Optional<TypeName> resolveUsableBuilderType(TypeElement referencedType) {
public Optional<ResolvedBuilder> resolveUsableBuilderType(TypeElement referencedType) {
if (referencedType == null) {
return Optional.empty();
}
Expand Down Expand Up @@ -171,13 +180,13 @@ public void resetGeneratedBuilders() {
resolvedBuilderTypes.clear();
}

private Optional<TypeName> resolve(TypeElement referencedType) {
private Optional<ResolvedBuilder> resolve(TypeElement referencedType) {
if (referencedType == null || isIgnoredForBuilderGeneration(referencedType)) {
return Optional.empty();
}

String referencedTypeFqn = referencedType.getQualifiedName().toString();
String packageName = context.getPackageName(referencedType);
TypeName referencedTypeName = JavaLangMapper.mapToTypeName(referencedType, context);

// The usage scope determines whether a type is eligible to be referenced as a builder
// helper. When empty, any package is allowed (backward compatibility). When set, only
Expand All @@ -189,50 +198,121 @@ private Optional<TypeName> resolve(TypeElement referencedType) {
// Types whose builders are generated in the current processing round are trusted
// immediately — our own generators always produce the builder contract, so no
// classpath lookup or contract check is needed.
Optional<TypeName> generatedBuilder =
generatedBuilders.findBuilder(
new TypeName(packageName, referencedType.getSimpleName().toString()));
Optional<TypeName> generatedBuilder = generatedBuilders.findBuilder(referencedTypeName);
if (generatedBuilder.isPresent()) {
return generatedBuilder;
// Our generators always emit a static create() and no create(T) - the empty path uses
// the factory, the copy path the constructor
return generatedBuilder.map(
builder ->
new ResolvedBuilder(
builder,
new BuilderInstantiation.StaticFactoryCall("create"),
new BuilderInstantiation.ConstructorCall()));
}

// For types not generated in this round, look up the candidate on the classpath using
// builderUsageSuffix (which falls back to builderSuffix if not configured) and verify
// the builder contract.
String suffix = context.getConfiguration().getBuilderUsageSuffix();
TypeName candidate = JavaLangMapper.createBuilderTypeName(referencedType, context, suffix);
return resolveByBuilderContract(candidate, referencedTypeFqn);
return resolveByBuilderContract(candidate, referencedTypeName);
}

/**
* Looks up the candidate builder type on the classpath and verifies it satisfies the builder
* contract: a constructor accepting the referenced type, a no-arg constructor, and a no-arg
* {@code build()} method returning it - each accessible from the generated builder's package,
* since the generated code calls them from there. The contract check is annotation-agnostic, so
* builders generated with custom template annotations or from external sources are supported as
* long as they follow the builder contract. It also avoids false positives like {@code String} →
* {@code StringBuilder}.
* contract: a way to create an empty instance (a no-arg constructor or a static parameterless
* factory like {@code create()}), a way to create an instance seeded with a value (a constructor
* accepting the referenced type or a static factory like {@code create(T)}/{@code of(T)}), and a
* no-arg {@code build()} method returning it - each accessible from the generated builder's
* package, since the generated code calls them from there. The contract check is
* annotation-agnostic, so builders generated with custom template annotations or from external
* sources are supported as long as they follow the builder contract. It also avoids false
* positives like {@code String} → {@code StringBuilder}.
*
* @param candidate the candidate builder type name to look up
* @param expectedType the qualified name of the referenced type the builder must accept and
* return
* @return the candidate if a matching builder class exists on the classpath, empty otherwise
* @param expectedType the referenced type the builder must accept and return
* @return the resolved builder with the instantiation paths to call, or empty if no matching
* builder class exists on the classpath
*/
private Optional<TypeName> resolveByBuilderContract(TypeName candidate, String expectedType) {
private Optional<ResolvedBuilder> resolveByBuilderContract(
TypeName candidate, TypeName expectedType) {
TypeElement builderTypeElement = context.getTypeElement(candidate.getFullQualifiedName());
if (builderTypeElement == null) {
return Optional.empty();
}
if (!JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) {
Optional<BuilderInstantiation> funcForEmptyBuilder =
resolveFuncForEmptyBuilder(builderTypeElement);
Optional<BuilderInstantiation> funcForPrefilledBuilder =
resolveFuncForPrefilledBuilder(builderTypeElement, expectedType);
if (funcForEmptyBuilder.isEmpty()
|| funcForPrefilledBuilder.isEmpty()
|| !JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) {
return Optional.empty();
}
if (!JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) {
return Optional.empty();
return Optional.of(
new ResolvedBuilder(candidate, funcForEmptyBuilder.get(), funcForPrefilledBuilder.get()));
}

/**
* Resolves the instantiation path for an empty builder instance: a static parameterless factory
* when the builder offers one, the no-arg constructor otherwise.
*
* @param builderTypeElement the candidate builder type to inspect
* @return the instantiation to emit, or empty when the builder offers neither
*/
private Optional<BuilderInstantiation> resolveFuncForEmptyBuilder(
TypeElement builderTypeElement) {
Optional<BuilderInstantiation> func = findStaticFactoryCall(builderTypeElement, List.of());
if (func.isEmpty() && JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) {
func = Optional.of(new BuilderInstantiation.ConstructorCall());
}
if (!JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) {
return Optional.empty();
return func;
}

/**
* Resolves the instantiation path for a builder instance seeded with a value of the referenced
* type: a static factory accepting the type when the builder offers one, the constructor
* accepting the type otherwise.
*
* @param builderTypeElement the candidate builder type to inspect
* @param expectedType the referenced type to seed the builder with
* @return the instantiation to emit, or empty when the builder offers neither
*/
private Optional<BuilderInstantiation> resolveFuncForPrefilledBuilder(
TypeElement builderTypeElement, TypeName expectedType) {
Optional<BuilderInstantiation> func =
findStaticFactoryCall(builderTypeElement, List.of(expectedType));
if (func.isEmpty()
&& JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) {
func = Optional.of(new BuilderInstantiation.ConstructorCall());
}
return Optional.of(candidate);
return func;
}

/**
* Finds an accessible static method on the builder type taking the expected parameter types and
* returning the builder type itself. When several candidates exist, {@code create} and {@code of}
* are preferred in that order, then the alphabetically first name.
*
* @param builderTypeElement the builder type to inspect
* @param expectedParameterTypes the parameter types the factory must accept
* @return the instantiation calling the found factory, or empty when none exists
*/
private Optional<BuilderInstantiation> findStaticFactoryCall(
TypeElement builderTypeElement, List<TypeName> expectedParameterTypes) {
TypeName builderTypeName = JavaLangMapper.mapToTypeName(builderTypeElement, context);
return JavaLangAnalyser.findMethodsStatic(
builderTypeElement, expectedParameterTypes, builderTypeName, context)
.stream()
.min(
Comparator.comparingInt(BuilderScopeResolver::preferredFactoryNameRank)
.thenComparing(Comparator.naturalOrder()))
.map(BuilderInstantiation.StaticFactoryCall::new);
}

private static int preferredFactoryNameRank(String name) {
int index = PREFERRED_FACTORY_NAMES.indexOf(name);
return index < 0 ? PREFERRED_FACTORY_NAMES.size() : index;
}

private void refreshForConfigurationIfNeeded() {
Expand Down
Loading
Loading