From 48b499778700dca518953a4fda2a7f2a5d5f0545 Mon Sep 17 00:00:00 2001 From: Jordan Zimmerman Date: Sat, 18 Oct 2025 17:50:58 +0100 Subject: [PATCH 1/2] Add support for detected RecordBuilder components If a record component is, itself, annotated with `@RecordBuilder` or a template, adds a setter that takes a `Consumer` which accepts a builder for that component so that you can easily build/with nested records that have a builder. --- options.md | 29 ++--- .../recordbuilder/core/RecordBuilder.java | 7 ++ .../recordbuilder/core/RecordBuilderFull.java | 2 +- .../recordbuilder/processor/ElementUtils.java | 12 +- .../InternalDeconstructorProcessor.java | 10 +- .../InternalRecordBuilderProcessor.java | 112 ++++++++++++++++-- .../processor/NestedBuilder.java | 91 ++++++++++++++ .../processor/RecordBuilderProcessor.java | 4 +- .../processor/RecordClassType.java | 9 +- .../soabase/recordbuilder/test/Annotated.java | 1 + .../recordbuilder/test/CollectionCopying.java | 2 +- .../recordbuilder/test/CollectionRecord.java | 2 +- .../test/DuplicateMethodNames.java | 2 +- .../recordbuilder/test/MyTemplate.java | 2 +- .../test/WildcardSingleItems.java | 2 +- .../recordbuilder/test/nested/Address.java | 22 ++++ .../test/nested/BigOlNestedContainer.java | 27 +++++ .../recordbuilder/test/nested/CityState.java | 22 ++++ .../test/nested/ConvertRequest.java | 22 ++++ .../recordbuilder/test/nested/Employee.java | 22 ++++ .../test/nested/TestNestedBuilders.java | 42 +++++++ 21 files changed, 405 insertions(+), 39 deletions(-) create mode 100644 record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/NestedBuilder.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Address.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/BigOlNestedContainer.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/CityState.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/ConvertRequest.java create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Employee.java create mode 100644 record-builder-test/src/test/java/io/soabase/recordbuilder/test/nested/TestNestedBuilders.java diff --git a/options.md b/options.md index 9bb7689d..af939362 100644 --- a/options.md +++ b/options.md @@ -56,20 +56,21 @@ The names used for generated methods, classes, etc. can be changed via the follo ## Miscellaneous -| option | details | -|----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `@RecordBuilder.Options(inheritComponentAnnotations = true/false)` | If true, any annotations (if applicable) on record components are copied to the builder methods. The default is `true`. | -| `@RecordBuilder.Options(publicBuilderConstructors = true/false)` | Makes the generated builder's constructors public. The default is `false`. | -| `@RecordBuilder.Options(builderClassModifiers = {}})` | Any additional `javax.lang.model.element.Modifier` you wish to apply to the builder. | -| `@RecordBuilder.Options(beanClassName = "Foo")` | If set, the Builder will contain an internal interface with this name. | -| `@RecordBuilder.Options(addClassRetainedGenerated = true/false)` | If true, generated classes are annotated with `RecordBuilderGenerated`. The default is `false`. | -| `@RecordBuilder.Options(addStaticBuilder = true/false)` | If true, a functional-style builder is added so that record instances can be instantiated without `new()`. The default is `true`. | -| `@RecordBuilder.Options(inheritComponentAnnotations = true/false)` | If true, any annotations (if applicable) on record components are copied to the builder methods. The default is `true`. | -| `@RecordBuilder.Options(addConcreteSettersForOptional = )` | Add non-optional setter methods for optional record components. The default is `ConcreteSettersForOptionalMode.DISABLED`. | -| `@RecordBuilder.Options(nullableAnnotationClass = "com.foo.Nullable")` | Nullability annotation to use when RecordBuilder needs to add one. | -| `@RecordBuilder.Options(useValidationApi = true/false)` | Pass built records through the Java Validation API if it's available in the classpath. The default is `false`. | -| `@RecordBuilder.Options(builderMode = BuilderMode.XXX)` | Whether to add standard builder, staged builder or both. The default is `BuilderMode.STANDARD`. | -| `@RecordBuilder.Options(onceOnlyAssignment = true/false)` | If true, attributes can be set/assigned only 1 time. Attempts to reassign/reset attributes will throw `java.lang.IllegalStateException`. The default is `false`. | +| option | details | +|------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `@RecordBuilder.Options(inheritComponentAnnotations = true/false)` | If true, any annotations (if applicable) on record components are copied to the builder methods. The default is `true`. | +| `@RecordBuilder.Options(publicBuilderConstructors = true/false)` | Makes the generated builder's constructors public. The default is `false`. | +| `@RecordBuilder.Options(builderClassModifiers = {}})` | Any additional `javax.lang.model.element.Modifier` you wish to apply to the builder. | +| `@RecordBuilder.Options(beanClassName = "Foo")` | If set, the Builder will contain an internal interface with this name. | +| `@RecordBuilder.Options(addClassRetainedGenerated = true/false)` | If true, generated classes are annotated with `RecordBuilderGenerated`. The default is `false`. | +| `@RecordBuilder.Options(addStaticBuilder = true/false)` | If true, a functional-style builder is added so that record instances can be instantiated without `new()`. The default is `true`. | +| `@RecordBuilder.Options(inheritComponentAnnotations = true/false)` | If true, any annotations (if applicable) on record components are copied to the builder methods. The default is `true`. | +| `@RecordBuilder.Options(addConcreteSettersForOptional = )` | Add non-optional setter methods for optional record components. The default is `ConcreteSettersForOptionalMode.DISABLED`. | +| `@RecordBuilder.Options(nullableAnnotationClass = "com.foo.Nullable")` | Nullability annotation to use when RecordBuilder needs to add one. | +| `@RecordBuilder.Options(useValidationApi = true/false)` | Pass built records through the Java Validation API if it's available in the classpath. The default is `false`. | +| `@RecordBuilder.Options(builderMode = BuilderMode.XXX)` | Whether to add standard builder, staged builder or both. The default is `BuilderMode.STANDARD`. | +| `@RecordBuilder.Options(onceOnlyAssignment = true/false)` | If true, attributes can be set/assigned only 1 time. Attempts to reassign/reset attributes will throw `java.lang.IllegalStateException`. The default is `false`. | +| `@RecordBuilder.Options(detectNestedRecordBuilders = true)` | If set, detects if a component is, itself, annotated with `@RecordBuilder` and, if so, adds a setter that is a `Consumer` of a builder for that record. | ### Staged Builders 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 4583fbd1..cacdebd5 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 @@ -359,6 +359,13 @@ * @see #nullablePattern */ boolean defaultNotNull() default false; + + /** + * If true, record components that are themselves {@code @RecordBuilder} records will have builder methods that + * are nested builder consumers so that you can do, for example, + * {@code myRecord.withNestedRecord(nestedBuilder -> nestedBuilder.field1(...).field2(...))} + */ + boolean detectNestedRecordBuilders() default false; } @Retention(RetentionPolicy.CLASS) diff --git a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java index e4a7f2f9..12c88b89 100644 --- a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java +++ b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java @@ -20,7 +20,7 @@ /** * An alternate form of {@code @RecordBuilder} that has most optional features turned on */ -@RecordBuilder.Template(options = @RecordBuilder.Options(interpretNotNulls = true, useImmutableCollections = true, addSingleItemCollectionBuilders = true, addFunctionalMethodsToWith = true, addClassRetainedGenerated = true)) +@RecordBuilder.Template(options = @RecordBuilder.Options(interpretNotNulls = true, useImmutableCollections = true, addSingleItemCollectionBuilders = true, addFunctionalMethodsToWith = true, addClassRetainedGenerated = true, detectNestedRecordBuilders = true)) @Retention(RetentionPolicy.SOURCE) @Target({ ElementType.TYPE, ElementType.METHOD }) @Inherited 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 c377da89..e94874f0 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 @@ -19,6 +19,7 @@ import com.palantir.javapoet.ParameterizedTypeName; import com.palantir.javapoet.TypeName; import com.palantir.javapoet.TypeVariableName; +import io.soabase.recordbuilder.core.RecordBuilder; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.*; @@ -135,8 +136,9 @@ public static RecordClassType getRecordClassType(ProcessingEnvironment processin List canonicalConstructorAnnotations) { var typeName = TypeName.get(recordComponent.asType()); var rawTypeName = TypeName.get(processingEnv.getTypeUtils().erasure(recordComponent.asType())); - return new RecordClassType(typeName, rawTypeName, recordComponent.getSimpleName().toString(), - recordComponent.getSimpleName().toString(), accessorAnnotations, canonicalConstructorAnnotations); + return new RecordClassType(recordComponent.asType().getKind(), typeName, rawTypeName, + recordComponent.getSimpleName().toString(), recordComponent.getSimpleName().toString(), + accessorAnnotations, canonicalConstructorAnnotations); } public static String getWithMethodName(ClassType component, String prefix) { @@ -180,6 +182,12 @@ private static String getNamePrefix(Element element) { return ""; } + public static RecordBuilder.Options getMetaData(ProcessingEnvironment processingEnv, Element element) { + var recordSpecificMetaData = element.getAnnotation(RecordBuilder.Options.class); + return (recordSpecificMetaData != null) ? recordSpecificMetaData + : RecordBuilderOptions.build(processingEnv.getOptions()); + } + private ElementUtils() { } } diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalDeconstructorProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalDeconstructorProcessor.java index b36eba2c..2664005a 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalDeconstructorProcessor.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalDeconstructorProcessor.java @@ -188,8 +188,8 @@ private List buildRecordComponents(TypeElement typeElement) { .stream().filter(annotation -> !annotation.getAnnotationType().asElement().getSimpleName() .toString().equals(DeconstructorAccessor.class.getSimpleName())) .toList(); - var type = new RecordClassType(typeName, rawTypeName, name, - executableElement.getSimpleName().toString(), annotationMirrors, List.of()); + var type = new RecordClassType(executableElement.getReturnType().getKind(), typeName, rawTypeName, + name, executableElement.getSimpleName().toString(), annotationMirrors, List.of()); var orderedType = Map.entry(deconstructorAccessor.order(), type); return Stream.of(orderedType); }).sorted((o1, o2) -> { @@ -229,9 +229,9 @@ private List buildRecordComponents(ExecutableElement executable return executableElement.getParameters().stream().map(parameter -> { ValidatedParameter validatedParameter = validateParameter(parameter.getSimpleName().toString(), parameter.asType()); - return new RecordClassType(validatedParameter.typeName, validatedParameter.rawTypeName, - parameter.getSimpleName().toString(), parameter.getSimpleName().toString(), - parameter.getAnnotationMirrors(), List.of()); + return new RecordClassType(parameter.asType().getKind(), validatedParameter.typeName, + validatedParameter.rawTypeName, parameter.getSimpleName().toString(), + parameter.getSimpleName().toString(), parameter.getAnnotationMirrors(), List.of()); }).toList(); } 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 4faab9cd..ff2d22d0 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 @@ -23,11 +23,9 @@ import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.Element; import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; import java.util.*; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; -import java.util.function.Consumer; -import java.util.function.Predicate; +import java.util.function.*; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -39,6 +37,7 @@ import static io.soabase.recordbuilder.processor.ElementUtils.getWithMethodName; import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.generatedRecordBuilderAnnotation; import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.recordBuilderGeneratedAnnotation; +import static java.util.stream.Collectors.toMap; import static javax.tools.Diagnostic.Kind.ERROR; class InternalRecordBuilderProcessor { @@ -51,19 +50,23 @@ class InternalRecordBuilderProcessor { private final Optional builderType; private final TypeSpec.Builder builder; private final String uniqueVarName; + private final String isCopyBuilderVarName; private final Pattern notNullPattern; private final Pattern nullablePattern; private final CollectionBuilderUtils collectionBuilderUtils; + private static final ClassName unaryOperatorType = ClassName.get(UnaryOperator.class); private static final TypeName optionalType = TypeName.get(Optional.class); private static final TypeName overrideType = TypeName.get(Override.class); private static final TypeName javaxValidType = ClassName.get("javax.validation", "Valid"); private static final TypeName jakartaValidType = ClassName.get("jakarta.validation", "Valid"); private static final TypeName validatorTypeName = ClassName.get("io.soabase.recordbuilder.validator", "RecordBuilderValidator"); + private static final TypeVariableName rType = TypeVariableName.get("R"); private final Modifier constructorVisibilityModifier; private final Map initializers; + private final Map nestedBuilderOptions; InternalRecordBuilderProcessor(ProcessingEnvironment processingEnv, RecordFacade recordFacade, RecordBuilder.Options metaData) { @@ -74,6 +77,7 @@ class InternalRecordBuilderProcessor { typeVariables = recordFacade.typeVariables(); recordComponents = recordFacade.recordComponents(); uniqueVarName = getUniqueVarName(); + isCopyBuilderVarName = getUniqueVarName("_isCopyBuilde"); notNullPattern = Pattern.compile(metaData.interpretNotNullsPattern()); nullablePattern = Pattern.compile(metaData.nullablePattern()); collectionBuilderUtils = new CollectionBuilderUtils(recordComponents, this.metaData); @@ -86,14 +90,21 @@ class InternalRecordBuilderProcessor { builder.addAnnotation(recordBuilderGeneratedAnnotation); } + nestedBuilderOptions = recordComponents.stream().collect(toMap(ClassType::name, + recordComponent -> NestedBuilder.build(processingEnv, metaData, recordComponent))); + if (!validateMethodNameConflicts(processingEnv, recordFacade.element())) { builderType = Optional.empty(); return; } + if (hasNestedBuilders()) { + builder.addField(FieldSpec.builder(boolean.class, isCopyBuilderVarName, Modifier.PRIVATE).build()); + } + addVisibility(recordFacade.builderIsInRecordPackage(), recordFacade.modifiers()); if (metaData.enableWither()) { - addWithNestedClass(); + addWithNestedClass(processingEnv); } if (!metaData.beanClassName().isEmpty()) { addBeanNestedClass(); @@ -137,6 +148,9 @@ class InternalRecordBuilderProcessor { if (metaData.addConcreteSettersForOptional() != RecordBuilder.ConcreteSettersForOptionalMode.DISABLED) { add1ConcreteOptionalSetterMethod(component); } + if (metaData.detectNestedRecordBuilders()) { + checkAdd1NestedBuilderSetter(processingEnv, builder, component, Optional.empty(), false); + } var collectionMetaData = collectionBuilderUtils.singleItemsMetaData(component, EXCLUDE_WILDCARD_TYPES); collectionMetaData.ifPresent(meta -> add1CollectionBuilders(meta, component)); }); @@ -358,7 +372,7 @@ private void addNullableAnnotation(ParameterSpec.Builder builder) { } } - private void addWithNestedClass() { + private void addWithNestedClass(ProcessingEnvironment processingEnv) { /* * Adds a nested interface that adds withers similar to: * @@ -374,8 +388,15 @@ private void addWithNestedClass() { recordComponents.forEach(component -> addNestedGetterMethod(classBuilder, component, component.name())); addWithBuilderMethod(classBuilder); addWithSuppliedBuilderMethod(classBuilder); - IntStream.range(0, recordComponents.size()) - .forEach(index -> add1WithMethod(classBuilder, recordComponents.get(index), index)); + + IntStream.range(0, recordComponents.size()).forEach(index -> { + RecordClassType component = recordComponents.get(index); + add1WithMethod(classBuilder, component, index); + if (metaData.detectNestedRecordBuilders()) { + checkAdd1NestedBuilderSetter(processingEnv, classBuilder, component, + Optional.of(recordClassType.typeName()), true); + } + }); if (metaData.addFunctionalMethodsToWith()) { classBuilder.addType(buildFunctionalInterface("Function", true)) .addType(buildFunctionalInterface("Consumer", false)) @@ -590,6 +611,9 @@ private void addAllArgsConstructor() { constructorBuilder.addParameter(parameterSpecBuilder.build()); constructorBuilder.addStatement("this.$L = $L", component.name(), component.name()); }); + if (hasNestedBuilders()) { + constructorBuilder.addStatement("this.$L = true", isCopyBuilderVarName); + } builder.addMethod(constructorBuilder.build()); } @@ -1045,6 +1069,73 @@ private void add1ListBuilder(SingleItemsMetaData meta, RecordClassType component } } + private void checkAdd1NestedBuilderSetter(ProcessingEnvironment processingEnv, TypeSpec.Builder builder, + RecordClassType component, Optional overrideReturnType, boolean forWither) { + /* + * For a single record component that has a @RecordBuilder annotation, add a setter similar to: + * + * public MyRecordBuilder p(Consumer b) { PBuilder builder = PBuilder.builder(); b.accept(builder); + * this.p = builder.build(); return this; } + */ + + Optional maybeOptions = Optional.ofNullable(nestedBuilderOptions.get(component.name())) + .flatMap(NestedBuilder::builderOptions); + if (maybeOptions.isEmpty()) { + return; + } + RecordBuilder.Options options = maybeOptions.get(); + + TypeElement typeElement = processingEnv.getElementUtils().getTypeElement(component.rawTypeName().toString()); + if ((typeElement == null) || (typeElement.asType() == null)) { + return; + } + + TypeName returnType = overrideReturnType.orElseGet(builderClassType::typeName); + + var componentFacade = RecordFacade.fromTypeElement(processingEnv, typeElement, Optional.empty(), options); + + String operatorVarName = getUniqueVarName("operato"); + String builderVarName = getUniqueVarName("builde"); + + String builderName = componentFacade.builderClassType().name(); + TypeName builderType = componentFacade.builderClassType().typeName(); + ParameterizedTypeName builderOpeeratorType = ParameterizedTypeName.get(unaryOperatorType, builderType); + + var methodName = forWither ? getWithMethodName(component, metaData.withClassMethodPrefix()) + : prefixedName(component, false); + + var methodSpec = MethodSpec.methodBuilder(methodName).addModifiers(Modifier.PUBLIC) + .addAnnotation(generatedRecordBuilderAnnotation).returns(returnType) + .addParameter(builderOpeeratorType, operatorVarName); + + CodeBlock.Builder codeBlockBuilder = CodeBlock.builder(); + + if (forWither) { + methodSpec.addModifiers(Modifier.DEFAULT); + methodSpec.addJavadoc("Return a new instance of {@code $L} with a new value for {@code $L}\n", + recordClassType.name(), component.name()); + + // FooBuilder builder = FooBuilder.builder(foo()); + codeBlockBuilder.addStatement("$T $L = $L.$L($L())", builderType, builderVarName, builderName, + options.copyMethodName(), component.name()); + } else { + methodSpec.addJavadoc("Set a new value for the {@code $L} record component in the builder\n", + component.name()); + + // FooBuilder builder = _isCopyBuilder_r ? FooBuilder.builder(foo()) : FooBuilder.builder(); + codeBlockBuilder.addStatement("$T $L = $L ? $L.$L($L) : $L.$L()", builderType, builderVarName, + isCopyBuilderVarName, builderName, options.copyMethodName(), component.name(), builderName, + options.builderMethodName()); + } + + codeBlockBuilder.addStatement("return $L($L.apply($L).$L())", methodName, operatorVarName, builderVarName, + options.buildMethodName()); + + methodSpec.addCode(codeBlockBuilder.build()); + + builder.addMethod(methodSpec.build()); + } + private void add1GetterMethod(RecordClassType component) { /* * For a single record component, add a getter similar to: @@ -1219,4 +1310,9 @@ private String stagedBuilderName(ClassType component) { private ClassType stagedBuilderType(ClassType component) { return getClassTypeFromNames(ClassName.get("", stagedBuilderName(component)), typeVariables); } + + private boolean hasNestedBuilders() { + return nestedBuilderOptions.values().stream() + .anyMatch(nestedBuilder -> nestedBuilder.builderOptions().isPresent()); + } } diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/NestedBuilder.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/NestedBuilder.java new file mode 100644 index 00000000..576bfec5 --- /dev/null +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/NestedBuilder.java @@ -0,0 +1,91 @@ +/* + * 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.processor; + +import com.palantir.javapoet.TypeName; +import io.soabase.recordbuilder.core.RecordBuilder; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import java.util.Optional; +import java.util.stream.Stream; + +record NestedBuilder(Optional builderOptions) { + private static final NestedBuilder NONE = new NestedBuilder(Optional.empty()); + + private static final TypeName recordBuilderType = TypeName.get(RecordBuilder.class); + private static final TypeName recordBuilderTemplateType = TypeName.get(RecordBuilder.Template.class); + + static NestedBuilder build(ProcessingEnvironment processingEnv, RecordBuilder.Options metaData, + RecordClassType component) { + if (!metaData.detectNestedRecordBuilders()) { + return NONE; + } + + if (component.typeKind() != TypeKind.DECLARED) { + return NONE; + } + + TypeElement typeElement = processingEnv.getElementUtils().getTypeElement(component.rawTypeName().toString()); + if ((typeElement == null) || (typeElement.asType() == null)) { + return NONE; + } + + TypeMirror recordBuilderMirror = processingEnv.getElementUtils().getTypeElement(recordBuilderType.toString()) + .asType(); + TypeMirror recordBuilderTemplateMirror = processingEnv.getElementUtils() + .getTypeElement(recordBuilderTemplateType.toString()).asType(); + + Optional maybeOptions = typeElement.getAnnotationMirrors().stream() + .flatMap(annotation -> { + Optional annotationMirror = ElementUtils.findAnnotationMirror( + processingEnv, annotation.getAnnotationType().asElement(), + recordBuilderTemplateType.toString()); + if (annotationMirror.isPresent()) { + RecordBuilder.Options newOptions = ElementUtils.getMetaData(processingEnv, + annotation.getAnnotationType().asElement()); + return Stream.of(newOptions); + } + return Stream.empty(); + }).findFirst(); + + if (maybeOptions.isEmpty()) { + maybeOptions = processingEnv.getElementUtils().getAllAnnotationMirrors(typeElement).stream() + .flatMap(annotationMirror -> { + if (processingEnv.getTypeUtils().isSameType(annotationMirror.getAnnotationType(), + recordBuilderMirror)) { + return Stream.of(ElementUtils.getMetaData(processingEnv, typeElement)); + } + if (processingEnv.getTypeUtils().isSameType(annotationMirror.getAnnotationType(), + recordBuilderTemplateMirror)) { + RecordBuilder.Options options = recordBuilderTemplateMirror + .getAnnotation(RecordBuilder.Options.class); + return Stream.of(options); + } + return Stream.empty(); + }).findFirst(); + } + if (maybeOptions.isEmpty()) { + return NONE; + } + + RecordBuilder.Options options = maybeOptions.get(); + return new NestedBuilder(Optional.of(options)); + } +} diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java index c2cd5c54..36c43cf1 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java @@ -115,9 +115,7 @@ private void process(TypeElement annotation, Element element) { } private RecordBuilder.Options getMetaData(Element element) { - var recordSpecificMetaData = element.getAnnotation(RecordBuilder.Options.class); - return (recordSpecificMetaData != null) ? recordSpecificMetaData - : RecordBuilderOptions.build(processingEnv.getOptions()); + return ElementUtils.getMetaData(processingEnv, element); } private void processIncludes(Element element, RecordBuilder.Options metaData, String annotationClass) { diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordClassType.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordClassType.java index 7866253e..e625a582 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordClassType.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordClassType.java @@ -18,24 +18,31 @@ import com.palantir.javapoet.TypeName; import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.type.TypeKind; import java.util.List; public class RecordClassType extends ClassType { + private final TypeKind typeKind; private final TypeName rawTypeName; private final String accessorName; private final List accessorAnnotations; private final List canonicalConstructorAnnotations; - public RecordClassType(TypeName typeName, TypeName rawTypeName, String name, String accessorName, + public RecordClassType(TypeKind typeKind, TypeName typeName, TypeName rawTypeName, String name, String accessorName, List accessorAnnotations, List canonicalConstructorAnnotations) { super(typeName, name); + this.typeKind = typeKind; this.rawTypeName = rawTypeName; this.accessorName = accessorName; this.accessorAnnotations = accessorAnnotations; this.canonicalConstructorAnnotations = canonicalConstructorAnnotations; } + public TypeKind typeKind() { + return typeKind; + } + public String accessorName() { return accessorName; } diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/Annotated.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/Annotated.java index b3a0b962..ba30dc4b 100644 --- a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/Annotated.java +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/Annotated.java @@ -22,5 +22,6 @@ import javax.validation.constraints.Null; @RecordBuilder +@RecordBuilder.Options(detectNestedRecordBuilders = true) public record Annotated(@NotNull @Null String hey, @Min(10) @Max(100) int i, double d) { } diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionCopying.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionCopying.java index 05e0ab5c..eae82d3b 100644 --- a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionCopying.java +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionCopying.java @@ -24,7 +24,7 @@ import java.util.Set; @RecordBuilder -@RecordBuilder.Options(addSingleItemCollectionBuilders = true, useImmutableCollections = true, mutableListClassName = "PersonalizedMutableList") +@RecordBuilder.Options(addSingleItemCollectionBuilders = true, useImmutableCollections = true, mutableListClassName = "PersonalizedMutableList", detectNestedRecordBuilders = true) public record CollectionCopying(List list, Set set, Map map, Collection collection, int count) implements CollectionCopyingBuilder.With { } diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionRecord.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionRecord.java index 85eea443..d60cbf79 100644 --- a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionRecord.java +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/CollectionRecord.java @@ -24,7 +24,7 @@ import java.util.Set; @RecordBuilder -@RecordBuilder.Options(useImmutableCollections = true, addFunctionalMethodsToWith = true) +@RecordBuilder.Options(useImmutableCollections = true, addFunctionalMethodsToWith = true, detectNestedRecordBuilders = true) public record CollectionRecord(List l, Set s, Map m, Collection c) implements CollectionRecordBuilder.With { public static void main(String[] args) { diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/DuplicateMethodNames.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/DuplicateMethodNames.java index b096ac2e..6a068416 100644 --- a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/DuplicateMethodNames.java +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/DuplicateMethodNames.java @@ -18,6 +18,6 @@ import io.soabase.recordbuilder.core.RecordBuilder; @RecordBuilder -@RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STANDARD_AND_STAGED, builderMethodName = "notBuilder", buildMethodName = "notBuild", stagedBuilderMethodName = "notStagedBuilder") +@RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STANDARD_AND_STAGED, builderMethodName = "notBuilder", buildMethodName = "notBuild", stagedBuilderMethodName = "notStagedBuilder", detectNestedRecordBuilders = true) public record DuplicateMethodNames(int builder, int build, int from, int stream, int stagedBuilder) { } diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/MyTemplate.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/MyTemplate.java index 82db93e8..49b80942 100644 --- a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/MyTemplate.java +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/MyTemplate.java @@ -17,6 +17,6 @@ import io.soabase.recordbuilder.core.RecordBuilder; -@RecordBuilder.Template(options = @RecordBuilder.Options(fileComment = "This is a test", withClassName = "Com")) +@RecordBuilder.Template(options = @RecordBuilder.Options(fileComment = "This is a test", withClassName = "Com", detectNestedRecordBuilders = true)) public @interface MyTemplate { } diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/WildcardSingleItems.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/WildcardSingleItems.java index 8c9d3018..bb9e88c3 100644 --- a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/WildcardSingleItems.java +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/WildcardSingleItems.java @@ -24,7 +24,7 @@ import java.util.Set; @RecordBuilder -@RecordBuilder.Options(addSingleItemCollectionBuilders = true, useImmutableCollections = true) +@RecordBuilder.Options(addSingleItemCollectionBuilders = true, useImmutableCollections = true, detectNestedRecordBuilders = true) public record WildcardSingleItems(List strings, Set> sets, Map map, Collection collection) implements WildcardSingleItemsBuilder.With { diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Address.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Address.java new file mode 100644 index 00000000..ad920cd8 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Address.java @@ -0,0 +1,22 @@ +/* + * 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.nested; + +import io.soabase.recordbuilder.core.RecordBuilderFull; + +@RecordBuilderFull +public record Address(String address, CityState cityState, String country) implements AddressBuilder.With { +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/BigOlNestedContainer.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/BigOlNestedContainer.java new file mode 100644 index 00000000..089c689d --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/BigOlNestedContainer.java @@ -0,0 +1,27 @@ +/* + * 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.nested; + +import io.soabase.recordbuilder.core.RecordBuilderFull; +import io.soabase.recordbuilder.test.*; + +@RecordBuilderFull +public record BigOlNestedContainer(Annotated annotated, CollectionCopying collectionCopying, + CollectionRecord collectionRecord, FullRecord fullRecord, ConvertRequest convertRequest, + TemplateTest templateTest, WildcardSingleItems wildcardSingleItems, + DuplicateMethodNames duplicateMethodNames, BigOlNestedContainer recursive) + implements BigOlNestedContainerBuilder.With { +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/CityState.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/CityState.java new file mode 100644 index 00000000..90f1dffa --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/CityState.java @@ -0,0 +1,22 @@ +/* + * 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.nested; + +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +public record CityState(String city, String state) implements CityStateBuilder.With { +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/ConvertRequest.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/ConvertRequest.java new file mode 100644 index 00000000..d07de396 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/ConvertRequest.java @@ -0,0 +1,22 @@ +/* + * 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.nested; + +import io.soabase.recordbuilder.test.naming.Builder; + +@Builder +record ConvertRequest(double from, double to) { +} \ No newline at end of file diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Employee.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Employee.java new file mode 100644 index 00000000..ead998a3 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/nested/Employee.java @@ -0,0 +1,22 @@ +/* + * 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.nested; + +import io.soabase.recordbuilder.core.RecordBuilderFull; + +@RecordBuilderFull +public record Employee(String firstName, String lastName, Address address) implements EmployeeBuilder.With { +} diff --git a/record-builder-test/src/test/java/io/soabase/recordbuilder/test/nested/TestNestedBuilders.java b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/nested/TestNestedBuilders.java new file mode 100644 index 00000000..5267f6fe --- /dev/null +++ b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/nested/TestNestedBuilders.java @@ -0,0 +1,42 @@ +/* + * 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.nested; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestNestedBuilders { + @Test + public void testNestedBuilders() { + Employee employee = EmployeeBuilder.builder().firstName("John").lastName("Doe").address( + b -> b.address("123 Main St").cityState(cs -> cs.city("Springfield").state("IL")).country("USA")) + .build(); + + Employee employee2 = employee.with(b -> b.address( + a -> a.cityState(cs -> cs.city("Shelbyville")).country("Nope").cityState(cs -> cs.state("Good")))); + assertThat(employee2).isEqualTo( + new Employee("John", "Doe", new Address("123 Main St", new CityState("Shelbyville", "Good"), "Nope"))); + + Employee employee3 = employee.withAddress(a -> a.cityState(cs -> cs.city("Shelbyville2"))); + assertThat(employee3).isEqualTo( + new Employee("John", "Doe", new Address("123 Main St", new CityState("Shelbyville2", "IL"), "USA"))); + + Employee employee4 = employee.withAddress(a -> a.country("Israel")); + assertThat(employee4).isEqualTo( + new Employee("John", "Doe", new Address("123 Main St", new CityState("Springfield", "IL"), "Israel"))); + } +} From 491833e30b35e78804904e2ec497bd644f106fd0 Mon Sep 17 00:00:00 2001 From: Jordan Zimmerman Date: Sat, 25 Oct 2025 20:54:15 +0100 Subject: [PATCH 2/2] temp --- .../main/java/io/soabase/recordbuilder/core/RecordBuilder.java | 2 +- .../java/io/soabase/recordbuilder/core/RecordBuilderFull.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 cacdebd5..29cd8df7 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 @@ -365,7 +365,7 @@ * are nested builder consumers so that you can do, for example, * {@code myRecord.withNestedRecord(nestedBuilder -> nestedBuilder.field1(...).field2(...))} */ - boolean detectNestedRecordBuilders() default false; + boolean detectNestedRecordBuilders() default true; } @Retention(RetentionPolicy.CLASS) diff --git a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java index 12c88b89..e4a7f2f9 100644 --- a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java +++ b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilderFull.java @@ -20,7 +20,7 @@ /** * An alternate form of {@code @RecordBuilder} that has most optional features turned on */ -@RecordBuilder.Template(options = @RecordBuilder.Options(interpretNotNulls = true, useImmutableCollections = true, addSingleItemCollectionBuilders = true, addFunctionalMethodsToWith = true, addClassRetainedGenerated = true, detectNestedRecordBuilders = true)) +@RecordBuilder.Template(options = @RecordBuilder.Options(interpretNotNulls = true, useImmutableCollections = true, addSingleItemCollectionBuilders = true, addFunctionalMethodsToWith = true, addClassRetainedGenerated = true)) @Retention(RetentionPolicy.SOURCE) @Target({ ElementType.TYPE, ElementType.METHOD }) @Inherited