From 60e9f35b83fa9f38076c80f1c158b057f6e5a8b3 Mon Sep 17 00:00:00 2001 From: Jordan Zimmerman Date: Mon, 1 Jan 2024 13:03:59 +0000 Subject: [PATCH] Support staged builder Closes #159 New option to have standard builder, staged builder or both --- .../recordbuilder/core/RecordBuilder.java | 26 +++- .../recordbuilder/processor/ElementUtils.java | 9 ++ .../InternalRecordBuilderProcessor.java | 141 ++++++++++++++++-- .../test/staged/CombinedGenericStaged.java | 23 +++ .../test/staged/CombinedSimpleStaged.java | 25 ++++ .../test/staged/GenericStaged.java | 23 +++ .../test/staged/SimpleStaged.java | 25 ++++ .../test/staged/TestStagedBuilder.java | 64 ++++++++ 8 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedGenericStaged.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedSimpleStaged.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/GenericStaged.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/SimpleStaged.java create mode 100644 record-builder-test/src/test/java/io/soabase/recordbuilder/test/staged/TestStagedBuilder.java diff --git a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java index 41b575f8..ce56f916 100644 --- a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java +++ b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java @@ -15,13 +15,8 @@ */ package io.soabase.recordbuilder.core; -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - import javax.lang.model.element.Modifier; +import java.lang.annotation.*; @Retention(RetentionPolicy.SOURCE) @Target(ElementType.TYPE) @@ -311,6 +306,21 @@ * Makes the generated builder's constructors public */ boolean publicBuilderConstructors() default false; + + /** + * Whether to add standard builder, staged builder or both + */ + BuilderMode builderMode() default BuilderMode.STANDARD; + + /** + * The name to use for the staged builder if present + */ + String stagedBuilderMethodName() default "stagedBuilder"; + + /** + * The suffix to use for the staged builder interfaces if present + */ + String stagedBuilderMethodSuffix() default "Stage"; } @Retention(RetentionPolicy.CLASS) @@ -321,4 +331,8 @@ boolean asRecordInterface() default false; } + + enum BuilderMode { + STANDARD, STAGED, STANDARD_AND_STAGED, + } } diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java index 0aec3c8e..535cba29 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java @@ -111,6 +111,15 @@ public static ClassType getClassType(ClassName builderClassName, return new ClassType(ParameterizedTypeName.get(builderClassName, typeNames), builderClassName.simpleName()); } + public static ClassType getClassTypeFromNames(ClassName builderClassName, + List typeVariableNames) { + if (typeVariableNames.isEmpty()) { + return new ClassType(builderClassName, builderClassName.simpleName()); + } + TypeName[] typeNames = typeVariableNames.toArray(TypeName[]::new); + return new ClassType(ParameterizedTypeName.get(builderClassName, typeNames), builderClassName.simpleName()); + } + public static RecordClassType getRecordClassType(ProcessingEnvironment processingEnv, RecordComponentElement recordComponent, List accessorAnnotations, List canonicalConstructorAnnotations) { diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java index 375af12b..ca47feab 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java @@ -17,6 +17,8 @@ import com.squareup.javapoet.*; import io.soabase.recordbuilder.core.RecordBuilder; +import io.soabase.recordbuilder.core.RecordBuilder.BuilderMode; +import io.soabase.recordbuilder.processor.CollectionBuilderUtils.SingleItemsMetaData; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.*; @@ -31,8 +33,7 @@ import static io.soabase.recordbuilder.processor.CollectionBuilderUtils.SingleItemsMetaDataMode.EXCLUDE_WILDCARD_TYPES; import static io.soabase.recordbuilder.processor.CollectionBuilderUtils.SingleItemsMetaDataMode.STANDARD_FOR_SETTER; -import static io.soabase.recordbuilder.processor.ElementUtils.getBuilderName; -import static io.soabase.recordbuilder.processor.ElementUtils.getWithMethodName; +import static io.soabase.recordbuilder.processor.ElementUtils.*; import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.generatedRecordBuilderAnnotation; import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.recordBuilderGeneratedAnnotation; @@ -85,6 +86,11 @@ class InternalRecordBuilderProcessor { if (!metaData.beanClassName().isEmpty()) { addBeanNestedClass(); } + if (metaData.builderMode() != BuilderMode.STANDARD) { + addStagedBuilderClasses(); + addStaticStagedBuilderMethod((metaData.builderMode() == BuilderMode.STANDARD_AND_STAGED) + ? metaData.stagedBuilderMethodName() : metaData.builderMethodName()); + } addDefaultConstructor(); if (metaData.addStaticBuilder()) { addStaticBuilder(); @@ -92,7 +98,9 @@ class InternalRecordBuilderProcessor { if (recordComponents.size() > 0) { addAllArgsConstructor(); } - addStaticDefaultBuilderMethod(); + if (metaData.builderMode() != BuilderMode.STAGED) { + addStaticDefaultBuilderMethod(); + } addStaticCopyBuilderMethod(); if (metaData.enableWither()) { addStaticFromWithMethod(); @@ -136,7 +144,7 @@ private void addVisibility(boolean builderIsInRecordPackage, Set modif if (modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.PRIVATE) || modifiers.contains(Modifier.PROTECTED)) { builder.addModifiers(Modifier.PUBLIC); // builders are top level classes - can only be public or - // package-private + // package-private } // is package-private } else { @@ -162,6 +170,76 @@ private List buildRecordComponents(TypeElement record) { }).collect(Collectors.toList()); } + public void addStagedBuilderClasses() { + if (recordComponents.size() < 2) { + return; + } + + IntStream.range(0, recordComponents.size()).forEach(index -> { + Optional nextComponent = ((index + 1) < recordComponents.size()) + ? Optional.of(recordComponents.get(index + 1)) : Optional.empty(); + add1StagedBuilderClass(recordComponents.get(index), nextComponent); + }); + + /* + * Adds the final builder stage that has the "build" methods similar to: + * + * public class BuilderStage { PersonBuilder builder(); + * + * default Person build() { return builder().build(); } + */ + var classBuilder = TypeSpec.interfaceBuilder(stagedBuilderName(builderClassType)) + .addAnnotation(generatedRecordBuilderAnnotation) + .addJavadoc("Add final staged builder to {@code $L}\n", recordClassType.name()) + .addModifiers(Modifier.PUBLIC).addTypeVariables(typeVariables); + if (metaData.addClassRetainedGenerated()) { + classBuilder.addAnnotation(recordBuilderGeneratedAnnotation); + } + + MethodSpec buildMethod = buildMethod().addModifiers(Modifier.PUBLIC, Modifier.DEFAULT) + .addStatement("return builder().build()").build(); + classBuilder.addMethod(buildMethod); + + var builderMethod = MethodSpec.methodBuilder(metaData.builderMethodName()) + .addJavadoc("Return a new builder with all fields set to the current values in this builder\n") + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT).addAnnotation(generatedRecordBuilderAnnotation) + .returns(builderClassType.typeName()).build(); + classBuilder.addMethod(builderMethod); + + builder.addType(classBuilder.build()); + } + + public void add1StagedBuilderClass(RecordClassType component, Optional nextComponent) { + /* + * Adds a nested interface similar to: + * + * public class NameStage { AgeStage name(String name); } + */ + var classBuilder = TypeSpec.interfaceBuilder(stagedBuilderName(component)) + .addAnnotation(generatedRecordBuilderAnnotation) + .addJavadoc("Add staged builder to {@code $L} for component {@code $L}\n", recordClassType.name(), + component.name()) + .addModifiers(Modifier.PUBLIC).addTypeVariables(typeVariables); + if (metaData.addClassRetainedGenerated()) { + classBuilder.addAnnotation(recordBuilderGeneratedAnnotation); + } + + var returnType = nextComponent.map(this::stagedBuilderType) + .orElseGet(() -> stagedBuilderType(builderClassType)); + var methodSpec = MethodSpec.methodBuilder(prefixedName(component, false)) + .addAnnotation(generatedRecordBuilderAnnotation).returns(returnType.typeName()) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT); + + methodSpec.addJavadoc("Set a new value for the {@code $L} record component in the builder\n", component.name()); + var parameterSpecBuilder = ParameterSpec.builder(component.typeName(), component.name()); + addConstructorAnnotations(component, parameterSpecBuilder); + methodSpec.addParameter(parameterSpecBuilder.build()); + + classBuilder.addMethod(methodSpec.build()); + + builder.addType(classBuilder.build()); + } + private void addWithNestedClass() { /* * Adds a nested interface that adds withers similar to: @@ -470,11 +548,15 @@ private void addBuildMethod() { * public MyRecord build() { return new MyRecord(p1, p2, ...); } */ CodeBlock codeBlock = buildCodeBlock(); - var methodSpec = MethodSpec.methodBuilder(metaData.buildMethodName()) + MethodSpec methodSpec = buildMethod().addCode(codeBlock).build(); + builder.addMethod(methodSpec); + } + + private MethodSpec.Builder buildMethod() { + return MethodSpec.methodBuilder(metaData.buildMethodName()) .addJavadoc("Return a new record instance with all fields set to the current values in this builder\n") .addModifiers(Modifier.PUBLIC).addAnnotation(generatedRecordBuilderAnnotation) - .returns(recordClassType.typeName()).addCode(codeBlock).build(); - builder.addMethod(methodSpec); + .returns(recordClassType.typeName()); } private CodeBlock buildCodeBlock() { @@ -603,7 +685,7 @@ private void addStaticCopyBuilderMethod() { private void addStaticDefaultBuilderMethod() { /* - * Adds a the default builder method similar to: + * Adds the default builder method similar to: * * public static MyRecordBuilder builder() { return new MyRecordBuilder(); } */ @@ -615,6 +697,35 @@ private void addStaticDefaultBuilderMethod() { builder.addMethod(methodSpec); } + private void addStaticStagedBuilderMethod(String builderMethodName) { + if (recordComponents.size() < 2) { + return; + } + + /* + * Adds the staged builder method similar to: + * + * public static NameStage stagedBuilder() { return name -> age -> () -> new PersonBuilder(name, age).build(); } + */ + CodeBlock.Builder codeBlock = CodeBlock.builder().add("return "); + recordComponents.forEach(recordComponent -> codeBlock.add("$L -> ", recordComponent.name())); + codeBlock.add("() -> new $T(", builderClassType.typeName()); + IntStream.range(0, recordComponents.size()).forEach(index -> { + if (index > 0) { + codeBlock.add(", "); + } + codeBlock.add("$L", recordComponents.get(index).name()); + }); + codeBlock.addStatement(")"); + + var methodSpec = MethodSpec.methodBuilder(builderMethodName) + .addJavadoc("Return the first stage of a staged builder\n") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC).addAnnotation(generatedRecordBuilderAnnotation) + .addTypeVariables(typeVariables).returns(stagedBuilderType(recordComponents.get(0)).typeName()) + .addCode(codeBlock.build()).build(); + builder.addMethod(methodSpec); + } + private void addStaticComponentsMethod() { /* * Adds a static method that converts a record instance into a stream of its component parts @@ -701,7 +812,7 @@ private String capitalize(String s) { return (s.length() < 2) ? s.toUpperCase(Locale.ROOT) : (Character.toUpperCase(s.charAt(0)) + s.substring(1)); } - private void add1CollectionBuilders(CollectionBuilderUtils.SingleItemsMetaData meta, RecordClassType component) { + private void add1CollectionBuilders(SingleItemsMetaData meta, RecordClassType component) { if (collectionBuilderUtils.isList(component) || collectionBuilderUtils.isSet(component)) { add1ListBuilder(meta, component); } else if (collectionBuilderUtils.isMap(component)) { @@ -709,7 +820,7 @@ private void add1CollectionBuilders(CollectionBuilderUtils.SingleItemsMetaData m } } - private void add1MapBuilder(CollectionBuilderUtils.SingleItemsMetaData meta, RecordClassType component) { + private void add1MapBuilder(SingleItemsMetaData meta, RecordClassType component) { /* * For a single map record component, add a methods similar to: * @@ -757,7 +868,7 @@ private void add1MapBuilder(CollectionBuilderUtils.SingleItemsMetaData meta, Rec } } - private void add1ListBuilder(CollectionBuilderUtils.SingleItemsMetaData meta, RecordClassType component) { + private void add1ListBuilder(SingleItemsMetaData meta, RecordClassType component) { /* * For a single list or set record component, add methods similar to: * @@ -955,4 +1066,12 @@ private String prefixedName(RecordClassType component, boolean isGetter) { } return prefixer.apply(metaData.setterPrefix(), component.name()); } + + private String stagedBuilderName(ClassType component) { + return capitalize(component.name()) + metaData.stagedBuilderMethodSuffix(); + } + + private ClassType stagedBuilderType(ClassType component) { + return getClassTypeFromNames(ClassName.get("", stagedBuilderName(component)), typeVariables); + } } diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedGenericStaged.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedGenericStaged.java new file mode 100644 index 00000000..9c4c6271 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedGenericStaged.java @@ -0,0 +1,23 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test.staged; + +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +@RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STANDARD_AND_STAGED) +public record CombinedGenericStaged, U>(String name, T aT, U theUThing) { +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedSimpleStaged.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedSimpleStaged.java new file mode 100644 index 00000000..a9ecb7c2 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/CombinedSimpleStaged.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test.staged; + +import io.soabase.recordbuilder.core.RecordBuilder; + +import java.time.Instant; + +@RecordBuilder +@RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STANDARD_AND_STAGED) +public record CombinedSimpleStaged(int i, String s, Instant instant) { +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/GenericStaged.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/GenericStaged.java new file mode 100644 index 00000000..8785ad6d --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/GenericStaged.java @@ -0,0 +1,23 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test.staged; + +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +@RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STAGED) +public record GenericStaged(String name, T aT, U theUThing) { +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/SimpleStaged.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/SimpleStaged.java new file mode 100644 index 00000000..f0f35353 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/staged/SimpleStaged.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test.staged; + +import io.soabase.recordbuilder.core.RecordBuilder; + +import java.time.Instant; + +@RecordBuilder +@RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STAGED) +public record SimpleStaged(int i, String s, Instant instant) { +} diff --git a/record-builder-test/src/test/java/io/soabase/recordbuilder/test/staged/TestStagedBuilder.java b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/staged/TestStagedBuilder.java new file mode 100644 index 00000000..7554ae27 --- /dev/null +++ b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/staged/TestStagedBuilder.java @@ -0,0 +1,64 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test.staged; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TestStagedBuilder { + @Test + void testSimple() { + var now = Instant.now(); + var obj = SimpleStagedBuilder.builder().i(1).s("s").instant(now).build(); + assertEquals(new SimpleStaged(1, "s", now), obj); + } + + @Test + void testSimpleCombined() { + var now = Instant.now(); + var obj1 = CombinedSimpleStagedBuilder.builder().i(1).s("s").instant(now).build(); + var obj2 = CombinedSimpleStagedBuilder.stagedBuilder().i(1).s("s").instant(now).build(); + assertEquals(obj1, obj2); + } + + @Test + void testGeneric() { + var now = Instant.now(); + var obj = SimpleStagedBuilder.builder().i(1).s("s").instant(now).build(); + + GenericStaged generic = GenericStagedBuilder. builder().name("name") + .aT(obj).theUThing("thing").build(); + + assertEquals(new GenericStaged<>("name", new SimpleStaged(1, "s", now), "thing"), generic); + } + + @Test + void testGenericCombined() { + var now = Instant.now(); + var builder = SimpleStagedBuilder.builder().i(1).s("s").instant(now).builder(); + + var obj1 = CombinedGenericStagedBuilder.builder().name("name") + .aT(new GenericStaged<>("other", builder.build(), BigInteger.TEN)).theUThing(BigDecimal.ONE).build(); + var obj2 = CombinedGenericStagedBuilder.stagedBuilder().name("name") + .aT(new GenericStaged<>("other", builder.build(), BigInteger.TEN)).theUThing(BigDecimal.ONE).build(); + assertEquals(obj1, obj2); + } +}