Skip to content

Commit 5e372a2

Browse files
committed
Alternate staged builders that handle optional, etc.
A variant of staged builders is available that only stages required record components. Any optional components (when `addConcreteSettersForOptional` is enabled) are not staged and are added to the final stage. Additionally, if Collection options are enabled, those too are added to the final stage. Closes #170
1 parent 68c3ca4 commit 5e372a2

File tree

5 files changed

+130
-39
lines changed

5 files changed

+130
-39
lines changed

options.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ Use `@RecordBuilder.Options(builderMode = BuilderMode.STAGED)` or `@RecordBuilde
7474
builders. Staged builders require that each record component is built in order and that each component is specified. The generated builder ensures
7575
this via individual staged builders. See [TestStagedBuilder](record-builder-test/src/test/java/io/soabase/recordbuilder/test/staged/TestStagedBuilder.java) for examples.
7676

77+
A variant of staged builders is available that only stages required record components. Use `BuilderMode.STAGED_REQUIRED_ONLY` or `BuilderMode.STANDARD_AND_STAGED_REQUIRED_ONLY`.
78+
The following are not staged and are added to the final stage:
79+
- optional components (when `addConcreteSettersForOptional` is enabled)
80+
- Any collections matching enabled [Collection options](#collections)
81+
- Any annotated compontents that match the `nullablePattern()` pattern option (e.g. `@Nullable`)
82+
7783
## Default Values / Initializers
7884

7985
| option | details |
@@ -88,11 +94,12 @@ for an example.
8894

8995
## Null Handling
9096

91-
| option | details |
92-
|-----------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
93-
| `@RecordBuilder.Options(interpretNotNulls = true/false)` | Add not-null checks for record components annotated with any null-pattern annotation. The default is `false`. |
94-
| `@RecordBuilder.Options(interpretNotNullsPattern = "regex")` | The regex pattern used to determine if an annotation name means non-null. |
95-
| `@RecordBuilder.Options(allowNullableCollections = true/false)` | Adds special null handling for record collectioncomponents. The default is `false`. |
97+
| option | details |
98+
|-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|
99+
| `@RecordBuilder.Options(interpretNotNulls = true/false)` | Add not-null checks for record components annotated with any null-pattern annotation. The default is `false`. |
100+
| `@RecordBuilder.Options(interpretNotNullsPattern = "regex")` | The regex pattern used to determine if an annotation name means non-null. |
101+
| `@RecordBuilder.Options(allowNullableCollections = true/false)` | Adds special null handling for record collectioncomponents. The default is `false`. |
102+
| `@RecordBuilder.Options(nullablePattern = "regex")` | Regex pattern to use for `BuilderMode.STAGED_REQUIRED_ONLY` and `BuilderMode.STANDARD_AND_STAGED_REQUIRED_ONLY`. |
96103

97104
## Collections
98105

record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,12 @@
322322
*/
323323
String stagedBuilderMethodSuffix() default "Stage";
324324

325+
/**
326+
* If {@link #builderMode()} is `STAGED_REQUIRED_ONLY` or `STANDARD_AND_STAGED_REQUIRED_ONLY, this is the regex
327+
* pattern used to determine if an annotation name means "null-able"
328+
*/
329+
String nullablePattern() default "(?i)^((null)|(nullable))$";
330+
325331
/**
326332
* If true, attributes can be set/assigned only 1 time. Attempts to reassign/reset attributes will throw
327333
* {@code java.lang.IllegalStateException}
@@ -345,7 +351,7 @@
345351
}
346352

347353
enum BuilderMode {
348-
STANDARD, STAGED, STANDARD_AND_STAGED,
354+
STANDARD, STAGED, STAGED_REQUIRED_ONLY, STANDARD_AND_STAGED, STANDARD_AND_STAGED_REQUIRED_ONLY,
349355
}
350356

351357
/**

record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ class InternalRecordBuilderProcessor {
4848
private final TypeSpec.Builder builder;
4949
private final String uniqueVarName;
5050
private final Pattern notNullPattern;
51+
private final Pattern nullablePattern;
5152
private final CollectionBuilderUtils collectionBuilderUtils;
5253

54+
private static final TypeName optionalType = TypeName.get(Optional.class);
5355
private static final TypeName overrideType = TypeName.get(Override.class);
5456
private static final TypeName validType = ClassName.get("javax.validation", "Valid");
5557
private static final TypeName validatorTypeName = ClassName.get("io.soabase.recordbuilder.validator",
@@ -72,6 +74,7 @@ class InternalRecordBuilderProcessor {
7274
recordComponents = buildRecordComponents(record);
7375
uniqueVarName = getUniqueVarName();
7476
notNullPattern = Pattern.compile(metaData.interpretNotNullsPattern());
77+
nullablePattern = Pattern.compile(metaData.nullablePattern());
7578
collectionBuilderUtils = new CollectionBuilderUtils(recordComponents, this.metaData);
7679
constructorVisibilityModifier = metaData.publicBuilderConstructors() ? Modifier.PUBLIC : Modifier.PRIVATE;
7780
initializers = InitializerUtil.detectInitializers(processingEnv, record);
@@ -90,8 +93,9 @@ class InternalRecordBuilderProcessor {
9093
}
9194
if (metaData.builderMode() != BuilderMode.STANDARD) {
9295
addStagedBuilderClasses();
93-
addStaticStagedBuilderMethod((metaData.builderMode() == BuilderMode.STANDARD_AND_STAGED)
94-
? metaData.stagedBuilderMethodName() : metaData.builderMethodName());
96+
addStaticStagedBuilderMethod(((metaData.builderMode() == BuilderMode.STANDARD_AND_STAGED)
97+
|| (metaData.builderMode() == BuilderMode.STANDARD_AND_STAGED_REQUIRED_ONLY))
98+
? metaData.stagedBuilderMethodName() : metaData.builderMethodName());
9599
}
96100
addDefaultConstructor();
97101
if (metaData.addStaticBuilder()) {
@@ -100,7 +104,8 @@ class InternalRecordBuilderProcessor {
100104
if (recordComponents.size() > 0) {
101105
addAllArgsConstructor();
102106
}
103-
if (metaData.builderMode() != BuilderMode.STAGED) {
107+
if ((metaData.builderMode() != BuilderMode.STAGED)
108+
&& (metaData.builderMode() != BuilderMode.STAGED_REQUIRED_ONLY)) {
104109
addStaticDefaultBuilderMethod();
105110
}
106111
addStaticCopyBuilderMethod();
@@ -195,11 +200,32 @@ private void addOnceOnlySupport() {
195200
builder.addField(onceOnlyField);
196201
}
197202

203+
private boolean isRequiredStage(RecordClassType recordComponent) {
204+
if ((metaData.builderMode() != BuilderMode.STAGED_REQUIRED_ONLY)
205+
&& (metaData.builderMode() != BuilderMode.STANDARD_AND_STAGED_REQUIRED_ONLY)) {
206+
return true;
207+
}
208+
209+
if (collectionBuilderUtils.isNullableCollection(recordComponent)
210+
|| collectionBuilderUtils.isImmutableCollection(recordComponent)) {
211+
return false;
212+
}
213+
214+
if (isNullableAnnotated(recordComponent)) {
215+
return false;
216+
}
217+
218+
return !metaData.emptyDefaultForOptional() || !recordComponent.rawTypeName().equals(optionalType);
219+
}
220+
198221
private void addStagedBuilderClasses() {
199-
IntStream.range(0, recordComponents.size()).forEach(index -> {
200-
Optional<RecordClassType> nextComponent = ((index + 1) < recordComponents.size())
201-
? Optional.of(recordComponents.get(index + 1)) : Optional.empty();
202-
add1StagedBuilderClass(recordComponents.get(index), nextComponent);
222+
List<RecordClassType> filteredRecordComponents = recordComponents.stream().filter(this::isRequiredStage)
223+
.toList();
224+
225+
IntStream.range(0, filteredRecordComponents.size()).forEach(index -> {
226+
Optional<RecordClassType> nextComponent = ((index + 1) < filteredRecordComponents.size())
227+
? Optional.of(filteredRecordComponents.get(index + 1)) : Optional.empty();
228+
add1StagedBuilderClass(filteredRecordComponents.get(index), nextComponent);
203229
});
204230

205231
/*
@@ -218,9 +244,25 @@ private void addStagedBuilderClasses() {
218244
}
219245

220246
MethodSpec buildMethod = buildMethod().addModifiers(Modifier.PUBLIC, Modifier.DEFAULT)
221-
.addStatement("return builder().build()").build();
247+
.addStatement("return $L().$L()", metaData.builderMethodName(), metaData.buildMethodName()).build();
222248
classBuilder.addMethod(buildMethod);
223249

250+
recordComponents.stream().filter(recordComponent -> !isRequiredStage(recordComponent))
251+
.forEach(optionalComponent -> {
252+
var codeBlock = CodeBlock.builder().add("return $L().$L($L);", metaData.builderMethodName(),
253+
optionalComponent.name(), optionalComponent.name()).build();
254+
255+
var parameterSpecBuilder = ParameterSpec.builder(optionalComponent.typeName(),
256+
optionalComponent.name());
257+
addConstructorAnnotations(optionalComponent, parameterSpecBuilder);
258+
var methodSpec = MethodSpec.methodBuilder(optionalComponent.name())
259+
.addAnnotation(generatedRecordBuilderAnnotation)
260+
.addJavadoc("Call builder for optional component {@code $L}", optionalComponent.name())
261+
.addModifiers(Modifier.PUBLIC, Modifier.DEFAULT).addParameter(parameterSpecBuilder.build())
262+
.addCode(codeBlock).returns(builderClassType.typeName()).build();
263+
classBuilder.addMethod(methodSpec);
264+
});
265+
224266
var builderMethod = MethodSpec.methodBuilder(metaData.builderMethodName())
225267
.addJavadoc("Return a new builder with all fields set to the current values in this builder\n")
226268
.addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT).addAnnotation(generatedRecordBuilderAnnotation)
@@ -460,19 +502,24 @@ private void addNullCheckCodeBlock(CodeBlock.Builder builder, int index) {
460502
if (metaData.interpretNotNulls()) {
461503
var component = recordComponents.get(index);
462504
if (!collectionBuilderUtils.isImmutableCollection(component)) {
463-
if (!component.typeName().isPrimitive() && isNullAnnotated(component)) {
505+
if (!component.typeName().isPrimitive() && isNotNullAnnotated(component)) {
464506
builder.addStatement("$T.requireNonNull($L, $S)", Objects.class, component.name(),
465507
component.name() + " is required");
466508
}
467509
}
468510
}
469511
}
470512

471-
private boolean isNullAnnotated(RecordClassType component) {
513+
private boolean isNotNullAnnotated(RecordClassType component) {
472514
return component.getCanonicalConstructorAnnotations().stream().anyMatch(annotation -> notNullPattern
473515
.matcher(annotation.getAnnotationType().asElement().getSimpleName().toString()).matches());
474516
}
475517

518+
private boolean isNullableAnnotated(RecordClassType component) {
519+
return component.getCanonicalConstructorAnnotations().stream().anyMatch(annotation -> nullablePattern
520+
.matcher(annotation.getAnnotationType().asElement().getSimpleName().toString()).matches());
521+
}
522+
476523
private void addAllArgsConstructor() {
477524
/*
478525
* Adds an all-args constructor similar to:
@@ -724,30 +771,21 @@ private void addStaticStagedBuilderMethod(String builderMethodName) {
724771
*
725772
* public static NameStage stagedBuilder() { return name -> age -> () -> new PersonBuilder(name, age).build(); }
726773
*/
774+
775+
List<RecordClassType> filteredRecordComponents = recordComponents.stream().filter(this::isRequiredStage)
776+
.toList();
777+
727778
var codeBlock = CodeBlock.builder();
728-
if (metaData.onceOnlyAssignment()) {
729-
codeBlock.addStatement("$T $L = new $T()", builderClassType.typeName(), uniqueVarName,
730-
builderClassType.typeName());
731-
codeBlock.add("return ");
732-
recordComponents.forEach(recordComponent -> {
733-
codeBlock.add("$L -> {\n", recordComponent.name()).indent()
734-
.addStatement("$L.$L($L)", uniqueVarName, recordComponent.name(), recordComponent.name())
735-
.add("return ");
736-
});
737-
codeBlock.addStatement("() -> $L", uniqueVarName);
738-
IntStream.range(0, recordComponents.size()).forEach(__ -> codeBlock.unindent().addStatement("}"));
739-
} else {
740-
codeBlock.add("return ");
741-
recordComponents.forEach(recordComponent -> codeBlock.add("$L -> ", recordComponent.name()));
742-
codeBlock.add("() -> new $T(", builderClassType.typeName());
743-
IntStream.range(0, recordComponents.size()).forEach(index -> {
744-
if (index > 0) {
745-
codeBlock.add(", ");
746-
}
747-
codeBlock.add("$L", recordComponents.get(index).name());
748-
});
749-
codeBlock.addStatement(")");
750-
}
779+
codeBlock.addStatement("$T $L = new $T()", builderClassType.typeName(), uniqueVarName,
780+
builderClassType.typeName());
781+
codeBlock.add("return ");
782+
filteredRecordComponents.forEach(recordComponent -> {
783+
codeBlock.add("$L -> {\n", recordComponent.name()).indent()
784+
.addStatement("$L.$L($L)", uniqueVarName, recordComponent.name(), recordComponent.name())
785+
.add("return ");
786+
});
787+
codeBlock.addStatement("() -> $L", uniqueVarName);
788+
IntStream.range(0, filteredRecordComponents.size()).forEach(__ -> codeBlock.unindent().addStatement("}"));
751789

