From 63570258f54d6b41214b9d5183261a118b772c98 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 22:30:28 +0200 Subject: [PATCH 01/11] First implementation for taking annotations over into builder --- .../processor/dtos/AnnotationDto.java | 75 ++++++ .../builders/processor/dtos/FieldDto.java | 21 ++ .../util/BuilderDefinitionCreator.java | 73 ++++++ .../processor/util/JavaCodeGenerator.java | 25 +- .../processor/util/JavapoetMapper.java | 21 ++ .../processor/AnnotationCopyTest.java | 221 ++++++++++++++++++ 6 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java new file mode 100644 index 00000000..444ade37 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java @@ -0,0 +1,75 @@ +/* + * MIT License + * + * Copyright (c) 2025 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.dtos; + +import javax.lang.model.element.AnnotationMirror; + +/** + * DTO representing an annotation to be copied from the target class field to the builder class + * field. + */ +public class AnnotationDto { + /** The annotation type (fully qualified name). */ + private TypeName annotationType; + + /** The annotation mirror containing all annotation data including parameters. */ + private AnnotationMirror annotationMirror; + + /** + * Gets the annotation type. + * + * @return the type of the annotation + */ + public TypeName getAnnotationType() { + return annotationType; + } + + /** + * Sets the annotation type. + * + * @param annotationType the type of the annotation + */ + public void setAnnotationType(TypeName annotationType) { + this.annotationType = annotationType; + } + + /** + * Gets the annotation mirror. + * + * @return the annotation mirror containing all annotation data + */ + public AnnotationMirror getAnnotationMirror() { + return annotationMirror; + } + + /** + * Sets the annotation mirror. + * + * @param annotationMirror the annotation mirror containing all annotation data + */ + public void setAnnotationMirror(AnnotationMirror annotationMirror) { + this.annotationMirror = annotationMirror; + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java index c28b5e7d..41a4522e 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java @@ -55,6 +55,9 @@ public class FieldDto { */ private String getterName; + /** Annotations to be copied from the target class field to the builder class field. */ + private final List annotations = new ArrayList<>(); + /** * Getting name of field. * @@ -175,4 +178,22 @@ public Optional getGetterName() { public void setGetterName(String getterName) { this.getterName = getterName; } + + /** + * Returns the list of annotations to be copied to the builder field. + * + * @return the list of annotations + */ + public List getAnnotations() { + return annotations; + } + + /** + * Adds an annotation to be copied to the builder field. + * + * @param annotation the annotation to add + */ + public void addAnnotation(AnnotationDto annotation) { + this.annotations.add(annotation); + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java index f2cab96f..a0421ff6 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java @@ -550,6 +550,9 @@ private static Optional createFieldDto( // Add basic setter method field.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType)); + // Extract and add annotations from the field parameter + extractAnnotations(param, field, context); + // Add consumer/supplier/helper methods addConsumerMethodsForField(field, fieldName, fieldType, param, fieldTypeElement, context); addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement); @@ -558,6 +561,76 @@ private static Optional createFieldDto( return Optional.of(field); } + /** + * Extracts annotations from a field parameter and adds them to the FieldDto. Filters out + * annotations that should not be copied to the builder. + * + * @param param the parameter element containing annotations + * @param field the field DTO to add annotations to + * @param context processing context + */ + private static void extractAnnotations( + VariableElement param, FieldDto field, ProcessingContext context) { + List annotationMirrors = param.getAnnotationMirrors(); + + context.debug( + " -> Extracting %d annotation(s) from field %s", + annotationMirrors.size(), param.getSimpleName()); + + for (AnnotationMirror mirror : annotationMirrors) { + // Get the annotation type element + Element annotationElement = mirror.getAnnotationType().asElement(); + if (!(annotationElement instanceof TypeElement annotationType)) { + continue; + } + + String annotationQualifiedName = annotationType.getQualifiedName().toString(); + + // Filter out annotations that should not be copied to the builder + if (shouldSkipAnnotation(annotationQualifiedName)) { + context.debug(" -> Skipping annotation: %s", annotationQualifiedName); + continue; + } + + // Create AnnotationDto + AnnotationDto annotationDto = new AnnotationDto(); + + // Extract package and class name + String packageName = context.getPackageName(annotationType); + String simpleName = annotationType.getSimpleName().toString(); + annotationDto.setAnnotationType(new TypeName(packageName, simpleName)); + annotationDto.setAnnotationMirror(mirror); + + field.addAnnotation(annotationDto); + context.debug(" -> Added annotation: %s", annotationQualifiedName); + } + } + + /** + * Determines if an annotation should be skipped (not copied to the builder). + * + * @param qualifiedName the fully qualified name of the annotation + * @return true if the annotation should be skipped, false otherwise + */ + private static boolean shouldSkipAnnotation(String qualifiedName) { + // Skip SimpleBuilder framework annotations + if (qualifiedName.startsWith("org.javahelpers.simple.builders.")) { + return true; + } + // Skip standard Java annotations that are not relevant for builder fields + if (qualifiedName.equals("java.lang.Override") + || qualifiedName.equals("java.lang.Deprecated") + || qualifiedName.equals("java.lang.SuppressWarnings")) { + return true; + } + // Skip javax.annotation.processing annotations + if (qualifiedName.startsWith("javax.annotation.processing.")) { + return true; + } + // Keep all other annotations + return false; + } + private static MethodDto createFieldSetterWithTransform( String fieldName, String transform, TypeName fieldType) { MethodParameterDto parameter = new MethodParameterDto(); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java index 5d261417..1c17a092 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java @@ -246,13 +246,24 @@ private FieldSpec createFieldMember(FieldDto fieldDto) { ClassName builderFieldWrapper = ClassName.get(TrackedValue.class); ParameterizedTypeName wrappedFieldType = ParameterizedTypeName.get(builderFieldWrapper, fieldType); - return FieldSpec.builder(wrappedFieldType, fieldDto.getFieldName(), Modifier.PRIVATE) - .addJavadoc( - "Tracked value for $L: $L.\n", - fieldDto.getFieldName(), - fieldDto.getJavaDoc()) - .initializer("$T.unsetValue()", builderFieldWrapper) - .build(); + + FieldSpec.Builder fieldBuilder = + FieldSpec.builder(wrappedFieldType, fieldDto.getFieldName(), Modifier.PRIVATE) + .addJavadoc( + "Tracked value for $L: $L.\n", + fieldDto.getFieldName(), + fieldDto.getJavaDoc()) + .initializer("$T.unsetValue()", builderFieldWrapper); + + // Add annotations from the target class field + if (!fieldDto.getAnnotations().isEmpty()) { + logger.debug( + " Adding %d annotation(s) to builder field: %s", + fieldDto.getAnnotations().size(), fieldDto.getFieldName()); + fieldBuilder.addAnnotations(map2AnnotationSpecs(fieldDto.getAnnotations())); + } + + return fieldBuilder.build(); } private MethodSpec createMethodBuild( diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java index 00dd7361..7308b3aa 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java @@ -24,6 +24,7 @@ package org.javahelpers.simple.builders.processor.util; +import com.palantir.javapoet.AnnotationSpec; import com.palantir.javapoet.ArrayTypeName; import com.palantir.javapoet.ClassName; import com.palantir.javapoet.CodeBlock; @@ -171,4 +172,24 @@ private static Object toCodeblockValue(MethodCodePlaceholder placeHolderValue throw new UnsupportedOperationException(""); } } + + /** + * Maps an AnnotationDto to a JavaPoet AnnotationSpec. + * + * @param annotationDto the annotation DTO to map + * @return JavaPoet AnnotationSpec + */ + public static AnnotationSpec map2AnnotationSpec(AnnotationDto annotationDto) { + return AnnotationSpec.get(annotationDto.getAnnotationMirror()); + } + + /** + * Maps a list of AnnotationDto to JavaPoet AnnotationSpec instances. + * + * @param annotations the list of annotations to map + * @return list of AnnotationSpec + */ + public static List map2AnnotationSpecs(List annotations) { + return annotations.stream().map(JavapoetMapper::map2AnnotationSpec).toList(); + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java new file mode 100644 index 00000000..c5f0d158 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -0,0 +1,221 @@ +package org.javahelpers.simple.builders.processor; + +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.Compiler; +import com.google.testing.compile.JavaFileObjects; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; +import org.junit.jupiter.api.Test; + +/** Test that annotations from target class fields are copied to builder fields. */ +class AnnotationCopyTest { + + private Compilation compileSources(JavaFileObject... sources) { + BuilderProcessor processor = new BuilderProcessor(); + Compiler compiler = Compiler.javac().withProcessors(processor); + return compiler.compile(sources); + } + + @Test + void annotations_copiedToBuilderFields() { + String packageName = "test.annotations"; + + JavaFileObject notNullAnnotation = + JavaFileObjects.forSourceString( + packageName + ".NotNull", + """ + package test.annotations; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface NotNull { + } + """); + + JavaFileObject customAnnotation = + JavaFileObjects.forSourceString( + packageName + ".CustomAnnotation", + """ + package test.annotations; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface CustomAnnotation { + String value() default ""; + } + """); + + JavaFileObject person = + JavaFileObjects.forSourceString( + packageName + ".Person", + """ + package test.annotations; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Person { + private String name; + private String email; + private int age; + + public String getName() { return name; } + public void setName(@NotNull String name) { this.name = name; } + + public String getEmail() { return email; } + public void setEmail(@CustomAnnotation("email-field") String email) { this.email = email; } + + public int getAge() { return age; } + public void setAge(int age) { this.age = age; } + } + """); + + Compilation compilation = compileSources(notNullAnnotation, customAnnotation, person); + String generatedCode = loadGeneratedSource(compilation, "PersonBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "PersonBuilder", generatedCode); + + // Verify that annotations are copied to builder fields + ProcessorAsserts.assertingResult( + generatedCode, + // NotNull annotation should be on the name field + contains("@NotNull"), + // CustomAnnotation should be on the email field + contains("@CustomAnnotation(\"email-field\")")); + } + + @Test + void annotations_constructorParameters_copiedToBuilderFields() { + String packageName = "test.annotations.constructor"; + + JavaFileObject notNullAnnotation = + JavaFileObjects.forSourceString( + packageName + ".NotNull", + """ + package test.annotations.constructor; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface NotNull { + } + """); + + JavaFileObject positiveAnnotation = + JavaFileObjects.forSourceString( + packageName + ".Positive", + """ + package test.annotations.constructor; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface Positive { + } + """); + + JavaFileObject product = + JavaFileObjects.forSourceString( + packageName + ".Product", + """ + package test.annotations.constructor; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Product { + private final String name; + private final double price; + + public Product( + @NotNull String name, + @Positive double price) { + this.name = name; + this.price = price; + } + + public String getName() { return name; } + public double getPrice() { return price; } + } + """); + + Compilation compilation = compileSources(notNullAnnotation, positiveAnnotation, product); + String generatedCode = loadGeneratedSource(compilation, "ProductBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ProductBuilder", generatedCode); + + // Verify that annotations from constructor parameters are copied to builder fields + ProcessorAsserts.assertingResult( + generatedCode, + // NotNull annotation should be on the name field + contains("@NotNull"), + // Positive annotation should be on the price field + contains("@Positive")); + } + + @Test + void annotations_frameworkAnnotations_notCopied() { + String packageName = "test.annotations.filtered"; + + JavaFileObject notNullAnnotation = + JavaFileObjects.forSourceString( + packageName + ".NotNull", + """ + package test.annotations.filtered; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface NotNull { + } + """); + + JavaFileObject person = + JavaFileObjects.forSourceString( + packageName + ".Person", + """ + package test.annotations.filtered; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.annotations.IgnoreInBuilder; + + @SimpleBuilder + public class Person { + private String name; + private String ignoredField; + + public String getName() { return name; } + public void setName(@NotNull String name) { this.name = name; } + + public String getIgnoredField() { return ignoredField; } + @IgnoreInBuilder + public void setIgnoredField(String ignoredField) { this.ignoredField = ignoredField; } + } + """); + + Compilation compilation = compileSources(notNullAnnotation, person); + String generatedCode = loadGeneratedSource(compilation, "PersonBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "PersonBuilder", generatedCode); + + // Verify that NotNull annotation is copied + ProcessorAsserts.assertingResult(generatedCode, contains("@NotNull")); + + // Verify that ignoredField is not in the builder (due to @IgnoreInBuilder) + ProcessorAsserts.assertNotContaining(generatedCode, "ignoredField"); + } +} From f0553cf0bc242983b41d4b9b7be240408ca6e3c4 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 22:51:12 +0200 Subject: [PATCH 02/11] Moving code into FieldAnnotationExtractor and removing javax-dependency in AnnotationDto --- .../processor/dtos/AnnotationDto.java | 31 ++-- .../util/BuilderDefinitionCreator.java | 73 +--------- .../util/FieldAnnotationExtractor.java | 134 ++++++++++++++++++ .../processor/util/JavapoetMapper.java | 11 +- 4 files changed, 165 insertions(+), 84 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java index 444ade37..b06cad0c 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java @@ -24,18 +24,24 @@ package org.javahelpers.simple.builders.processor.dtos; -import javax.lang.model.element.AnnotationMirror; +import java.util.LinkedHashMap; +import java.util.Map; /** * DTO representing an annotation to be copied from the target class field to the builder class - * field. + * field. Contains the annotation type and its members in a plain format suitable for code + * generation. */ public class AnnotationDto { /** The annotation type (fully qualified name). */ private TypeName annotationType; - /** The annotation mirror containing all annotation data including parameters. */ - private AnnotationMirror annotationMirror; + /** + * Annotation members (parameters) as key-value pairs. The values are stored as code strings ready + * for generation (e.g., "\"value\"", "123", "{1, 2, 3}"). LinkedHashMap preserves declaration + * order. + */ + private final Map members = new LinkedHashMap<>(); /** * Gets the annotation type. @@ -56,20 +62,21 @@ public void setAnnotationType(TypeName annotationType) { } /** - * Gets the annotation mirror. + * Gets the annotation members (parameters) as key-value pairs. * - * @return the annotation mirror containing all annotation data + * @return map of member names to their code string values */ - public AnnotationMirror getAnnotationMirror() { - return annotationMirror; + public Map getMembers() { + return members; } /** - * Sets the annotation mirror. + * Adds an annotation member (parameter). * - * @param annotationMirror the annotation mirror containing all annotation data + * @param name the member name + * @param value the member value as a code string */ - public void setAnnotationMirror(AnnotationMirror annotationMirror) { - this.annotationMirror = annotationMirror; + public void addMember(String name, String value) { + this.members.put(name, value); } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java index a0421ff6..7def582d 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java @@ -551,7 +551,8 @@ private static Optional createFieldDto( field.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType)); // Extract and add annotations from the field parameter - extractAnnotations(param, field, context); + List annotations = FieldAnnotationExtractor.extractAnnotations(param, context); + annotations.forEach(field::addAnnotation); // Add consumer/supplier/helper methods addConsumerMethodsForField(field, fieldName, fieldType, param, fieldTypeElement, context); @@ -561,76 +562,6 @@ private static Optional createFieldDto( return Optional.of(field); } - /** - * Extracts annotations from a field parameter and adds them to the FieldDto. Filters out - * annotations that should not be copied to the builder. - * - * @param param the parameter element containing annotations - * @param field the field DTO to add annotations to - * @param context processing context - */ - private static void extractAnnotations( - VariableElement param, FieldDto field, ProcessingContext context) { - List annotationMirrors = param.getAnnotationMirrors(); - - context.debug( - " -> Extracting %d annotation(s) from field %s", - annotationMirrors.size(), param.getSimpleName()); - - for (AnnotationMirror mirror : annotationMirrors) { - // Get the annotation type element - Element annotationElement = mirror.getAnnotationType().asElement(); - if (!(annotationElement instanceof TypeElement annotationType)) { - continue; - } - - String annotationQualifiedName = annotationType.getQualifiedName().toString(); - - // Filter out annotations that should not be copied to the builder - if (shouldSkipAnnotation(annotationQualifiedName)) { - context.debug(" -> Skipping annotation: %s", annotationQualifiedName); - continue; - } - - // Create AnnotationDto - AnnotationDto annotationDto = new AnnotationDto(); - - // Extract package and class name - String packageName = context.getPackageName(annotationType); - String simpleName = annotationType.getSimpleName().toString(); - annotationDto.setAnnotationType(new TypeName(packageName, simpleName)); - annotationDto.setAnnotationMirror(mirror); - - field.addAnnotation(annotationDto); - context.debug(" -> Added annotation: %s", annotationQualifiedName); - } - } - - /** - * Determines if an annotation should be skipped (not copied to the builder). - * - * @param qualifiedName the fully qualified name of the annotation - * @return true if the annotation should be skipped, false otherwise - */ - private static boolean shouldSkipAnnotation(String qualifiedName) { - // Skip SimpleBuilder framework annotations - if (qualifiedName.startsWith("org.javahelpers.simple.builders.")) { - return true; - } - // Skip standard Java annotations that are not relevant for builder fields - if (qualifiedName.equals("java.lang.Override") - || qualifiedName.equals("java.lang.Deprecated") - || qualifiedName.equals("java.lang.SuppressWarnings")) { - return true; - } - // Skip javax.annotation.processing annotations - if (qualifiedName.startsWith("javax.annotation.processing.")) { - return true; - } - // Keep all other annotations - return false; - } - private static MethodDto createFieldSetterWithTransform( String fieldName, String transform, TypeName fieldType) { MethodParameterDto parameter = new MethodParameterDto(); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java new file mode 100644 index 00000000..24bdbc19 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java @@ -0,0 +1,134 @@ +/* + * MIT License + * + * Copyright (c) 2025 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import org.javahelpers.simple.builders.processor.dtos.AnnotationDto; +import org.javahelpers.simple.builders.processor.dtos.TypeName; + +/** Extractor for field annotations, converting them from Java model elements to DTOs. */ +public final class FieldAnnotationExtractor { + + private FieldAnnotationExtractor() { + // Private constructor to prevent instantiation + } + + /** + * Extracts annotations from a field parameter. Filters out annotations that should not be copied + * to the builder. + * + * @param param the parameter element containing annotations + * @param context processing context + * @return list of annotations to be copied to the builder field + */ + public static List extractAnnotations( + VariableElement param, ProcessingContext context) { + List annotations = new ArrayList<>(); + List annotationMirrors = param.getAnnotationMirrors(); + + context.debug( + " -> Extracting %d annotation(s) from field %s", + annotationMirrors.size(), param.getSimpleName()); + + for (AnnotationMirror mirror : annotationMirrors) { + // Get the annotation type element + Element annotationElement = mirror.getAnnotationType().asElement(); + if (!(annotationElement instanceof TypeElement annotationType)) { + continue; + } + + String annotationQualifiedName = annotationType.getQualifiedName().toString(); + + // Filter out annotations that should not be copied to the builder + if (shouldSkipAnnotation(annotationQualifiedName)) { + context.debug(" -> Skipping annotation: %s", annotationQualifiedName); + continue; + } + + // Create AnnotationDto + AnnotationDto annotationDto = new AnnotationDto(); + + // Extract package and class name + String packageName = context.getPackageName(annotationType); + String simpleName = annotationType.getSimpleName().toString(); + annotationDto.setAnnotationType(new TypeName(packageName, simpleName)); + + // Extract annotation members (parameters) + Map + elementValues = mirror.getElementValues(); + for (Map.Entry< + ? extends ExecutableElement, ? extends javax.lang.model.element.AnnotationValue> + entry : elementValues.entrySet()) { + String memberName = entry.getKey().getSimpleName().toString(); + String memberValue = formatAnnotationValue(entry.getValue()); + annotationDto.addMember(memberName, memberValue); + context.debug(" -> Member: %s = %s", memberName, memberValue); + } + + annotations.add(annotationDto); + context.debug(" -> Added annotation: %s", annotationQualifiedName); + } + + return annotations; + } + + /** + * Formats an annotation value as a code string suitable for code generation. Uses {@link + * javax.lang.model.element.AnnotationValue#toString()} which is explicitly designed to return a + * form suitable for source code representation. + * + * @param value the annotation value to format + * @return formatted code string suitable for source code + */ + private static String formatAnnotationValue(javax.lang.model.element.AnnotationValue value) { + // AnnotationValue.toString() returns a form suitable for source code representation + return value.toString(); + } + + /** + * Determines if an annotation should be skipped (not copied to the builder). + * + * @param qualifiedName the fully qualified name of the annotation + * @return true if the annotation should be skipped, false otherwise + */ + private static boolean shouldSkipAnnotation(String qualifiedName) { + // Skip SimpleBuilder framework annotations + if (qualifiedName.startsWith("org.javahelpers.simple.builders.")) { + return true; + } + // Skip standard Java annotations that are not relevant for builder fields + return qualifiedName.equals("java.lang.Override") + || qualifiedName.equals("java.lang.Deprecated") + || qualifiedName.equals("java.lang.SuppressWarnings") + || qualifiedName.startsWith("javax.annotation.processing."); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java index 7308b3aa..58ebf185 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java @@ -180,7 +180,16 @@ private static Object toCodeblockValue(MethodCodePlaceholder placeHolderValue * @return JavaPoet AnnotationSpec */ public static AnnotationSpec map2AnnotationSpec(AnnotationDto annotationDto) { - return AnnotationSpec.get(annotationDto.getAnnotationMirror()); + ClassName annotationType = map2ClassName(annotationDto.getAnnotationType()); + AnnotationSpec.Builder builder = AnnotationSpec.builder(annotationType); + + // Add annotation members (parameters) + for (Map.Entry member : annotationDto.getMembers().entrySet()) { + // Use $L (literal) format since the values are already formatted as code strings + builder.addMember(member.getKey(), "$L", member.getValue()); + } + + return builder.build(); } /** From 5fcbffd2c3c0fd6124b3396f36d0b6d61fb7d1d1 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 22:55:52 +0200 Subject: [PATCH 03/11] Refactoring for cleaner code --- .../util/FieldAnnotationExtractor.java | 84 +++++++++++-------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java index 24bdbc19..a7f88595 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; @@ -60,47 +61,58 @@ public static List extractAnnotations( annotationMirrors.size(), param.getSimpleName()); for (AnnotationMirror mirror : annotationMirrors) { - // Get the annotation type element - Element annotationElement = mirror.getAnnotationType().asElement(); - if (!(annotationElement instanceof TypeElement annotationType)) { - continue; - } - - String annotationQualifiedName = annotationType.getQualifiedName().toString(); - - // Filter out annotations that should not be copied to the builder - if (shouldSkipAnnotation(annotationQualifiedName)) { - context.debug(" -> Skipping annotation: %s", annotationQualifiedName); - continue; - } - - // Create AnnotationDto - AnnotationDto annotationDto = new AnnotationDto(); - - // Extract package and class name - String packageName = context.getPackageName(annotationType); - String simpleName = annotationType.getSimpleName().toString(); - annotationDto.setAnnotationType(new TypeName(packageName, simpleName)); - - // Extract annotation members (parameters) - Map - elementValues = mirror.getElementValues(); - for (Map.Entry< - ? extends ExecutableElement, ? extends javax.lang.model.element.AnnotationValue> - entry : elementValues.entrySet()) { - String memberName = entry.getKey().getSimpleName().toString(); - String memberValue = formatAnnotationValue(entry.getValue()); - annotationDto.addMember(memberName, memberValue); - context.debug(" -> Member: %s = %s", memberName, memberValue); - } - - annotations.add(annotationDto); - context.debug(" -> Added annotation: %s", annotationQualifiedName); + extractAnnotation(mirror, context).ifPresent(annotations::add); } return annotations; } + /** + * Extracts a single annotation from an AnnotationMirror. Filters out annotations that should not + * be copied to the builder. + * + * @param mirror the annotation mirror to process + * @param context processing context + * @return Optional containing the extracted annotation, or empty if it should be skipped + */ + private static Optional extractAnnotation( + AnnotationMirror mirror, ProcessingContext context) { + // Get the annotation type element + Element annotationElement = mirror.getAnnotationType().asElement(); + if (!(annotationElement instanceof TypeElement annotationType)) { + return Optional.empty(); + } + + String annotationQualifiedName = annotationType.getQualifiedName().toString(); + + // Filter out annotations that should not be copied to the builder + if (shouldSkipAnnotation(annotationQualifiedName)) { + context.debug(" -> Skipping annotation: %s", annotationQualifiedName); + return Optional.empty(); + } + + // Create AnnotationDto + AnnotationDto annotationDto = new AnnotationDto(); + + // Extract package and class name + String packageName = context.getPackageName(annotationType); + String simpleName = annotationType.getSimpleName().toString(); + annotationDto.setAnnotationType(new TypeName(packageName, simpleName)); + + // Extract annotation members (parameters) + Map + elementValues = mirror.getElementValues(); + for (Map.Entry + entry : elementValues.entrySet()) { + String memberName = entry.getKey().getSimpleName().toString(); + String memberValue = formatAnnotationValue(entry.getValue()); + annotationDto.addMember(memberName, memberValue); + } + + context.debug(" -> Added annotation: %s", annotationQualifiedName); + return Optional.of(annotationDto); + } + /** * Formats an annotation value as a code string suitable for code generation. Uses {@link * javax.lang.model.element.AnnotationValue#toString()} which is explicitly designed to return a From 73a98e56a8239acc7e2f57e07c9ab1a7b46a5aa6 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 23:13:29 +0200 Subject: [PATCH 04/11] Fixing location where annotation is placed, it should not be on the field --- .../builders/processor/dtos/FieldDto.java | 21 --------- .../processor/dtos/MethodParameterDto.java | 24 ++++++++++ .../util/BuilderDefinitionCreator.java | 33 +++++++++++--- .../processor/util/JavaCodeGenerator.java | 45 +++++++++++-------- .../processor/AnnotationCopyTest.java | 24 +++++----- 5 files changed, 90 insertions(+), 57 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java index 41a4522e..c28b5e7d 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java @@ -55,9 +55,6 @@ public class FieldDto { */ private String getterName; - /** Annotations to be copied from the target class field to the builder class field. */ - private final List annotations = new ArrayList<>(); - /** * Getting name of field. * @@ -178,22 +175,4 @@ public Optional getGetterName() { public void setGetterName(String getterName) { this.getterName = getterName; } - - /** - * Returns the list of annotations to be copied to the builder field. - * - * @return the list of annotations - */ - public List getAnnotations() { - return annotations; - } - - /** - * Adds an annotation to be copied to the builder field. - * - * @param annotation the annotation to add - */ - public void addAnnotation(AnnotationDto annotation) { - this.annotations.add(annotation); - } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodParameterDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodParameterDto.java index e2d5bb36..a94842da 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodParameterDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodParameterDto.java @@ -24,6 +24,9 @@ package org.javahelpers.simple.builders.processor.dtos; +import java.util.ArrayList; +import java.util.List; + /** MethodParameterDto contains all information for generating parameters in method headers. */ public class MethodParameterDto { /** Name of parameter. */ @@ -32,6 +35,9 @@ public class MethodParameterDto { /** Type of parameter. */ private TypeName parameterType; + /** Annotations to be applied to this parameter. */ + private final List annotations = new ArrayList<>(); + /** * Getting name of parameter * @@ -67,4 +73,22 @@ public TypeName getParameterType() { public void setParameterTypeName(TypeName parameterType) { this.parameterType = parameterType; } + + /** + * Returns the list of annotations to be applied to this parameter. + * + * @return the list of annotations + */ + public List getAnnotations() { + return annotations; + } + + /** + * Adds an annotation to be applied to this parameter. + * + * @param annotation the annotation to add + */ + public void addAnnotation(AnnotationDto annotation) { + this.annotations.add(annotation); + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java index 7def582d..978bf539 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java @@ -547,12 +547,11 @@ private static Optional createFieldDto( JavaLangAnalyser.findGetterForField(dtoType, fieldName, fieldTypeMirror, context) .ifPresent(getter -> field.setGetterName(getter.getSimpleName().toString())); - // Add basic setter method - field.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType)); - - // Extract and add annotations from the field parameter + // Extract annotations from the field parameter List annotations = FieldAnnotationExtractor.extractAnnotations(param, context); - annotations.forEach(field::addAnnotation); + + // Add basic setter method with annotations + field.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType, annotations)); // Add consumer/supplier/helper methods addConsumerMethodsForField(field, fieldName, fieldType, param, fieldTypeElement, context); @@ -562,11 +561,35 @@ private static Optional createFieldDto( return Optional.of(field); } + /** + * Creates a field setter method with optional transform, without annotations. + * + * @param fieldName the name of the field + * @param transform optional transform expression (e.g., "Optional.of(%s)") + * @param fieldType the type of the field + * @return the method DTO for the setter + */ private static MethodDto createFieldSetterWithTransform( String fieldName, String transform, TypeName fieldType) { + return createFieldSetterWithTransform(fieldName, transform, fieldType, List.of()); + } + + /** + * Creates a field setter method with optional transform and annotations. + * + * @param fieldName the name of the field + * @param transform optional transform expression (e.g., "Optional.of(%s)") + * @param fieldType the type of the field + * @param annotations annotations to apply to the parameter + * @return the method DTO for the setter + */ + private static MethodDto createFieldSetterWithTransform( + String fieldName, String transform, TypeName fieldType, List annotations) { MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName); parameter.setParameterTypeName(fieldType); + // Add annotations to the parameter + annotations.forEach(parameter::addAnnotation); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); methodDto.addParameter(parameter); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java index 1c17a092..5c4a7756 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java @@ -34,6 +34,7 @@ import com.palantir.javapoet.FieldSpec; import com.palantir.javapoet.JavaFile; import com.palantir.javapoet.MethodSpec; +import com.palantir.javapoet.ParameterSpec; import com.palantir.javapoet.ParameterizedTypeName; import com.palantir.javapoet.TypeSpec; import java.io.IOException; @@ -247,23 +248,13 @@ private FieldSpec createFieldMember(FieldDto fieldDto) { ParameterizedTypeName wrappedFieldType = ParameterizedTypeName.get(builderFieldWrapper, fieldType); - FieldSpec.Builder fieldBuilder = - FieldSpec.builder(wrappedFieldType, fieldDto.getFieldName(), Modifier.PRIVATE) - .addJavadoc( - "Tracked value for $L: $L.\n", - fieldDto.getFieldName(), - fieldDto.getJavaDoc()) - .initializer("$T.unsetValue()", builderFieldWrapper); - - // Add annotations from the target class field - if (!fieldDto.getAnnotations().isEmpty()) { - logger.debug( - " Adding %d annotation(s) to builder field: %s", - fieldDto.getAnnotations().size(), fieldDto.getFieldName()); - fieldBuilder.addAnnotations(map2AnnotationSpecs(fieldDto.getAnnotations())); - } - - return fieldBuilder.build(); + return FieldSpec.builder(wrappedFieldType, fieldDto.getFieldName(), Modifier.PRIVATE) + .addJavadoc( + "Tracked value for $L: $L.\n", + fieldDto.getFieldName(), + fieldDto.getJavaDoc()) + .initializer("$T.unsetValue()", builderFieldWrapper) + .build(); } private MethodSpec createMethodBuild( @@ -364,8 +355,8 @@ private MethodSpec createMethod( for (int i = 0; i <= maxIndexParameters; i++) { MethodParameterDto paramDto = methodDto.getParameters().get(i); - com.palantir.javapoet.TypeName parameterType = map2ParameterType(paramDto.getParameterType()); - methodBuilder.addParameter(parameterType, paramDto.getParameterName()); + methodBuilder.addParameter(createParameter(paramDto)); + if (i == maxIndexParameters && paramDto.getParameterType() instanceof TypeNameArray) { methodBuilder.varargs(); // Arrays should be mapped to be generics } @@ -397,4 +388,20 @@ private MethodSpec createMethod( methodBuilder.addCode(codeBlock).addJavadoc("\n@return current instance of builder"); return methodBuilder.build(); } + + /** + * Creates a ParameterSpec from a MethodParameterDto, including any annotations. + * + * @param paramDto the parameter DTO containing type, name, and annotations + * @return the generated ParameterSpec + */ + private ParameterSpec createParameter(MethodParameterDto paramDto) { + com.palantir.javapoet.TypeName parameterType = map2ParameterType(paramDto.getParameterType()); + ParameterSpec.Builder paramBuilder = + ParameterSpec.builder(parameterType, paramDto.getParameterName()); + if (!paramDto.getAnnotations().isEmpty()) { + paramBuilder.addAnnotations(map2AnnotationSpecs(paramDto.getAnnotations())); + } + return paramBuilder.build(); + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java index c5f0d158..eeac23ba 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -84,13 +84,13 @@ public class Person { String generatedCode = loadGeneratedSource(compilation, "PersonBuilder"); ProcessorAsserts.assertGenerationSucceeded(compilation, "PersonBuilder", generatedCode); - // Verify that annotations are copied to builder fields + // Verify that annotations are copied to builder method parameters ProcessorAsserts.assertingResult( generatedCode, - // NotNull annotation should be on the name field - contains("@NotNull"), - // CustomAnnotation should be on the email field - contains("@CustomAnnotation(\"email-field\")")); + // NotNull annotation should be on the name method parameter + contains("name(@NotNull String name)"), + // CustomAnnotation should be on the email method parameter + contains("email(@CustomAnnotation(\"email-field\") String email)")); } @Test @@ -157,13 +157,13 @@ public Product( String generatedCode = loadGeneratedSource(compilation, "ProductBuilder"); ProcessorAsserts.assertGenerationSucceeded(compilation, "ProductBuilder", generatedCode); - // Verify that annotations from constructor parameters are copied to builder fields + // Verify that annotations from constructor parameters are copied to builder method parameters ProcessorAsserts.assertingResult( generatedCode, - // NotNull annotation should be on the name field - contains("@NotNull"), - // Positive annotation should be on the price field - contains("@Positive")); + // NotNull annotation should be on the name method parameter + contains("name(@NotNull String name)"), + // Positive annotation should be on the price method parameter + contains("price(@Positive double price)")); } @Test @@ -212,8 +212,8 @@ public class Person { String generatedCode = loadGeneratedSource(compilation, "PersonBuilder"); ProcessorAsserts.assertGenerationSucceeded(compilation, "PersonBuilder", generatedCode); - // Verify that NotNull annotation is copied - ProcessorAsserts.assertingResult(generatedCode, contains("@NotNull")); + // Verify that NotNull annotation is copied to method parameters + ProcessorAsserts.assertingResult(generatedCode, contains("name(@NotNull String name)")); // Verify that ignoredField is not in the builder (due to @IgnoreInBuilder) ProcessorAsserts.assertNotContaining(generatedCode, "ignoredField"); From f3b461c5276c768482407ea377f15d159ae329f2 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 23:18:30 +0200 Subject: [PATCH 05/11] Adding annotations to String.format function too --- .../processor/util/BuilderDefinitionCreator.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java index 978bf539..9a4dc666 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java @@ -234,11 +234,12 @@ private static boolean isMethodRelevantForBuilder( } private static void addAdditionalHelperMethodsForField( - FieldDto result, String fieldName, TypeName fieldType) { + FieldDto result, String fieldName, TypeName fieldType, List annotations) { // Check for String type (not array) and add format method if (isString(fieldType) && !(fieldType instanceof TypeNameArray)) { result.addMethod( - createStringFormatMethodWithTransform(fieldName, "String.format(format, args)")); + createStringFormatMethodWithTransform( + fieldName, "String.format(format, args)", annotations)); } // Only process generic types (List, Set, Map, Optional, etc.) @@ -274,7 +275,7 @@ private static void addAdditionalHelperMethodsForField( if (isString(innerType)) { result.addMethod( createStringFormatMethodWithTransform( - fieldName, "Optional.of(String.format(format, args))")); + fieldName, "Optional.of(String.format(format, args))", List.of())); } } } @@ -556,7 +557,7 @@ private static Optional createFieldDto( // Add consumer/supplier/helper methods addConsumerMethodsForField(field, fieldName, fieldType, param, fieldTypeElement, context); addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement); - addAdditionalHelperMethodsForField(field, fieldName, fieldType); + addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations); return Optional.of(field); } @@ -751,12 +752,14 @@ private static MethodDto createFieldSupplier(String fieldName, TypeName fieldTyp } private static MethodDto createStringFormatMethodWithTransform( - String fieldName, String transform) { + String fieldName, String transform, List annotations) { TypeName stringType = new TypeName("java.lang", "String"); MethodParameterDto formatParam = new MethodParameterDto(); formatParam.setParameterName("format"); formatParam.setParameterTypeName(stringType); + // Apply annotations to the format parameter (it's a String value) + annotations.forEach(formatParam::addAnnotation); MethodParameterDto argsParam = new MethodParameterDto(); argsParam.setParameterName("args"); From c54913e6553ae065906a530478d706e968f20f3e Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 23:21:33 +0200 Subject: [PATCH 06/11] Fixing creation of optionals with unknown values from user --- .../builders/processor/util/BuilderDefinitionCreator.java | 4 ++-- .../simple/builders/processor/BuilderProcessorTest.java | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java index 9a4dc666..5ec4d0cf 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java @@ -266,9 +266,9 @@ private static void addAdditionalHelperMethodsForField( result.addMethod( createFieldSetterWithTransform(fieldName, "Map.ofEntries(%s)", mapEntryType)); } else if (isOptional(fieldType) && innerTypesCnt == 1) { - // Add setter that accepts the inner type T and wraps it in Optional.of() + // Add setter that accepts the inner type T and wraps it in Optional.ofNullable() result.addMethod( - createFieldSetterWithTransform(fieldName, "Optional.of(%s)", innerTypes.get(0))); + createFieldSetterWithTransform(fieldName, "Optional.ofNullable(%s)", innerTypes.get(0))); // If Optional, add format method TypeName innerType = innerTypes.get(0); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index 85a8e6d3..89026937 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -620,12 +620,12 @@ void shouldGenerateOptionalSettersAndProviders() { "public WithOptionalsBuilder description(Supplier> descriptionSupplier)", "public WithOptionalsBuilder description(String description)", "public WithOptionalsBuilder description(String format, Object... args)", - "this.description = changedValue(Optional.of(description));", + "this.description = changedValue(Optional.ofNullable(description));", "private TrackedValue> description = unsetValue();", "public WithOptionalsBuilder age(Optional age)", "public WithOptionalsBuilder age(Supplier> ageSupplier)", "public WithOptionalsBuilder age(Integer age)", - "this.age = changedValue(Optional.of(age));", + "this.age = changedValue(Optional.ofNullable(age));", "private TrackedValue> age = unsetValue();"); } @@ -703,7 +703,7 @@ void shouldGenerateStringFormatMethodForOptionalString() { "private TrackedValue> message = unsetValue();", """ public WithOptionalStringBuilder message(String message) { - this.message = changedValue(Optional.of(message)); + this.message = changedValue(Optional.ofNullable(message)); return this; } """, From b985f41395870dcba46d6cf603e186c9eca6fdad Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 23:27:52 +0200 Subject: [PATCH 07/11] Changing test to have annotations in different package --- .../simple/builders/processor/AnnotationCopyTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java index eeac23ba..6d362dee 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -21,11 +21,11 @@ private Compilation compileSources(JavaFileObject... sources) { @Test void annotations_copiedToBuilderFields() { - String packageName = "test.annotations"; + String packageName = "test"; JavaFileObject notNullAnnotation = JavaFileObjects.forSourceString( - packageName + ".NotNull", + packageName + ".annotations.NotNull", """ package test.annotations; import java.lang.annotation.ElementType; @@ -41,7 +41,7 @@ void annotations_copiedToBuilderFields() { JavaFileObject customAnnotation = JavaFileObjects.forSourceString( - packageName + ".CustomAnnotation", + packageName + ".annotations.CustomAnnotation", """ package test.annotations; import java.lang.annotation.ElementType; @@ -60,8 +60,10 @@ void annotations_copiedToBuilderFields() { JavaFileObjects.forSourceString( packageName + ".Person", """ - package test.annotations; + package test; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import test.annotations.NotNull; + import test.annotations.CustomAnnotation; @SimpleBuilder public class Person { From 503aeb01f3d9734663aa2f582dc2ebe8e5d1596c Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 23:41:32 +0200 Subject: [PATCH 08/11] Adding tests for complex annotations and fixing implementation in extractor --- .../util/FieldAnnotationExtractor.java | 67 +++++++- .../processor/AnnotationCopyTest.java | 145 ++++++++++++++++++ 2 files changed, 208 insertions(+), 4 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java index a7f88595..671d9ac6 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java @@ -30,9 +30,11 @@ import java.util.Optional; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; import org.javahelpers.simple.builders.processor.dtos.AnnotationDto; import org.javahelpers.simple.builders.processor.dtos.TypeName; @@ -114,18 +116,75 @@ private static Optional extractAnnotation( } /** - * Formats an annotation value as a code string suitable for code generation. Uses {@link - * javax.lang.model.element.AnnotationValue#toString()} which is explicitly designed to return a - * form suitable for source code representation. + * Formats an annotation value as a code string suitable for code generation. Handles different + * types of annotation values including primitives, strings, enums, classes, arrays, and nested + * annotations. * * @param value the annotation value to format * @return formatted code string suitable for source code */ private static String formatAnnotationValue(javax.lang.model.element.AnnotationValue value) { - // AnnotationValue.toString() returns a form suitable for source code representation + Object actualValue = value.getValue(); + + // Handle enum constants - need to fully qualify them + if (actualValue instanceof VariableElement varElement + && varElement.getKind() == ElementKind.ENUM_CONSTANT) { + return formatEnumConstant(varElement); + } + + // Handle class literals - already properly formatted by toString() + if (actualValue instanceof DeclaredType) { + return value.toString(); + } + + // Handle arrays - need to recursively format elements + if (actualValue instanceof List list) { + return formatArray(list); + } + + // For all other types (primitives, strings, nested annotations), toString() works correctly return value.toString(); } + /** + * Formats an enum constant with its fully qualified name. + * + * @param varElement the variable element representing the enum constant + * @return fully qualified enum constant name + */ + private static String formatEnumConstant(VariableElement varElement) { + Element enumClass = varElement.getEnclosingElement(); + if (enumClass instanceof TypeElement enumType) { + return enumType.getQualifiedName() + "." + varElement.getSimpleName(); + } + return varElement.getSimpleName().toString(); + } + + /** + * Formats an array of annotation values. + * + * @param list the list of array elements + * @return formatted array string + */ + private static String formatArray(List list) { + if (list.isEmpty()) { + return "{}"; + } + StringBuilder sb = new StringBuilder("{"); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(", "); + } + if (list.get(i) instanceof javax.lang.model.element.AnnotationValue annotValue) { + sb.append(formatAnnotationValue(annotValue)); + } else { + sb.append(list.get(i).toString()); + } + } + sb.append("}"); + return sb.toString(); + } + /** * Determines if an annotation should be skipped (not copied to the builder). * diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java index 6d362dee..dac12bce 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -220,4 +220,149 @@ public class Person { // Verify that ignoredField is not in the builder (due to @IgnoreInBuilder) ProcessorAsserts.assertNotContaining(generatedCode, "ignoredField"); } + + @Test + void annotations_withComplexValues_copiedCorrectly() { + String packageName = "test.complex"; + + // Create an enum for testing + JavaFileObject priorityEnum = + JavaFileObjects.forSourceString( + packageName + ".Priority", + """ + package test.complex; + public enum Priority { + LOW, MEDIUM, HIGH + } + """); + + // Create a nested annotation + JavaFileObject metadataAnnotation = + JavaFileObjects.forSourceString( + packageName + ".Metadata", + """ + package test.complex; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + + @Retention(RetentionPolicy.RUNTIME) + public @interface Metadata { + String author(); + int version(); + } + """); + + // Create a complex annotation with various value types + JavaFileObject complexAnnotation = + JavaFileObjects.forSourceString( + packageName + ".ComplexAnnotation", + """ + package test.complex; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface ComplexAnnotation { + // Primitives + int intValue() default 42; + long longValue() default 100L; + boolean boolValue() default true; + double doubleValue() default 3.14; + + // String + String stringValue() default "default"; + + // Enum + Priority priority() default Priority.MEDIUM; + + // Class literal + Class type() default String.class; + + // Array + String[] tags() default {}; + int[] numbers() default {}; + + // Nested annotation + Metadata metadata() default @Metadata(author = "unknown", version = 1); + } + """); + + JavaFileObject task = + JavaFileObjects.forSourceString( + packageName + ".Task", + """ + package test.complex; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Task { + private String name; + private String description; + private String simpleField; + + public String getName() { return name; } + public void setName( + @ComplexAnnotation( + intValue = 123, + longValue = 999L, + boolValue = false, + doubleValue = 2.71, + stringValue = "test-value", + priority = Priority.HIGH, + type = Task.class, + tags = {"urgent", "important"}, + numbers = {1, 2, 3}, + metadata = @Metadata(author = "John", version = 2) + ) String name) { + this.name = name; + } + + public String getDescription() { return description; } + public void setDescription(@ComplexAnnotation String description) { + this.description = description; + } + + public String getSimpleField() { return simpleField; } + public void setSimpleField( + @ComplexAnnotation(stringValue = "custom", priority = Priority.LOW, tags = "single") + String simpleField) { + this.simpleField = simpleField; + } + } + """); + + Compilation compilation = + compileSources(priorityEnum, metadataAnnotation, complexAnnotation, task); + String generatedCode = loadGeneratedSource(compilation, "TaskBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "TaskBuilder", generatedCode); + + // Verify annotation with all value types is copied correctly + ProcessorAsserts.assertingResult( + generatedCode, + // Method with full annotation containing all parameter types + contains( + """ + public TaskBuilder name( + @ComplexAnnotation(intValue = 123, longValue = 999L, boolValue = false, doubleValue = 2.71, stringValue = "test-value", priority = test.complex.Priority.HIGH, type = test.complex.Task.class, tags = {"urgent", "important"}, numbers = {1, 2, 3}, metadata = @test.complex.Metadata(author="John", version=2)) String name)"""), + // Method with annotation using only default values + contains("public TaskBuilder description(@ComplexAnnotation String description)"), + // Method with annotation with partial parameter override + contains( + """ + public TaskBuilder simpleField( + @ComplexAnnotation(stringValue = "custom", priority = test.complex.Priority.LOW, tags = {"single"}) String simpleField)"""), + // Format method should also have the full annotation + contains( + """ + public TaskBuilder name( + @ComplexAnnotation(intValue = 123, longValue = 999L, boolValue = false, doubleValue = 2.71, stringValue = "test-value", priority = test.complex.Priority.HIGH, type = test.complex.Task.class, tags = {"urgent", "important"}, numbers = {1, 2, 3}, metadata = @test.complex.Metadata(author="John", version=2)) String format,"""), + // Format method with partial override + contains( + """ + public TaskBuilder simpleField( + @ComplexAnnotation(stringValue = "custom", priority = test.complex.Priority.LOW, tags = {"single"}) String format,""")); + } } From b2a6dd9fafb567ea216455ac2770da0ea868acd9 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Fri, 24 Oct 2025 23:55:04 +0200 Subject: [PATCH 09/11] Extending readme with annotation cloning --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 9646c7db..d002c737 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Simple Builders is a Java [annotation processor](https://docs.oracle.com/en/java - **Fluent API**: Clean, chainable API for object construction - **Collections Support**: Built-in support for collections and maps - **Nested Builders**: Automatic generation of nested object builders +- **Annotation Preservation**: Validation annotations are automatically copied to builder methods ## Requirements @@ -120,6 +121,41 @@ Person person = PersonBuilder.create() .build(); ``` +#### Validation Annotations + +Simple Builders preserves validation annotations on your builder methods: + +```java +import jakarta.validation.constraints.*; + +@SimpleBuilder +public class User { + private String email; + private int age; + + public String getEmail() { return email; } + public void setEmail(@Email @NotNull String email) { + this.email = email; + } + + public int getAge() { return age; } + public void setAge(@Min(18) int age) { + this.age = age; + } +} +``` + +The generated builder preserves these annotations: + +```java +User user = UserBuilder.create() + .email("user@example.com") // @Email and @NotNull are on the parameter + .age(25) // @Min(18) is on the parameter + .build(); +``` + +This ensures validation frameworks work seamlessly with builder-generated objects. + ### Collections and Nested Objects Simple Builders provides special handling for collections and nested objects: From 5b9cd135769177225cbc914759d914a6620d5b7e Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 25 Oct 2025 00:19:05 +0200 Subject: [PATCH 10/11] Extending tests --- .../processor/AnnotationCopyTest.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java index dac12bce..cf6eceba 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -365,4 +365,148 @@ public TaskBuilder name( public TaskBuilder simpleField( @ComplexAnnotation(stringValue = "custom", priority = test.complex.Priority.LOW, tags = {"single"}) String format,""")); } + + @Test + void annotations_javaLangAnnotations_notCopied() { + String packageName = "test.javafilter"; + + JavaFileObject service = + JavaFileObjects.forSourceString( + packageName + ".Service", + """ + package test.javafilter; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Service { + private String name; + + public String getName() { return name; } + + @Deprecated + @SuppressWarnings("unused") + public void setName(String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(service); + String generatedCode = loadGeneratedSource(compilation, "ServiceBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ServiceBuilder", generatedCode); + + // Verify that Java standard annotations are NOT copied to builder methods + ProcessorAsserts.assertNotContaining(generatedCode, "@Deprecated", "@SuppressWarnings"); + } + + @Test + void annotations_emptyArrayValues_formattedCorrectly() { + String packageName = "test.emptyarray"; + + JavaFileObject arrayAnnotation = + JavaFileObjects.forSourceString( + packageName + ".ArrayAnnotation", + """ + package test.emptyarray; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface ArrayAnnotation { + String[] tags() default {}; + int[] numbers() default {}; + } + """); + + JavaFileObject data = + JavaFileObjects.forSourceString( + packageName + ".Data", + """ + package test.emptyarray; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Data { + private String value; + + public String getValue() { return value; } + public void setValue(@ArrayAnnotation(tags = {}, numbers = {}) String value) { + this.value = value; + } + } + """); + + Compilation compilation = compileSources(arrayAnnotation, data); + String generatedCode = loadGeneratedSource(compilation, "DataBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "DataBuilder", generatedCode); + + // Verify empty arrays are formatted correctly + ProcessorAsserts.assertingResult( + generatedCode, + contains("@ArrayAnnotation(tags = {}, numbers = {})"), + contains( + "public DataBuilder value(@ArrayAnnotation(tags = {}, numbers = {}) String value)")); + } + + @Test + void annotations_mixedPrimitiveArrays_formattedCorrectly() { + String packageName = "test.mixedarray"; + + JavaFileObject rangeAnnotation = + JavaFileObjects.forSourceString( + packageName + ".Range", + """ + package test.mixedarray; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface Range { + int[] values(); + double[] decimals() default {1.0, 2.0}; + } + """); + + JavaFileObject measurement = + JavaFileObjects.forSourceString( + packageName + ".Measurement", + """ + package test.mixedarray; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Measurement { + private String sensor; + + public String getSensor() { return sensor; } + public void setSensor( + @Range(values = {0, 100, 255}, decimals = {0.5, 1.5, 2.5}) String sensor) { + this.sensor = sensor; + } + } + """); + + Compilation compilation = compileSources(rangeAnnotation, measurement); + String generatedCode = loadGeneratedSource(compilation, "MeasurementBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MeasurementBuilder", generatedCode); + + // Verify mixed primitive arrays are formatted correctly + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + public MeasurementBuilder sensor( + @Range(values = {0, 100, 255}, decimals = {0.5, 1.5, 2.5}) String sensor)"""), + // Format method should also have the annotation + contains( + """ + public MeasurementBuilder sensor( + @Range(values = {0, 100, 255}, decimals = {0.5, 1.5, 2.5}) String format,""")); + } } From 7b46d8ef701aeaa060e900f5850f0f2d4e40fe36 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 25 Oct 2025 00:41:58 +0200 Subject: [PATCH 11/11] Rethinking skipped annotations and extending tests --- .../util/FieldAnnotationExtractor.java | 12 +- .../processor/AnnotationCopyTest.java | 182 +++++++++++++++++- 2 files changed, 183 insertions(+), 11 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java index 671d9ac6..ccc572d4 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java @@ -196,10 +196,12 @@ private static boolean shouldSkipAnnotation(String qualifiedName) { if (qualifiedName.startsWith("org.javahelpers.simple.builders.")) { return true; } - // Skip standard Java annotations that are not relevant for builder fields - return qualifiedName.equals("java.lang.Override") - || qualifiedName.equals("java.lang.Deprecated") - || qualifiedName.equals("java.lang.SuppressWarnings") - || qualifiedName.startsWith("javax.annotation.processing."); + // Skip generated code annotations (these are for code generation metadata, not validation) + if (qualifiedName.equals("javax.annotation.Generated") + || qualifiedName.equals("javax.annotation.processing.Generated")) { + return true; + } + // Skip compiler-only annotations that are not relevant for builder parameters + return qualifiedName.equals("java.lang.SuppressWarnings"); } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java index cf6eceba..606b5404 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -367,7 +367,7 @@ public TaskBuilder simpleField( } @Test - void annotations_javaLangAnnotations_notCopied() { + void annotations_deprecatedCopied_suppressWarningsFiltered() { String packageName = "test.javafilter"; JavaFileObject service = @@ -383,9 +383,9 @@ public class Service { public String getName() { return name; } - @Deprecated - @SuppressWarnings("unused") - public void setName(String name) { + public void setName( + @Deprecated + @SuppressWarnings("unused") String name) { this.name = name; } } @@ -395,8 +395,178 @@ public void setName(String name) { String generatedCode = loadGeneratedSource(compilation, "ServiceBuilder"); ProcessorAsserts.assertGenerationSucceeded(compilation, "ServiceBuilder", generatedCode); - // Verify that Java standard annotations are NOT copied to builder methods - ProcessorAsserts.assertNotContaining(generatedCode, "@Deprecated", "@SuppressWarnings"); + // Verify that @Deprecated IS copied (developers need to know field is deprecated!) + ProcessorAsserts.assertingResult( + generatedCode, contains("public ServiceBuilder name(@Deprecated String name)")); + + // But @SuppressWarnings is NOT copied (compiler-only, not relevant for users) + ProcessorAsserts.assertNotContaining(generatedCode, "@SuppressWarnings"); + } + + @Test + void annotations_frameworkAnnotations_filtered() { + String packageName = "test.framework"; + + // Create an annotation in the SimpleBuilder framework package + JavaFileObject frameworkAnnotation = + JavaFileObjects.forSourceString( + "org.javahelpers.simple.builders.custom.FrameworkAnnotation", + """ + package org.javahelpers.simple.builders.custom; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface FrameworkAnnotation { + String value() default ""; + } + """); + + JavaFileObject entity = + JavaFileObjects.forSourceString( + packageName + ".Entity", + """ + package test.framework; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.custom.FrameworkAnnotation; + + @SimpleBuilder + public class Entity { + private String id; + + public String getId() { return id; } + + public void setId( + @FrameworkAnnotation("internal") String id) { + this.id = id; + } + } + """); + + Compilation compilation = compileSources(frameworkAnnotation, entity); + String generatedCode = loadGeneratedSource(compilation, "EntityBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "EntityBuilder", generatedCode); + + // Verify that SimpleBuilder framework annotations are NOT copied to builder methods + ProcessorAsserts.assertingResult( + generatedCode, + // Method should exist without the framework annotation + contains("public EntityBuilder id(String id)")); + + // Framework annotation should not appear anywhere in generated code + ProcessorAsserts.assertNotContaining(generatedCode, "@FrameworkAnnotation"); + } + + @Test + void annotations_customAnnotations_copiedCorrectly() { + String packageName = "test.custom"; + + // Create a custom annotation that should NOT be filtered + JavaFileObject validAnnotation = + JavaFileObjects.forSourceString( + packageName + ".ValidAnnotation", + """ + package test.custom; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + public @interface ValidAnnotation { + String value() default ""; + } + """); + + JavaFileObject model = + JavaFileObjects.forSourceString( + packageName + ".Model", + """ + package test.custom; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Model { + private String data; + + public String getData() { return data; } + public void setData(@ValidAnnotation("keep-me") String data) { + this.data = data; + } + } + """); + + Compilation compilation = compileSources(validAnnotation, model); + String generatedCode = loadGeneratedSource(compilation, "ModelBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ModelBuilder", generatedCode); + + // Verify that custom annotations ARE copied (not filtered out) + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + public ModelBuilder data( + @ValidAnnotation("keep-me") String data)""")); + } + + @Test + void annotations_generatedAnnotations_notCopied() { + String packageName = "test.generated"; + + // Create Generated annotation (commonly used by code generators) + JavaFileObject generatedAnnotation = + JavaFileObjects.forSourceString( + "javax.annotation.Generated", + """ + package javax.annotation; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.SOURCE) + @Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER}) + public @interface Generated { + String[] value(); + } + """); + + JavaFileObject model = + JavaFileObjects.forSourceString( + packageName + ".Model", + """ + package test.generated; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import javax.annotation.Generated; + + @SimpleBuilder + public class Model { + private String code; + + public String getCode() { return code; } + public void setCode(@Generated(\"SomeGenerator\") String code) { + this.code = code; + } + } + """); + + Compilation compilation = compileSources(generatedAnnotation, model); + String generatedCode = loadGeneratedSource(compilation, "ModelBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ModelBuilder", generatedCode); + + // Verify that @Generated annotations are NOT copied to parameters (they're metadata, not + // validation) + ProcessorAsserts.assertingResult( + generatedCode, + // Method should exist without @Generated on the parameter + contains("public ModelBuilder code(String code)")); + + // The annotation should not be on the method parameter + ProcessorAsserts.assertNotContaining(generatedCode, "code(@Generated"); } @Test