diff --git a/README.md b/README.md index 1246e7b..1e063da 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,31 @@ The location of the builder can be changed: @Buildable(packageName = "my.other.garage") public class Car { ``` + +### Pickup setter methods as buildable + +When Bob encounters setters (with or without the set prefix) +and a corresponding field it will add the fields to the final builder. + +In the below example, +if though `color` is not part of the constructor it will be part of the final generated Builder +because there is a setter available, which will be used. + +```java +@Buildable +public class Car { + private Brand brand; + private String color; + + public Car(Brand brand) { + this.brand = brand; + } + + public void color(String color) { + this.color = color; + } +} +``` ### Field exclusion diff --git a/processor/src/main/java/io/jonasg/bob/BuildableField.java b/processor/src/main/java/io/jonasg/bob/BuildableField.java new file mode 100644 index 0000000..4ddffdf --- /dev/null +++ b/processor/src/main/java/io/jonasg/bob/BuildableField.java @@ -0,0 +1,23 @@ +package io.jonasg.bob; + +import java.util.Optional; + +import javax.lang.model.type.TypeMirror; + +/** + * Represents a field that is buildable + * @param fieldName the name of the field as declared in the type that will be built + * @param isConstructorArgument indicates if the field can be set through the constructor + * @param setterMethodName the name of the setter method to access the field. + * @param type the type of the field + */ +public record BuildableField( + String fieldName, + boolean isConstructorArgument, + Optional setterMethodName, + TypeMirror type) { + + public static BuildableField fromConstructor(String fieldName, TypeMirror type) { + return new BuildableField(fieldName, true, Optional.empty(), type); + } +} diff --git a/processor/src/main/java/io/jonasg/bob/TypeSpecFactory.java b/processor/src/main/java/io/jonasg/bob/TypeSpecFactory.java index d849978..5ee0ce3 100644 --- a/processor/src/main/java/io/jonasg/bob/TypeSpecFactory.java +++ b/processor/src/main/java/io/jonasg/bob/TypeSpecFactory.java @@ -4,7 +4,10 @@ import java.util.Arrays; import java.util.Comparator; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.lang.model.element.Modifier; import javax.lang.model.type.TypeMirror; @@ -13,6 +16,7 @@ import com.squareup.javapoet.CodeBlock; import com.squareup.javapoet.FieldSpec; import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.MethodSpec.Builder; import com.squareup.javapoet.ParameterizedTypeName; import com.squareup.javapoet.TypeName; import com.squareup.javapoet.TypeSpec; @@ -32,7 +36,7 @@ public class TypeSpecFactory { private final Buildable buildable; - private final List eligibleConstructorParams; + private final List buildableFields; private String builderTypeName(TypeDefinition source) { return Formatter.format("$typeName$suffix", source.typeName(), "Builder"); @@ -41,7 +45,31 @@ private String builderTypeName(TypeDefinition source) { private TypeSpecFactory(TypeDefinition typeDefinition, Buildable buildable) { this.typeDefinition = typeDefinition; this.buildable = buildable; + this.constructorDefinition = extractConstructorDefinitionFrom(typeDefinition); + this.buildableFields = extractBuildableFieldsFrom(typeDefinition); + } + private List extractBuildableFieldsFrom(TypeDefinition typeDefinition) { + var fieldNames = typeDefinition.fields().stream() + .map(FieldDefinition::name) + .toList(); + List eligibleConstructorParams = this.constructorDefinition.parameters() + .stream() + .filter(p -> fieldNames.contains(p.name())) + .toList(); + Stream constructorBuildableFields = this.constructorDefinition.parameters() + .stream() + .filter(p -> fieldNames.contains(p.name())) + .map(p -> BuildableField.fromConstructor(p.name(), p.type())); + Stream setterBuildableFields = this.typeDefinition.getSetterMethods() + .stream() + .filter(field -> !eligibleConstructorParams + .contains(new ParameterDefinition(field.type(), field.fieldName()))) + .map(p -> new BuildableField(p.fieldName(), false, Optional.of(p.methodName()), p.type())); + return Stream.concat(constructorBuildableFields, setterBuildableFields).toList(); + } + + private ConstructorDefinition extractConstructorDefinitionFrom(TypeDefinition typeDefinition) { var buildableConstructors = typeDefinition.constructors().stream() .filter(c -> c.isAnnotatedWith(BuildableConstructor.class)) .toList(); @@ -49,19 +77,11 @@ private TypeSpecFactory(TypeDefinition typeDefinition, Buildable buildable) { throw new IllegalArgumentException("Only one constructor can be annotated with @BuildableConstructor"); } if (buildableConstructors.isEmpty()) { - this.constructorDefinition = typeDefinition.constructors().stream() + return typeDefinition.constructors().stream() .max(Comparator.comparingInt(c -> c.parameters().size())).orElseThrow(); } else { - this.constructorDefinition = buildableConstructors.get(0); + return buildableConstructors.get(0); } - - var fieldNames = typeDefinition.fields().stream() - .map(FieldDefinition::name) - .toList(); - this.eligibleConstructorParams = this.constructorDefinition.parameters() - .stream() - .filter(p -> fieldNames.contains(p.name())) - .toList(); } public static TypeSpec produce(TypeDefinition typeDefinition, Buildable buildable) { @@ -73,53 +93,79 @@ private TypeSpec typeSpec() { .addModifiers(Modifier.PUBLIC, Modifier.FINAL); if (!this.typeDefinition.genericParameters().isEmpty()) builder.addTypeVariables(toTypeVariableNames(this.typeDefinition)); - builder.addMethods(setters()); - builder.addFields(fields()); - builder.addMethod(buildMethod()); - builder.addMethod(constructor()); - if (!this.typeDefinition.genericParameters().isEmpty()) + builder.addMethods(generateSetters()); + builder.addFields(generateFields()); + builder.addMethod(genereateBuildMethod()); + builder.addMethod(generateConstructor()); + if (!this.typeDefinition.genericParameters().isEmpty()) { builder.addMethod(of()); + } return builder.build(); } - private List setters() { - List setters = new ArrayList<>(); - for (ParameterDefinition field : this.eligibleConstructorParams) { - if (notExcluded(field)) { - MethodSpec.Builder setter = MethodSpec.methodBuilder(fieldName(field.name())) + private List generateSetters() { + return this.buildableFields.stream() + .filter(this::notExcluded) + .map(field -> MethodSpec.methodBuilder(setterName(field.fieldName())) .addModifiers(Modifier.PUBLIC) .returns(builderType()) - .addParameter(TypeName.get(field.type()), field.name()); - setter - .addStatement("this.$L = $L", field.name(), field.name()) - .build(); - setters.add(setter + .addParameter(TypeName.get(field.type()), field.fieldName()) + .addStatement("this.$L = $L", field.fieldName(), field.fieldName()) .addStatement("return this") - .build()); - } - } - return setters; + .build()) + .toList(); } - private List fields() { - return this.eligibleConstructorParams.stream() - .map(field -> FieldSpec.builder(TypeName.get(field.type()), field.name(), Modifier.PRIVATE) + private List generateFields() { + return buildableFields.stream() + .map(field -> FieldSpec.builder(TypeName.get(field.type()), field.fieldName(), Modifier.PRIVATE) .build()) .toList(); } - private MethodSpec buildMethod() { - MethodSpec.Builder builder = MethodSpec.methodBuilder("build") + private MethodSpec genereateBuildMethod() { + Builder builder = MethodSpec.methodBuilder("build") .addModifiers(Modifier.PUBLIC) .returns(className(this.typeDefinition)); - builder.addStatement("return new $T($L)", className(this.typeDefinition), - this.constructorDefinition.parameters().stream() - .map(param -> this.eligibleConstructorParams.contains(param) ? param.name() - : defaultForType(param.type())) - .collect(Collectors.joining(", "))); + if (typeDefinition.containsSetterMethods()) { + createConstructorAndSetterAwareBuildMethod(builder); + } else { + createConstructorOnlyBuildMethod(builder); + } return builder.build(); } + private void createConstructorAndSetterAwareBuildMethod(Builder builder) { + builder.addStatement("var instance = new $T($L)", className(this.typeDefinition), + this.toConstructorCallingStatement(this.constructorDefinition)); + typeDefinition.getSetterMethods() + .forEach(method -> { + String fieldName; + if (method.methodName().startsWith("set")) { + fieldName = method.methodName().substring(3, 4).toLowerCase() + + method.methodName().substring(4); + builder.addStatement("instance.%s(this.%s)".formatted(method.methodName(), fieldName)); + } else { + fieldName = method.methodName(); + builder.addStatement("instance.%s(this.%s)".formatted(setterName(fieldName), fieldName)); + } + }); + builder.addStatement("return instance"); + } + + private void createConstructorOnlyBuildMethod(Builder builder) { + builder.addStatement("return new $T($L)", className(this.typeDefinition), + toConstructorCallingStatement(this.constructorDefinition)); + } + + private String toConstructorCallingStatement(ConstructorDefinition constructorDefinition) { + return constructorDefinition.parameters().stream() + .map(param -> this.buildableFields.stream().anyMatch(f -> Objects.equals(f.fieldName(), param.name())) + ? param.name() + : defaultForType(param.type())) + .collect(Collectors.joining(", ")); + } + private String defaultForType(TypeMirror type) { return switch (type.toString()) { case "int" -> "0"; @@ -134,15 +180,16 @@ private String defaultForType(TypeMirror type) { private MethodSpec of() { CodeBlock.Builder body = CodeBlock.builder(); body.addStatement("return new $L<>()", builderTypeName(this.typeDefinition)); - MethodSpec.Builder of = MethodSpec.methodBuilder("of") + Builder builder = MethodSpec.methodBuilder("of") .addModifiers(Modifier.PUBLIC, Modifier.STATIC) .addTypeVariables(builderTypeGenerics()) .returns(builderType()); - for (ParameterDefinition parameter : this.typeDefinition.genericParameters()) - of.addParameter(ParameterizedTypeName.get(ClassName.get("java.lang", "Class"), + for (ParameterDefinition parameter : this.typeDefinition.genericParameters()) { + builder.addParameter(ParameterizedTypeName.get(ClassName.get("java.lang", "Class"), TypeVariableName.get(parameter.name())), String.format("%stype", parameter.name())); - of.addCode(body.build()); - return of.build(); + } + builder.addCode(body.build()); + return builder.build(); } private TypeName builderType() { @@ -161,7 +208,7 @@ private TypeName builderType() { else result = String.format("%s.builder", this.typeDefinition.packageName()); return ParameterizedTypeName.get(ClassName.get(result, builderTypeName(this.typeDefinition)), - typeVariableNames.toArray(new TypeName[typeVariableNames.size()])); + typeVariableNames.toArray(new TypeName[0])); } private List builderTypeGenerics() { @@ -171,7 +218,7 @@ private List builderTypeGenerics() { for (SimpleTypeDefinition definition : param.bounds()) { bounds.add(TypeVariableName.get(definition.typeName())); } - typeVariableNames.add(TypeVariableName.get(param.name(), bounds.toArray(new TypeName[bounds.size()]))); + typeVariableNames.add(TypeVariableName.get(param.name(), bounds.toArray(new TypeName[0]))); } return typeVariableNames; } @@ -186,7 +233,7 @@ private TypeName className(TypeDefinition definition) { } else { List genericParameters = toTypeVariableNames(definition); return ParameterizedTypeName.get(ClassName.get(definition.packageName(), definition.fullTypeName()), - genericParameters.toArray(new TypeName[genericParameters.size()])); + genericParameters.toArray(new TypeName[0])); } } @@ -206,7 +253,7 @@ private List simpleClassNames(List definitions) return typeNames; } - private String fieldName(String name) { + private String setterName(String name) { if (buildable.setterPrefix().isEmpty()) { return name; } @@ -214,11 +261,11 @@ private String fieldName(String name) { name.substring(0, 1).toUpperCase() + name.substring(1)); } - private boolean notExcluded(ParameterDefinition field) { - return !Arrays.asList(buildable.excludeFields()).contains(field.name()); + private boolean notExcluded(BuildableField field) { + return !Arrays.asList(buildable.excludeFields()).contains(field.fieldName()); } - private MethodSpec constructor() { + private MethodSpec generateConstructor() { return MethodSpec.constructorBuilder() .addModifiers(Modifier.PUBLIC) .build(); diff --git a/processor/src/main/java/io/jonasg/bob/definitions/FieldDefinition.java b/processor/src/main/java/io/jonasg/bob/definitions/FieldDefinition.java index 30bbfed..0c789a1 100644 --- a/processor/src/main/java/io/jonasg/bob/definitions/FieldDefinition.java +++ b/processor/src/main/java/io/jonasg/bob/definitions/FieldDefinition.java @@ -2,22 +2,5 @@ import javax.lang.model.type.TypeMirror; -public class FieldDefinition { - - private final String name; - - private final TypeMirror type; - - public FieldDefinition(String name, TypeMirror type) { - this.name = name; - this.type = type; - } - - public String name() { - return name; - } - - public TypeMirror type() { - return type; - } +public record FieldDefinition(String name, TypeMirror type) { } diff --git a/processor/src/main/java/io/jonasg/bob/definitions/MethodDefinition.java b/processor/src/main/java/io/jonasg/bob/definitions/MethodDefinition.java index 4b70936..2350531 100644 --- a/processor/src/main/java/io/jonasg/bob/definitions/MethodDefinition.java +++ b/processor/src/main/java/io/jonasg/bob/definitions/MethodDefinition.java @@ -1,13 +1,9 @@ package io.jonasg.bob.definitions; -public class MethodDefinition { - private final String name; +import java.util.List; - public MethodDefinition(String name) { - this.name = name; - } +import javax.lang.model.type.TypeMirror; - public String name() { - return name; - } +public record MethodDefinition(String name, + List parameters) { } diff --git a/processor/src/main/java/io/jonasg/bob/definitions/SetterMethodDefinition.java b/processor/src/main/java/io/jonasg/bob/definitions/SetterMethodDefinition.java new file mode 100644 index 0000000..f0c4cb1 --- /dev/null +++ b/processor/src/main/java/io/jonasg/bob/definitions/SetterMethodDefinition.java @@ -0,0 +1,8 @@ +package io.jonasg.bob.definitions; + +import javax.lang.model.type.TypeMirror; + +public record SetterMethodDefinition(String methodName, + String fieldName, + TypeMirror type) { +} diff --git a/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinition.java b/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinition.java index b00c523..8349915 100644 --- a/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinition.java +++ b/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinition.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -71,6 +72,31 @@ public static Builder newBuilder() { return new Builder(); } + public List getSetterMethods() { + List setters = new ArrayList<>(); + List methodsWithOneParam = this.methods.stream() + .filter(m -> m.parameters().size() == 1) + .toList(); + for (FieldDefinition field : fields) { + String name = field.name().substring(0, 1).toUpperCase() + field.name().substring(1); + methodsWithOneParam.stream() + .filter(m -> m.name().equals(field.name())) + .findFirst() + .map(m -> new SetterMethodDefinition(m.name(), field.name(), m.parameters().get(0))) + .ifPresent(setters::add); + methodsWithOneParam.stream() + .filter(m -> m.name().equals("set%s".formatted(name))) + .findFirst() + .map(m -> new SetterMethodDefinition(m.name(), field.name(), m.parameters().get(0))) + .ifPresent(setters::add); + } + return setters; + } + + public boolean containsSetterMethods() { + return !getSetterMethods().isEmpty(); + } + public static class Builder { private TypeDefinition instance = new TypeDefinition(); diff --git a/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinitionFactory.java b/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinitionFactory.java index fa1c99f..cc600bd 100644 --- a/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinitionFactory.java +++ b/processor/src/main/java/io/jonasg/bob/definitions/TypeDefinitionFactory.java @@ -133,7 +133,13 @@ private List fields() { private List methods() { return ElementFilter.methodsIn(element.getEnclosedElements()).stream() - .map(e -> new MethodDefinition(e.getSimpleName().toString())) + .map(e -> new MethodDefinition(e.getSimpleName().toString(), parameterTypes(e))) + .toList(); + } + + private List parameterTypes(ExecutableElement element) { + return element.getParameters().stream() + .map(VariableElement::asType) .toList(); } } diff --git a/processor/src/test/java/io/jonasg/bob/BobFeaturesTests.java b/processor/src/test/java/io/jonasg/bob/BobFeaturesTests.java index f6ac43c..ce1d0fd 100644 --- a/processor/src/test/java/io/jonasg/bob/BobFeaturesTests.java +++ b/processor/src/test/java/io/jonasg/bob/BobFeaturesTests.java @@ -218,4 +218,24 @@ void recordsAreBuildable() { .executeTest(); } + @Test + void allPublicSettersThatHaveCorrespondingFieldsAreBuildable() { + Cute.blackBoxTest() + .given() + .processors(List.of(BuildableProcessor.class)) + .andSourceFiles( + "/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java") + .whenCompiled() + .thenExpectThat() + .compilationSucceeds() + .andThat() + .generatedSourceFile( + "io.jonasg.bob.test.builder.AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder") + .matches( + CuteApi.ExpectedFileObjectMatcherKind.BINARY, + JavaFileObjectUtils.readFromResource( + "/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/Expected_AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java")) + .executeTest(); + } + } diff --git a/processor/src/test/resources/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java b/processor/src/test/resources/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java new file mode 100644 index 0000000..e57e2ac --- /dev/null +++ b/processor/src/test/resources/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java @@ -0,0 +1,39 @@ +package io.jonasg.bob.test; + +import io.jonasg.bob.Buildable; + +@Buildable +public class AllPublicSettersThatHaveCorrespondingFieldsAreBuildable { + private String make; + + private int year; + + private double engineSize; + + private boolean isElectric; + + private float fuelEfficiency; + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildable() { + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildable(String make, int year) { + this.make = make; + this.year = year; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildable setEngineSize(double engineSize) { + this.engineSize = engineSize; + return this; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildable setIsElectric(boolean isElectric) { + isElectric = isElectric; + return this; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildable setFuelEfficiency(float fuelEfficiency) { + this.fuelEfficiency = fuelEfficiency; + return this; + } +} diff --git a/processor/src/test/resources/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/Expected_AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java b/processor/src/test/resources/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/Expected_AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java new file mode 100644 index 0000000..c9be676 --- /dev/null +++ b/processor/src/test/resources/tests/successful-compilation/AllPublicSettersThatHaveCorrespondingFieldsAreBuildable/Expected_AllPublicSettersThatHaveCorrespondingFieldsAreBuildable.java @@ -0,0 +1,55 @@ +package io.jonasg.bob.test.builder; + +import io.jonasg.bob.test.AllPublicSettersThatHaveCorrespondingFieldsAreBuildable; +import java.lang.String; + +public final class AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder { + private String make; + + private int year; + + private double engineSize; + + private boolean isElectric; + + private float fuelEfficiency; + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder() { + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder make(String make) { + this.make = make; + return this; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder year(int year) { + this.year = year; + return this; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder engineSize( + double engineSize) { + this.engineSize = engineSize; + return this; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder isElectric( + boolean isElectric) { + this.isElectric = isElectric; + return this; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildableBuilder fuelEfficiency( + float fuelEfficiency) { + this.fuelEfficiency = fuelEfficiency; + return this; + } + + public AllPublicSettersThatHaveCorrespondingFieldsAreBuildable build() { + var instance = new AllPublicSettersThatHaveCorrespondingFieldsAreBuildable(make, year); + instance.setEngineSize(this.engineSize); + instance.setIsElectric(this.isElectric); + instance.setFuelEfficiency(this.fuelEfficiency); + return instance; + } +}