752790
var returnType = stagedBuilderType(recordComponents.isEmpty() ? builderClassType : recordComponents.get(0));
753791

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2019 The original author or authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.soabase.recordbuilder.test.staged;
17+
18+
import io.soabase.recordbuilder.core.RecordBuilder;
19+
20+
import javax.validation.constraints.Null;
21+
import java.time.Instant;
22+
import java.util.List;
23+
import java.util.Optional;
24+
25+
@RecordBuilder
26+
@RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STAGED_REQUIRED_ONLY, interpretNotNulls = true, useImmutableCollections = true)
27+
public record OptionalListStaged(int a, Optional<String> b, double c, List<Instant> d, @Null String e, String f) {
28+
}

record-builder-test/src/test/java/io/soabase/recordbuilder/test/staged/TestStagedBuilder.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import java.math.BigDecimal;
2121
import java.math.BigInteger;
2222
import java.time.Instant;
23+
import java.util.List;
24+
import java.util.Optional;
2325

2426
import static org.junit.jupiter.api.Assertions.assertEquals;
2527

@@ -73,4 +75,14 @@ void testNoFields() {
7375
NoFieldsStaged obj = NoFieldsStagedBuilder.builder().build();
7476
assertEquals(new NoFieldsStaged(), obj);
7577
}
78+
79+
@Test
80+
void testOptionalList() {
81+
OptionalListStaged obj = OptionalListStagedBuilder.builder().a(1).c(1.1).f("ffff").b(Optional.of("bbbb"))
82+
.d(List.of(Instant.EPOCH)).e("eeee").build();
83+
assertEquals(new OptionalListStaged(1, Optional.of("bbbb"), 1.1, List.of(Instant.EPOCH), "eeee", "ffff"), obj);
84+
85+
obj = OptionalListStagedBuilder.builder().a(1).c(1.1).f("ffff").build();
86+
assertEquals(new OptionalListStaged(1, Optional.empty(), 1.1, List.of(), null, "ffff"), obj);
87+
}
7688
}

0 commit comments

Comments
 (0)