Skip to content

Commit

Permalink
feat: allow null values for concstructor enforced builders
Browse files Browse the repository at this point in the history
  • Loading branch information
JoranVanBelle authored and jonas-grgt committed Apr 21, 2024
1 parent 29350c0 commit bc9240f
Show file tree
Hide file tree
Showing 14 changed files with 351 additions and 63 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ preventing issues that arise from improperly constructed objects.
public class Car {
```

Another `ConstructorPolicy` that also enforces all the parameters to be set, but less strictly is
`ENFORCED_ALLOW_NULLS`. By using this policy, you enforce all variables to be set,
but you can also set them to null, which is not allowed when using `ENFORCED`.

```java
@Buildable(constructorPolicy = ENFORCED_ALLOW_NULLS)
public class Car {
```

### Mandatory Fields

Fields can be designated as mandatory;
Expand Down
6 changes: 5 additions & 1 deletion annotations/src/main/java/io/jonasg/bob/Buildable.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@
* fields. Alternatively,
* setting this to {@link ConstructorPolicy#ENFORCED} requires all fields to be
* explicitly set, otherwise,
* the builder throws an exception.
* the builder throws an exception. <br/>
* Setting it to {@link ConstructorPolicy#ENFORCED_ALLOW_NULLS} requires all
* fields
* to be explicitly set, otherwise the builder throws an exception. But by using
* this policy, null can also be set.
* </p>
*
* @return the constructor policy used by the builder
Expand Down
11 changes: 11 additions & 0 deletions annotations/src/main/java/io/jonasg/bob/ConstructorPolicy.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ public enum ConstructorPolicy {
*/
ENFORCED,

/**
* Requires all fields
* to be explicitly set with a concrete value or {@code null} in the
* constructor.
* If any field is not set, the builder will throw an exception.
* The main difference with {@link ConstructorPolicy#ENFORCED} is that fields
* are considered
* to be set to, even if set explicitly to {@code null}
*/
ENFORCED_ALLOW_NULLS,

/**
* Allows the object to be constructed even if not all constructor parameters
* have
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.jonasg.bob;

/**
* Container Object for a required field and its value that cannot be set as
* null
*
* @param <T>
* the type of the required field its value
*/
@SuppressWarnings("unused")
public final class NotNullableRequiredField<T> implements RequiredField<T> {

private T fieldValue;

private final String fieldName;

private final String typeName;

NotNullableRequiredField(T fieldValue, String fieldName, String typeName) {
this.fieldValue = fieldValue;
this.fieldName = fieldName;
this.typeName = typeName;
}

@Override
public void set(T value) {
this.fieldValue = value;
}

@Override
public T orElseThrow() {
if (fieldValue == null) {
throw new MandatoryFieldMissingException(fieldName, typeName);
}
return fieldValue;
}
}
39 changes: 39 additions & 0 deletions annotations/src/main/java/io/jonasg/bob/NullableRequiredField.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.jonasg.bob;

/**
* Container Object for a required field and its value that can be set as null
*
* @param <T>
* the type of the required field its value
*/
@SuppressWarnings("unused")
public class NullableRequiredField<T> implements RequiredField<T> {

private T fieldValue;

private boolean fieldSet;

private final String fieldName;

private final String typeName;

public NullableRequiredField(T fieldValue, String fieldName, String typeName) {
this.fieldValue = fieldValue;
this.fieldName = fieldName;
this.typeName = typeName;
}

@Override
public void set(T value) {
this.fieldValue = value;
this.fieldSet = true;
}

@Override
public T orElseThrow() {
if (!this.fieldSet && this.fieldValue == null) {
throw new MandatoryFieldMissingException(this.fieldName, this.typeName);
}
return this.fieldValue;
}
}
37 changes: 8 additions & 29 deletions annotations/src/main/java/io/jonasg/bob/RequiredField.java
Original file line number Diff line number Diff line change
@@ -1,38 +1,17 @@
package io.jonasg.bob;

/**
* Container Object for a required field and its value
*
* @param <T>
* the type of the required field its value
*/
@SuppressWarnings("unused")
public final class RequiredField<T> {
public interface RequiredField<T> {

private T fieldValue;

private final String fieldName;

private final String typeName;

private RequiredField(T fieldValue, String fieldName, String typeName) {
this.fieldValue = fieldValue;
this.fieldName = fieldName;
this.typeName = typeName;
static <T> RequiredField<T> notNullableOfNameWithinType(String fieldName, String typeName) {
return new NotNullableRequiredField<>(null, fieldName, typeName);
}

public static <T> RequiredField<T> ofNameWithinType(String fieldName, String typeName) {
return new RequiredField<>(null, fieldName, typeName);
static <T> RequiredField<T> nullableOfNameWithinType(String fieldName, String typeName) {
return new NullableRequiredField<>(null, fieldName, typeName);
}

public void set(T value) {
this.fieldValue = value;
}
void set(T value);

T orElseThrow();

public T orElseThrow() {
if (fieldValue == null) {
throw new MandatoryFieldMissingException(fieldName, typeName);
}
return fieldValue;
}
}
107 changes: 86 additions & 21 deletions annotations/src/test/java/io/jonasg/bob/RequiredFieldTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,101 @@

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

class RequiredFieldTest {

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

// when
ThrowingCallable whenOrElseThrowIsCalled = nameField::orElseThrow;
@Test
void throwExceptionWhenFieldValueIsNotSet() {
// given
RequiredField<String> nameField = RequiredField.notNullableOfNameWithinType("name", "Person");

// then
Assertions.assertThatThrownBy(whenOrElseThrowIsCalled)
.isInstanceOf(MandatoryFieldMissingException.class)
.hasMessage("Mandatory field (name) not set when building type (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.notNullableOfNameWithinType("name", "Person");
nameField.set("John");

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

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

// when
String value = nameField.orElseThrow();
@Test
void throwExceptionWhenNotNullableRequiredFieldSetToNull() {
// given
RequiredField<String> nameField = RequiredField.notNullableOfNameWithinType("name", "Person");
nameField.set(null);

// then
Assertions.assertThat(value)
.isEqualTo("John");
// when
ThrowingCallable whenOrElseThrowIsCalled = nameField::orElseThrow;

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

@Nested
class NullableRequiredFieldTest {

@Test
void throwExceptionWhenFieldValueIsNotSet() {
// given
RequiredField<String> nameField = RequiredField.nullableOfNameWithinType("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.nullableOfNameWithinType("name", "Person");
nameField.set("John");

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

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

@Test
void returnFieldValueWhenSetToNull() {
// given
RequiredField<String> nameField = RequiredField.nullableOfNameWithinType("name", "Person");
nameField.set(null);

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

// then
Assertions.assertThat(value)
.isEqualTo(null);
}
}
}
25 changes: 19 additions & 6 deletions processor/src/main/java/io/jonasg/bob/BuilderTypeSpecFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,12 @@ private List<MethodSpec> generateSetters() {
}

protected MethodSpec generateSetterForField(BuildableField field) {

var builder = MethodSpec.methodBuilder(setterName(field.fieldName()))
.addModifiers(Modifier.PUBLIC)
.returns(builderType())
.addParameter(TypeName.get(field.type()), field.fieldName());
if (field.isConstructorArgument() && isEnforcedConstructorPolicy() || field.isMandatory()) {
if (field.isConstructorArgument() && isAnEnforcedConstructorPolicy() || field.isMandatory()) {
builder.addStatement("this.$L.set($L)", field.fieldName(), field.fieldName());
} else {
builder.addStatement("this.$L = $L", field.fieldName(), field.fieldName());
Expand All @@ -129,24 +130,36 @@ protected MethodSpec generateSetterForField(BuildableField field) {
.build();
}

private boolean isAnEnforcedConstructorPolicy() {
return this.buildable.constructorPolicy().equals(ConstructorPolicy.ENFORCED) ||
this.buildable.constructorPolicy().equals(ConstructorPolicy.ENFORCED_ALLOW_NULLS);
}

private boolean isEnforcedConstructorPolicy() {
return this.buildable.constructorPolicy().equals(ConstructorPolicy.ENFORCED);
}

private boolean isEnforcedAllowNullsConstructorPolicy() {
return this.buildable.constructorPolicy().equals(ConstructorPolicy.ENFORCED_ALLOW_NULLS);
}

private List<FieldSpec> generateFields() {
return buildableFields.stream()
.map(this::generateField)
.toList();
}

protected FieldSpec generateField(BuildableField field) {
if ((field.isConstructorArgument() && isEnforcedConstructorPolicy()) || field.isMandatory()) {
if (field.isConstructorArgument() && isAnEnforcedConstructorPolicy() || field.isMandatory()) {
String methodName = this.buildable.constructorPolicy().equals(ConstructorPolicy.ENFORCED)
? "notNullableOfNameWithinType"
: "nullableOfNameWithinType";
return FieldSpec
.builder(ParameterizedTypeName.get(ClassName.get(RequiredField.class),
TypeName.get(boxedType(field.type()))), field.fieldName(), Modifier.PRIVATE,
Modifier.FINAL)
.initializer("$T.ofNameWithinType(\"" + field.fieldName() + "\", \""
+ this.typeDefinition.typeName() + "\")", RequiredField.class)
.initializer("$T.$L(\"" + field.fieldName() + "\", \""
+ this.typeDefinition.typeName() + "\")", RequiredField.class, methodName)
.build();
} else {
return FieldSpec.builder(TypeName.get(field.type()), field.fieldName(), Modifier.PRIVATE)
Expand Down Expand Up @@ -189,7 +202,7 @@ protected String toConstructorCallingStatement(ConstructorDefinition constructor
return constructorDefinition.parameters().stream()
.map(param -> this.buildableFields.stream().anyMatch(f -> Objects.equals(f.fieldName(), param.name()))
? String.format("%s%s", param.name(),
buildable.constructorPolicy().equals(ConstructorPolicy.ENFORCED) ? ".orElseThrow()"
isAnEnforcedConstructorPolicy() ? ".orElseThrow()"
: "")
: defaultForType(param.type()))
.collect(Collectors.joining(", "));
Expand All @@ -209,7 +222,7 @@ private void createConstructorAndSetterAwareBuildMethod(Builder builder) {
}

protected CodeBlock generateFieldAssignment(BuildableField field) {
if (field.isConstructorArgument() && isEnforcedConstructorPolicy() || field.isMandatory()) {
if (field.isConstructorArgument() && isAnEnforcedConstructorPolicy() || field.isMandatory()) {
return CodeBlock.builder()
.addStatement("instance.$L(this.$L.orElseThrow())",
setterName(field.setterMethodName().orElseThrow()), field.fieldName())
Expand Down
20 changes: 20 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,6 +259,26 @@ void constructorParametersAreEnforcedWhenConstructorPolicyIsEnforced() {
.executeTest();
}

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

@Test
void markThroughTopLevelAnnotationThatIndividualFieldsAsMandatoryWhenInPermissiveMode() {
Cute.blackBoxTest()
Expand Down
Loading

0 comments on commit bc9240f

Please sign in to comment.