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: 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..b06cad0c --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/AnnotationDto.java @@ -0,0 +1,82 @@ +/* + * 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 java.util.LinkedHashMap; +import java.util.Map; + +/** + * DTO representing an annotation to be copied from the target class field to the builder class + * 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; + + /** + * 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. + * + * @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 members (parameters) as key-value pairs. + * + * @return map of member names to their code string values + */ + public Map getMembers() { + return members; + } + + /** + * Adds an annotation member (parameter). + * + * @param name the member name + * @param value the member value as a code string + */ + public void addMember(String name, String value) { + this.members.put(name, value); + } +} 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 f2cab96f..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 @@ -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.) @@ -265,16 +266,16 @@ 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); if (isString(innerType)) { result.addMethod( createStringFormatMethodWithTransform( - fieldName, "Optional.of(String.format(format, args))")); + fieldName, "Optional.of(String.format(format, args))", List.of())); } } } @@ -547,22 +548,49 @@ 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 annotations from the field parameter + List annotations = FieldAnnotationExtractor.extractAnnotations(param, context); + + // 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); addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement); - addAdditionalHelperMethodsForField(field, fieldName, fieldType); + addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations); 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); @@ -724,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"); 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..ccc572d4 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/FieldAnnotationExtractor.java @@ -0,0 +1,207 @@ +/* + * 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 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; + +/** 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) { + 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. 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) { + 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). + * + * @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 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/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..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; @@ -246,6 +247,7 @@ 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", @@ -353,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 } @@ -386,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/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..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 @@ -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,33 @@ 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) { + 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(); + } + + /** + * 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..606b5404 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -0,0 +1,682 @@ +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"; + + JavaFileObject notNullAnnotation = + JavaFileObjects.forSourceString( + packageName + ".annotations.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 + ".annotations.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; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import test.annotations.NotNull; + import test.annotations.CustomAnnotation; + + @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 method parameters + ProcessorAsserts.assertingResult( + generatedCode, + // 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 + 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 method parameters + ProcessorAsserts.assertingResult( + generatedCode, + // 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 + 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 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"); + } + + @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,""")); + } + + @Test + void annotations_deprecatedCopied_suppressWarningsFiltered() { + 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; } + + public void setName( + @Deprecated + @SuppressWarnings("unused") String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(service); + String generatedCode = loadGeneratedSource(compilation, "ServiceBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ServiceBuilder", generatedCode); + + // 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 + 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,""")); + } +} 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; } """,