Skip to content

Commit

Permalink
feat: allow for making none-constructor fields explicitly mandatory
Browse files Browse the repository at this point in the history
This should close #11
  • Loading branch information
jonas-grgt committed Apr 15, 2024
1 parent 7ea0bf3 commit a208f7d
Show file tree
Hide file tree
Showing 17 changed files with 388 additions and 34 deletions.
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ new Car(null, 0, "red", BigDecimal.ZERO);

Because `brand` and `year` aren't fields the default value for the corresponding types are used.


### Different constructor

If you want to use a different constructor instead of the default selected one, annotated it with `@Buildable.Constructor`

### Constructor enforcement

When constructing Java objects,
Expand All @@ -107,10 +112,10 @@ to be partially constructed without setting all fields specified in the construc
This default behavior is governed by the `ConstructorPolicy.PERMISSIVE` setting.

However, the `ConstructorPolicy` also provides an `ENFORCED` mode.
When this mode is active,
When this policy is active,
it is mandatory to supply all constructor parameters when creating a new object.
If any required parameters are missing,
the policy enforces strict compliance by throwing an exception.
the policy enforces strict compliance by throwing an `MandatoryFieldMissingException`.
This ensures that every object is fully initialized as intended,
preventing issues that arise from improperly constructed objects.

Expand All @@ -119,11 +124,29 @@ preventing issues that arise from improperly constructed objects.
public class Car {
```

### Different constructor
### Mandatory Fields

If you want to use a different constructor instead of the default selected one, annotated it with `@Buildable.Constructor`
Fields can be designated as mandatory;
- through the `mandatoryFields` property of `@Buildable`
- through annotating the field with @Buildable.Mandatory.

Similar to the constructor parameters in the ENFORCED mode,
the omission of these required fields when building an object will trigger a MandatoryFieldMissingException.
This mechanism ensures that all necessary fields are set before an object is finalized.

### Package
```java
@Buildable(mandatoryFields = {"color"})
public class Car {
private String color;
```

```java
@Buildable
public class Car {
@Buildable.Mandatory
private String color;
```
### Change Default Package

A `CarBuilder` class will be generated in the same package as the source class with *builder* as suffix.
For the car example this will be `my.garage.CarBuilder`
Expand Down
12 changes: 12 additions & 0 deletions annotations/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,16 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj-core.version}</version>
</dependency>
</dependencies>

</project>
20 changes: 18 additions & 2 deletions annotations/src/main/java/io/jonasg/bob/Buildable.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
* The builder will have a build method
* that will create an instance of the annotated class.
*/
@SuppressWarnings("unused")
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Buildable {
Expand Down Expand Up @@ -39,6 +38,14 @@
*/
String packageName() default "";

/**
* List of fields that should be set within the building process.
* If not, an {@link MandatoryFieldMissingException} will be thrown.
*
* @return mandatory fields
*/
String[] mandatoryFields() default {};

/**
* Specifies the constructor policy to be applied when building an instance of
* the annotated class.
Expand All @@ -65,10 +72,19 @@
* using the selected constructor as opposed to the one with the most
* parameters.
*/
@SuppressWarnings("unused")
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.CONSTRUCTOR)
@interface Constructor {
}

/**
* Marks a field as mandatory. When the field is not set within the building
* process
* an {@link MandatoryFieldMissingException} will be thrown.
*/
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.FIELD)
@interface Mandatory {
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.jonasg.bob;

public final class MandatoryFieldMissingException extends RuntimeException {
public MandatoryFieldMissingException(String fieldName, String typeName) {
super("Mandatory field (" + fieldName + ") not set when building type (" + typeName + ")");
}
}
3 changes: 1 addition & 2 deletions annotations/src/main/java/io/jonasg/bob/RequiredField.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ public void set(T value) {

public T orElseThrow() {
if (fieldValue == null) {
throw new IllegalStateException(
"Required field (" + fieldName + ") not set when building type (" + typeName + ")");
throw new MandatoryFieldMissingException(fieldName, typeName);
}
return fieldValue;
}
Expand Down
37 changes: 37 additions & 0 deletions annotations/src/test/java/io/jonasg/bob/RequiredFieldTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.jonasg.bob;

import org.assertj.core.api.Assertions;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.Test;

class RequiredFieldTest {

@Test
void throwIllegalBuildExceptionWhenFieldValueIsNotSet() {
// given
RequiredField<String> nameField = RequiredField.ofNameWithinType("name", "Person");

// when
ThrowingCallable whenOrElseThrowIsCalled = nameField::orElseThrow;

// then
Assertions.assertThatThrownBy(whenOrElseThrowIsCalled)
.isInstanceOf(MandatoryFieldMissingException.class)
.hasMessage("Mandatory field (name) not set when building type (Person)");
}

@Test
void returnFieldValue() {
// given
RequiredField<String> nameField = RequiredField.ofNameWithinType("name", "Person");
nameField.set("John");

// when
String value = nameField.orElseThrow();

// then
Assertions.assertThat(value)
.isEqualTo("John");
}

}
7 changes: 4 additions & 3 deletions processor/src/main/java/io/jonasg/bob/BuildableField.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
public record BuildableField(
String fieldName,
boolean isConstructorArgument,
boolean isMandatory,
Optional<String> setterMethodName,
TypeMirror type) {

public static BuildableField fromConstructor(String fieldName, TypeMirror type) {
return new BuildableField(fieldName, true, Optional.empty(), type);
return new BuildableField(fieldName, true, false, Optional.empty(), type);
}

public boolean isMandatory() {
return isConstructorArgument;
public static BuildableField fromSetter(String fieldName, boolean fieldIsMandatory, String setterMethodName, TypeMirror type) {
return new BuildableField(fieldName, false, fieldIsMandatory, Optional.of(setterMethodName), type);
}
}
23 changes: 14 additions & 9 deletions processor/src/main/java/io/jonasg/bob/BuilderTypeSpecFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
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;

Expand Down Expand Up @@ -68,9 +67,14 @@ private List<BuildableField> extractBuildableFieldsFrom(TypeDefinition typeDefin
.map(p -> BuildableField.fromConstructor(p.name(), p.type()));
Stream<BuildableField> 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()));
.filter(setter -> !eligibleConstructorParams
.contains(new ParameterDefinition(setter.type(), setter.field().name())))
.map(p -> {
boolean fieldIsMandatory = Arrays.stream(this.buildable.mandatoryFields())
.anyMatch(f -> Objects.equals(f, p.field().name()))
|| p.field().isAnnotatedWith(Buildable.Mandatory.class);
return BuildableField.fromSetter(p.field().name(), fieldIsMandatory, p.methodName(), p.type());
});
return Stream.concat(constructorBuildableFields, setterBuildableFields).toList();
}

Expand Down Expand Up @@ -116,7 +120,7 @@ protected MethodSpec generateSetterForField(BuildableField field) {
.addModifiers(Modifier.PUBLIC)
.returns(builderType())
.addParameter(TypeName.get(field.type()), field.fieldName());
if (field.isMandatory() && isEnforcedConstructorPolicy()) {
if (field.isConstructorArgument() && isEnforcedConstructorPolicy() || field.isMandatory()) {
builder.addStatement("this.$L.set($L)", field.fieldName(), field.fieldName());
} else {
builder.addStatement("this.$L = $L", field.fieldName(), field.fieldName());
Expand All @@ -136,7 +140,7 @@ private List<FieldSpec> generateFields() {
}

protected FieldSpec generateField(BuildableField field) {
if (field.isMandatory() && isEnforcedConstructorPolicy()) {
if ((field.isConstructorArgument() && isEnforcedConstructorPolicy()) || field.isMandatory()) {
return FieldSpec
.builder(ParameterizedTypeName.get(ClassName.get(RequiredField.class),
TypeName.get(boxedType(field.type()))), field.fieldName(), Modifier.PRIVATE,
Expand Down Expand Up @@ -205,13 +209,14 @@ private void createConstructorAndSetterAwareBuildMethod(Builder builder) {
}

protected CodeBlock generateFieldAssignment(BuildableField field) {
if (field.isMandatory() && isEnforcedConstructorPolicy()) {
if (field.isConstructorArgument() && isEnforcedConstructorPolicy() || field.isMandatory()) {
return CodeBlock.builder()
.addStatement("instance.$L(this.$L)", setterName(field.setterMethodName().get()), field.fieldName())
.addStatement("instance.$L(this.$L.orElseThrow())",
setterName(field.setterMethodName().orElseThrow()), field.fieldName())
.build();
} else {
return CodeBlock.builder()
.addStatement("instance.%s(this.%s)".formatted(setterName(field.setterMethodName().get()),
.addStatement("instance.%s(this.%s)".formatted(setterName(field.setterMethodName().orElseThrow()),
field.fieldName()))
.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
package io.jonasg.bob.definitions;

import java.util.List;
import java.util.Objects;

import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.type.TypeMirror;

public record FieldDefinition(String name, TypeMirror type) {
public record FieldDefinition(String name,
List<? extends AnnotationMirror> annotations,
TypeMirror type) {
public <T> boolean isAnnotatedWith(Class<T> type) {
return annotations.stream()
.anyMatch(a -> Objects.equals(type.getName().replaceAll("\\$", "."),
a.getAnnotationType().asElement().asType().toString()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
import javax.lang.model.type.TypeMirror;

public record SetterMethodDefinition(String methodName,
String fieldName,
FieldDefinition field,
TypeMirror type) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ public List<SetterMethodDefinition> getSetterMethods() {
methodsWithOneParam.stream()
.filter(m -> m.name().equals(field.name()))
.findFirst()
.map(m -> new SetterMethodDefinition(m.name(), field.name(), m.parameters().get(0)))
.map(m -> new SetterMethodDefinition(m.name(), field, 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)))
.map(m -> new SetterMethodDefinition(m.name(), field, m.parameters().get(0)))
.ifPresent(setters::add);
}
return setters;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
Expand Down Expand Up @@ -75,17 +76,20 @@ private List<SimpleTypeDefinition> toTypeDefinitions(List<? extends TypeMirror>
private static String join(String[] aArr, String sSep) {
StringBuilder sbStr = new StringBuilder();
for (int i = 0, il = aArr.length; i < il; i++) {
if (i > 0)
if (i > 0) {
sbStr.append(sSep);
}
sbStr.append(aArr[i]);
}
return sbStr.toString();
}

private List<FieldDefinition> fields(List<VariableElement> fields) {
List<FieldDefinition> definitions = new ArrayList<>();
for (VariableElement field : fields)
definitions.add(new FieldDefinition(field.getSimpleName().toString(), field.asType()));
for (VariableElement field : fields) {
definitions.add(new FieldDefinition(field.getSimpleName().toString(), field.getAnnotationMirrors(),
field.asType()));
}
return definitions;
}

Expand All @@ -103,15 +107,18 @@ private List<ConstructorDefinition> constructors(Element element) {
}

private String outerType(Element enclosingElement) {
String enclosedIn = null;
StringBuilder enclosedIn = null;
while (!enclosingElement.getKind().equals(ElementKind.PACKAGE)) {
if (enclosedIn == null)
enclosedIn = enclosingElement.getSimpleName().toString();
else
enclosedIn += String.format(".%s", enclosingElement.getSimpleName());
if (enclosedIn == null) {
enclosedIn = Optional.ofNullable(enclosingElement.getSimpleName().toString())
.map(StringBuilder::new)
.orElse(null);
} else {
enclosedIn.append(String.format(".%s", enclosingElement.getSimpleName()));
}
enclosingElement = enclosingElement.getEnclosingElement();
}
return enclosedIn;
return enclosedIn == null ? null : enclosedIn.toString();
}

private String typeName() {
Expand Down
39 changes: 39 additions & 0 deletions processor/src/test/java/io/jonasg/bob/BobFeaturesTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,43 @@ void constructorParametersAreEnforcedWhenConstructorPolicyIsEnforced() {
.executeTest();
}

@Test
void markThroughTopLevelAnnotationThatIndividualFieldsAsMandatoryWhenInPermissiveMode() {
Cute.blackBoxTest()
.given()
.processors(List.of(BuildableProcessor.class))
.andSourceFiles(
"/tests/successful-compilation/MarkIndividualFieldsAsMandatory/MarkThroughTopLevelAnnotationThatIndividualFieldsAreMandatoryWhenInPermissiveMode.java")
.whenCompiled()
.thenExpectThat()
.compilationSucceeds()
.andThat()
.generatedSourceFile(
"io.jonasg.bob.test.builder.MarkThroughTopLevelAnnotationThatIndividualFieldsAreMandatoryWhenInPermissiveModeBuilder")
.matches(
CuteApi.ExpectedFileObjectMatcherKind.BINARY,
JavaFileObjectUtils.readFromResource(
"/tests/successful-compilation/MarkIndividualFieldsAsMandatory/Expected_MarkThroughTopLevelAnnotationThatIndividualFieldsAreMandatoryWhenInPermissiveMode.java"))
.executeTest();
}

@Test
void markFieldAnnotationThatIndividualFieldsAreMandatoryWhenInPermissiveMode() {
Cute.blackBoxTest()
.given()
.processors(List.of(BuildableProcessor.class))
.andSourceFiles(
"/tests/successful-compilation/MarkIndividualFieldsAsMandatory/MarkFieldAnnotationThatIndividualFieldsAreMandatoryWhenInPermissiveMode.java")
.whenCompiled()
.thenExpectThat()
.compilationSucceeds()
.andThat()
.generatedSourceFile(
"io.jonasg.bob.test.builder.MarkFieldAnnotationThatIndividualFieldsAreMandatoryWhenInPermissiveModeBuilder")
.matches(
CuteApi.ExpectedFileObjectMatcherKind.BINARY,
JavaFileObjectUtils.readFromResource(
"/tests/successful-compilation/MarkIndividualFieldsAsMandatory/Expected_MarkFieldAnnotationThatIndividualFieldsAreMandatoryWhenInPermissiveMode.java"))
.executeTest();
}
}
Loading

0 comments on commit a208f7d

Please sign in to comment.