#3729 Support for using inner class Builder without using static factory method

This commit is contained in:
roelmang 2025-05-11 21:58:47 +02:00 committed by Filip Hrisafov
parent 2fb5776350
commit fce73aee6a
11 changed files with 321 additions and 38 deletions

View File

@ -6,6 +6,9 @@
### Enhancements
* Add support for locale parameter for numberFormat and dateFormat (#3628)
* Detect Builder without a factory method (#3729) - With this if there is an inner class that ends with `Builder` and has a constructor with parameters,
it will be treated as a potential builder.
Builders through static methods on the type have a precedence.
* Behaviour change: Warning when the target has no target properties (#1140)

View File

@ -421,8 +421,11 @@ If a Builder exists for a certain type, then that builder will be used for the m
The default implementation of the `BuilderProvider` assumes the following:
* The type has a parameterless public static builder creation method that returns a builder.
So for example `Person` has a public static method that returns `PersonBuilder`.
* The type has either
** A parameterless public static builder creation method that returns a builder.
e.g. `Person` has a public static method that returns `PersonBuilder`.
** A public static inner class with the name having the suffix "Builder", and a public no-args constructor
e.g. `Person` has an inner class `PersonBuilder` with a public no-args constructor.
* The builder type has a parameterless public method (build method) that returns the type being built.
In our example `PersonBuilder` has a method returning `Person`.
* In case there are multiple build methods, MapStruct will look for a method called `build`, if such method exists

View File

@ -10,6 +10,8 @@ import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
@ -184,32 +186,96 @@ public class DefaultBuilderProvider implements BuilderProvider {
return null;
}
List<ExecutableElement> methods = ElementFilter.methodsIn( typeElement.getEnclosedElements() );
List<BuilderInfo> builderInfo = new ArrayList<>();
for ( ExecutableElement method : methods ) {
if ( isPossibleBuilderCreationMethod( method, typeElement ) ) {
TypeElement builderElement = getTypeElement( method.getReturnType() );
Collection<ExecutableElement> buildMethods = findBuildMethods( builderElement, typeElement );
if ( !buildMethods.isEmpty() ) {
builderInfo.add( new BuilderInfo.Builder()
.builderCreationMethod( method )
.buildMethod( buildMethods )
.build()
);
// Builder infos which are determined by a static method on the type itself
List<BuilderInfo> methodBuilderInfos = new ArrayList<>();
// Builder infos which are determined by an inner builder class in the type itself
List<BuilderInfo> innerClassBuilderInfos = new ArrayList<>();
for ( Element enclosedElement : typeElement.getEnclosedElements() ) {
if ( ElementKind.METHOD == enclosedElement.getKind() ) {
ExecutableElement method = (ExecutableElement) enclosedElement;
BuilderInfo builderInfo = determineMethodBuilderInfo( method, typeElement );
if ( builderInfo != null ) {
methodBuilderInfos.add( builderInfo );
}
}
else if ( ElementKind.CLASS == enclosedElement.getKind() ) {
if ( !methodBuilderInfos.isEmpty() ) {
// Small optimization to not check the inner classes
// if we already have at least one builder through a method
continue;
}
TypeElement classElement = (TypeElement) enclosedElement;
BuilderInfo builderInfo = determineInnerClassBuilderInfo( classElement, typeElement );
if ( builderInfo != null ) {
innerClassBuilderInfos.add( builderInfo );
}
}
}
if ( builderInfo.size() == 1 ) {
return builderInfo.get( 0 );
if ( methodBuilderInfos.size() == 1 ) {
return methodBuilderInfos.get( 0 );
}
else if ( builderInfo.size() > 1 ) {
throw new MoreThanOneBuilderCreationMethodException( typeElement.asType(), builderInfo );
else if ( methodBuilderInfos.size() > 1 ) {
throw new MoreThanOneBuilderCreationMethodException( typeElement.asType(), methodBuilderInfos );
}
else if ( innerClassBuilderInfos.size() == 1 ) {
return innerClassBuilderInfos.get( 0 );
}
else if ( innerClassBuilderInfos.size() > 1 ) {
throw new MoreThanOneBuilderCreationMethodException( typeElement.asType(), innerClassBuilderInfos );
}
if ( checkParent ) {
return findBuilderInfo( typeElement.getSuperclass() );
}
return null;
}
private BuilderInfo determineMethodBuilderInfo(ExecutableElement method,
TypeElement typeElement) {
if ( isPossibleBuilderCreationMethod( method, typeElement ) ) {
TypeElement builderElement = getTypeElement( method.getReturnType() );
Collection<ExecutableElement> buildMethods = findBuildMethods( builderElement, typeElement );
if ( !buildMethods.isEmpty() ) {
return new BuilderInfo.Builder()
.builderCreationMethod( method )
.buildMethod( buildMethods )
.build();
}
}
return null;
}
private BuilderInfo determineInnerClassBuilderInfo(TypeElement innerClassElement,
TypeElement typeElement) {
if ( innerClassElement.getModifiers().contains( Modifier.PUBLIC )
&& innerClassElement.getModifiers().contains( Modifier.STATIC )
&& innerClassElement.getSimpleName().toString().endsWith( "Builder" ) ) {
for ( Element element : innerClassElement.getEnclosedElements() ) {
if ( ElementKind.CONSTRUCTOR == element.getKind() ) {
ExecutableElement constructor = (ExecutableElement) element;
if ( constructor.getParameters().isEmpty() ) {
// We have a no-arg constructor
// Now check if we have build methods
Collection<ExecutableElement> buildMethods = findBuildMethods( innerClassElement, typeElement );
if ( !buildMethods.isEmpty() ) {
return new BuilderInfo.Builder()
.builderCreationMethod( constructor )
.buildMethod( buildMethods )
.build();
}
// If we don't have any build methods
// then we can stop since we are only interested in the no-arg constructor
return null;
}
}
}
}
return null;
}

View File

@ -3,12 +3,13 @@
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple;
package org.mapstruct.ap.test.builder.simple.innerclass;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.ReportingPolicy;
import org.mapstruct.ap.test.builder.simple.SimpleMutablePerson;
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface ErroneousSimpleBuilderMapper {
@ -18,5 +19,5 @@ public interface ErroneousSimpleBuilderMapper {
@Mapping(target = "job", ignore = true ),
@Mapping(target = "city", ignore = true )
})
SimpleImmutablePerson toImmutable(SimpleMutablePerson source);
SimpleImmutablePersonWithInnerClassBuilder toImmutable(SimpleMutablePerson source);
}

View File

@ -3,12 +3,13 @@
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple;
package org.mapstruct.ap.test.builder.simple.innerclass;
import org.mapstruct.CollectionMappingStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.ap.test.builder.simple.SimpleMutablePerson;
@Mapper(collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED)
public interface SimpleBuilderMapper {
@ -18,5 +19,5 @@ public interface SimpleBuilderMapper {
@Mapping(target = "job", constant = "programmer"),
@Mapping(target = "city", expression = "java(\"Bengalore\")")
})
SimpleImmutablePerson toImmutable(SimpleMutablePerson source);
SimpleImmutablePersonWithInnerClassBuilder toImmutable(SimpleMutablePerson source);
}

View File

@ -3,11 +3,12 @@
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple;
package org.mapstruct.ap.test.builder.simple.innerclass;
import java.util.Arrays;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mapstruct.ap.test.builder.simple.SimpleMutablePerson;
import org.mapstruct.ap.testutil.ProcessorTest;
import org.mapstruct.ap.testutil.WithClasses;
import org.mapstruct.ap.testutil.compilation.annotation.CompilationResult;
@ -20,16 +21,16 @@ import static org.assertj.core.api.Assertions.assertThat;
@WithClasses({
SimpleMutablePerson.class,
SimpleImmutablePerson.class
SimpleImmutablePersonWithInnerClassBuilder.class
})
public class SimpleImmutableBuilderTest {
public class SimpleImmutableBuilderThroughInnerClassConstructorTest {
@RegisterExtension
final GeneratedSource generatedSource = new GeneratedSource();
@ProcessorTest
@WithClasses({ SimpleBuilderMapper.class })
public void testSimpleImmutableBuilderHappyPath() {
public void testSimpleImmutableBuilderThroughInnerClassConstructorHappyPath() {
SimpleBuilderMapper mapper = Mappers.getMapper( SimpleBuilderMapper.class );
SimpleMutablePerson source = new SimpleMutablePerson();
source.setAge( 3 );
@ -37,7 +38,7 @@ public class SimpleImmutableBuilderTest {
source.setChildren( Arrays.asList( "Alice", "Tom" ) );
source.setAddress( "Plaza 1" );
SimpleImmutablePerson targetObject = mapper.toImmutable( source );
SimpleImmutablePersonWithInnerClassBuilder targetObject = mapper.toImmutable( source );
assertThat( targetObject.getAge() ).isEqualTo( 3 );
assertThat( targetObject.getName() ).isEqualTo( "Bob" );
@ -53,8 +54,8 @@ public class SimpleImmutableBuilderTest {
diagnostics = @Diagnostic(
kind = javax.tools.Diagnostic.Kind.ERROR,
type = ErroneousSimpleBuilderMapper.class,
line = 21,
line = 22,
message = "Unmapped target property: \"name\"."))
public void testSimpleImmutableBuilderMissingPropertyFailsToCompile() {
public void testSimpleImmutableBuilderThroughInnerClassConstructorMissingPropertyFailsToCompile() {
}
}

View File

@ -3,12 +3,12 @@
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple;
package org.mapstruct.ap.test.builder.simple.innerclass;
import java.util.ArrayList;
import java.util.List;
public class SimpleImmutablePerson {
public class SimpleImmutablePersonWithInnerClassBuilder {
private final String name;
private final int age;
private final String job;
@ -16,7 +16,7 @@ public class SimpleImmutablePerson {
private final String address;
private final List<String> children;
SimpleImmutablePerson(Builder builder) {
SimpleImmutablePersonWithInnerClassBuilder(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.job = builder.job;
@ -25,10 +25,6 @@ public class SimpleImmutablePerson {
this.children = new ArrayList<>(builder.children);
}
public static Builder builder() {
return new Builder();
}
public int getAge() {
return age;
}
@ -66,8 +62,8 @@ public class SimpleImmutablePerson {
return this;
}
public SimpleImmutablePerson build() {
return new SimpleImmutablePerson( this );
public SimpleImmutablePersonWithInnerClassBuilder build() {
return new SimpleImmutablePersonWithInnerClassBuilder( this );
}
public Builder name(String name) {

View File

@ -0,0 +1,23 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple.staticfactorymethod;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.ReportingPolicy;
import org.mapstruct.ap.test.builder.simple.SimpleMutablePerson;
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface ErroneousSimpleBuilderMapper {
@Mappings({
@Mapping(target = "address", ignore = true ),
@Mapping(target = "job", ignore = true ),
@Mapping(target = "city", ignore = true )
})
SimpleImmutablePersonWithStaticFactoryMethodBuilder toImmutable(SimpleMutablePerson source);
}

View File

@ -0,0 +1,23 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple.staticfactorymethod;
import org.mapstruct.CollectionMappingStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.ap.test.builder.simple.SimpleMutablePerson;
@Mapper(collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED)
public interface SimpleBuilderMapper {
@Mappings({
@Mapping(target = "name", source = "fullName"),
@Mapping(target = "job", constant = "programmer"),
@Mapping(target = "city", expression = "java(\"Bengalore\")")
})
SimpleImmutablePersonWithStaticFactoryMethodBuilder toImmutable(SimpleMutablePerson source);
}

View File

@ -0,0 +1,61 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple.staticfactorymethod;
import java.util.Arrays;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mapstruct.ap.test.builder.simple.SimpleMutablePerson;
import org.mapstruct.ap.testutil.ProcessorTest;
import org.mapstruct.ap.testutil.WithClasses;
import org.mapstruct.ap.testutil.compilation.annotation.CompilationResult;
import org.mapstruct.ap.testutil.compilation.annotation.Diagnostic;
import org.mapstruct.ap.testutil.compilation.annotation.ExpectedCompilationOutcome;
import org.mapstruct.ap.testutil.runner.GeneratedSource;
import org.mapstruct.factory.Mappers;
import static org.assertj.core.api.Assertions.assertThat;
@WithClasses({
SimpleMutablePerson.class,
SimpleImmutablePersonWithStaticFactoryMethodBuilder.class
})
public class SimpleImmutableBuilderThroughStaticFactoryMethodTest {
@RegisterExtension
final GeneratedSource generatedSource = new GeneratedSource();
@ProcessorTest
@WithClasses({ SimpleBuilderMapper.class })
public void testSimpleImmutableBuilderThroughStaticFactoryMethodHappyPath() {
SimpleBuilderMapper mapper = Mappers.getMapper( SimpleBuilderMapper.class );
SimpleMutablePerson source = new SimpleMutablePerson();
source.setAge( 3 );
source.setFullName( "Bob" );
source.setChildren( Arrays.asList( "Alice", "Tom" ) );
source.setAddress( "Plaza 1" );
SimpleImmutablePersonWithStaticFactoryMethodBuilder targetObject = mapper.toImmutable( source );
assertThat( targetObject.getAge() ).isEqualTo( 3 );
assertThat( targetObject.getName() ).isEqualTo( "Bob" );
assertThat( targetObject.getJob() ).isEqualTo( "programmer" );
assertThat( targetObject.getCity() ).isEqualTo( "Bengalore" );
assertThat( targetObject.getAddress() ).isEqualTo( "Plaza 1" );
assertThat( targetObject.getChildren() ).contains( "Alice", "Tom" );
}
@ProcessorTest
@WithClasses({ ErroneousSimpleBuilderMapper.class })
@ExpectedCompilationOutcome(value = CompilationResult.FAILED,
diagnostics = @Diagnostic(
kind = javax.tools.Diagnostic.Kind.ERROR,
type = ErroneousSimpleBuilderMapper.class,
line = 22,
message = "Unmapped target property: \"name\"."))
public void testSimpleImmutableBuilderThroughStaticFactoryMethodMissingPropertyFailsToCompile() {
}
}

View File

@ -0,0 +1,105 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.builder.simple.staticfactorymethod;
import java.util.ArrayList;
import java.util.List;
public class SimpleImmutablePersonWithStaticFactoryMethodBuilder {
private final String name;
private final int age;
private final String job;
private final String city;
private final String address;
private final List<String> children;
SimpleImmutablePersonWithStaticFactoryMethodBuilder(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.job = builder.job;
this.city = builder.city;
this.address = builder.address;
this.children = new ArrayList<>( builder.children );
}
public static Builder builder() {
return new Builder();
}
public int getAge() {
return age;
}
public String getName() {
return name;
}
public String getJob() {
return job;
}
public String getCity() {
return city;
}
public String getAddress() {
return address;
}
public List<String> getChildren() {
return children;
}
public static class Builder {
private String name;
private int age;
private String job;
private String city;
private String address;
private List<String> children = new ArrayList<>();
private Builder() {
}
public Builder age(int age) {
this.age = age;
return this;
}
public SimpleImmutablePersonWithStaticFactoryMethodBuilder build() {
return new SimpleImmutablePersonWithStaticFactoryMethodBuilder( this );
}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder job(String job) {
this.job = job;
return this;
}
public Builder city(String city) {
this.city = city;
return this;
}
public Builder address(String address) {
this.address = address;
return this;
}
public List<String> getChildren() {
throw new UnsupportedOperationException( "This is just a marker method" );
}
public Builder addChild(String child) {
this.children.add( child );
return this;
}
}
}