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 3bd1170c..18fd3a1d 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 @@ -41,6 +41,28 @@ /** Extractor for field annotations, converting them from Java model elements to DTOs. */ public final class FieldAnnotationExtractor { + /** + * List of predicates that determine which annotations should be skipped (not copied to the + * builder). Each predicate receives the fully qualified annotation name and returns true if the + * annotation should be filtered out. + * + *

To add a new filter, simply add a new predicate to this list with a descriptive comment. + */ + private static final java.util.List> ANNOTATION_FILTERS = + java.util.List.of( + // Skip SimpleBuilder framework annotations + name -> name.startsWith("org.javahelpers.simple.builders."), + // Skip code generation metadata annotations + name -> name.equals("javax.annotation.Generated"), + name -> name.equals("javax.annotation.processing.Generated"), + // Skip compiler-only annotations not relevant for builder parameters + name -> name.equals("java.lang.SuppressWarnings"), + // Skip @Valid annotation for cascading validation (jakarta.validation.Valid / + // javax.validation.Valid) - only meaningful on fields or method return types, not on + // builder method parameters where individual values are set + name -> name.equals("jakarta.validation.Valid"), + name -> name.equals("javax.validation.Valid")); + private FieldAnnotationExtractor() { // Private constructor to prevent instantiation } @@ -192,17 +214,7 @@ private static String formatArray(List list) { * @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"); + return ANNOTATION_FILTERS.stream().anyMatch(filter -> filter.test(qualifiedName)); } /** 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 b795d1bc..c78b800a 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 @@ -2,6 +2,7 @@ import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createMockAnnotation; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; @@ -25,37 +26,15 @@ 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 { - } - """); + createMockAnnotation( + packageName + ".annotations", "NotNull", "ElementType.FIELD, ElementType.PARAMETER"); 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 ""; - } - """); + createMockAnnotation( + packageName + ".annotations", + "CustomAnnotation", + "ElementType.FIELD, ElementType.PARAMETER", + "String value() default \"\";"); JavaFileObject person = JavaFileObjects.forSourceString( @@ -101,36 +80,10 @@ 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 { - } - """); + createMockAnnotation(packageName, "NotNull", "ElementType.FIELD, ElementType.PARAMETER"); 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 { - } - """); + createMockAnnotation(packageName, "Positive", "ElementType.FIELD, ElementType.PARAMETER"); JavaFileObject product = JavaFileObjects.forSourceString( @@ -174,20 +127,7 @@ 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 { - } - """); + createMockAnnotation(packageName, "NotNull", "ElementType.FIELD, ElementType.PARAMETER"); JavaFileObject person = JavaFileObjects.forSourceString( @@ -255,41 +195,32 @@ public enum Priority { // Create a complex annotation with various value types JavaFileObject complexAnnotation = - JavaFileObjects.forSourceString( - packageName + ".ComplexAnnotation", + createMockAnnotation( + packageName, + "ComplexAnnotation", + "ElementType.FIELD, ElementType.PARAMETER", """ - package test.complex; - import java.lang.annotation.ElementType; - import java.lang.annotation.Retention; - import java.lang.annotation.RetentionPolicy; - import java.lang.annotation.Target; + // Primitives + int intValue() default 42; + long longValue() default 100L; + boolean boolValue() default true; + double doubleValue() default 3.14; - @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"; - // String - String stringValue() default "default"; + // Enum + Priority priority() default Priority.MEDIUM; - // Enum - Priority priority() default Priority.MEDIUM; + // Class literal + Class type() default String.class; - // Class literal - Class type() default String.class; + // Array + String[] tags() default {}; + int[] numbers() default {}; - // Array - String[] tags() default {}; - int[] numbers() default {}; - - // Nested annotation - Metadata metadata() default @Metadata(author = "unknown", version = 1); - } - """); + // Nested annotation + Metadata metadata() default @Metadata(author = "unknown", version = 1);"""); JavaFileObject task = JavaFileObjects.forSourceString( @@ -410,21 +341,11 @@ void annotations_frameworkAnnotations_filtered() { // 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 ""; - } - """); + createMockAnnotation( + "org.javahelpers.simple.builders.custom", + "FrameworkAnnotation", + "ElementType.FIELD, ElementType.PARAMETER", + "String value() default \"\";"); JavaFileObject entity = JavaFileObjects.forSourceString( @@ -467,21 +388,11 @@ void annotations_customAnnotations_copiedCorrectly() { // 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 ""; - } - """); + createMockAnnotation( + packageName, + "ValidAnnotation", + "ElementType.FIELD, ElementType.PARAMETER", + "String value() default \"\";"); JavaFileObject model = JavaFileObjects.forSourceString( @@ -520,21 +431,11 @@ void annotations_generatedAnnotations_notCopied() { // 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(); - } - """); + createMockAnnotation( + "javax.annotation", + "Generated", + "ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER", + "String[] value();"); JavaFileObject model = JavaFileObjects.forSourceString( @@ -575,22 +476,11 @@ 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 {}; - } - """); + createMockAnnotation( + packageName, + "ArrayAnnotation", + "ElementType.FIELD, ElementType.PARAMETER", + "String[] tags() default {};\n int[] numbers() default {};"); JavaFileObject data = JavaFileObjects.forSourceString( @@ -627,22 +517,11 @@ 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}; - } - """); + createMockAnnotation( + packageName, + "Range", + "ElementType.FIELD, ElementType.PARAMETER", + "int[] values();\n double[] decimals() default {1.0, 2.0};"); JavaFileObject measurement = JavaFileObjects.forSourceString( @@ -680,4 +559,285 @@ public MeasurementBuilder sensor( public MeasurementBuilder sensor( @Range(values = {0, 100, 255}, decimals = {0.5, 1.5, 2.5}) String format,""")); } + + @Test + void annotations_validAnnotation_jakartaValidation_filteredFromBuilderParameters() { + String packageName = "test.jakarta"; + + JavaFileObject address = + JavaFileObjects.forSourceString( + packageName + ".Address", + """ + package test.jakarta; + + public class Address { + private String street; + private String city; + + public String getStreet() { return street; } + public void setStreet(String street) { this.street = street; } + + public String getCity() { return city; } + public void setCity(String city) { this.city = city; } + } + """); + + JavaFileObject person = + JavaFileObjects.forSourceString( + packageName + ".Person", + """ + package test.jakarta; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import jakarta.validation.Valid; + import jakarta.validation.constraints.NotNull; + + @SimpleBuilder + public class Person { + private String name; + private Address address; + + public String getName() { return name; } + public void setName(@NotNull String name) { this.name = name; } + + public Address getAddress() { return address; } + public void setAddress(@Valid @NotNull Address address) { this.address = address; } + } + """); + + Compilation compilation = + compileSources( + createJakartaValidAnnotation(), createJakartaNotNullAnnotation(), address, person); + String generatedCode = loadGeneratedSource(compilation, "PersonBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "PersonBuilder", generatedCode); + + ProcessorAsserts.assertingResult( + generatedCode, + contains("name(@NotNull String name)"), + contains("address(@NotNull Address address)")); + + ProcessorAsserts.assertNotContaining(generatedCode, "@Valid"); + } + + @Test + void annotations_validAnnotation_javaxValidation_filteredFromBuilderParameters() { + String packageName = "test.javax"; + + JavaFileObject contact = + JavaFileObjects.forSourceString( + packageName + ".Contact", + """ + package test.javax; + + public class Contact { + private String email; + private String phone; + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + } + """); + + JavaFileObject customer = + JavaFileObjects.forSourceString( + packageName + ".Customer", + """ + package test.javax; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import javax.validation.Valid; + import javax.validation.constraints.Size; + + @SimpleBuilder + public class Customer { + private String id; + private Contact contact; + + public String getId() { return id; } + public void setId(@Size(min = 5, max = 20) String id) { this.id = id; } + + public Contact getContact() { return contact; } + public void setContact(@Valid Contact contact) { this.contact = contact; } + } + """); + + Compilation compilation = + compileSources( + createMockAnnotation( + "javax.validation", + "Valid", + "ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD"), + createMockAnnotation( + "javax.validation.constraints", + "Size", + "ElementType.FIELD, ElementType.PARAMETER", + "int min() default 0;\n int max() default Integer.MAX_VALUE;"), + contact, + customer); + String generatedCode = loadGeneratedSource(compilation, "CustomerBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "CustomerBuilder", generatedCode); + + ProcessorAsserts.assertingResult( + generatedCode, + contains("id(@Size(min = 5, max = 20) String id)"), + contains("contact(Contact contact)")); + + ProcessorAsserts.assertNotContaining(generatedCode, "@Valid"); + } + + @Test + void annotations_validAnnotation_constructorParameters_filteredFromBuilderParameters() { + String packageName = "test.constructor"; + + JavaFileObject department = + JavaFileObjects.forSourceString( + packageName + ".Department", + """ + package test.constructor; + + public class Department { + private String name; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """); + + JavaFileObject employee = + JavaFileObjects.forSourceString( + packageName + ".Employee", + """ + package test.constructor; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import jakarta.validation.Valid; + import jakarta.validation.constraints.NotNull; + + @SimpleBuilder + public class Employee { + private final String name; + private final Department department; + + public Employee( + @NotNull String name, + @Valid @NotNull Department department) { + this.name = name; + this.department = department; + } + + public String getName() { return name; } + public Department getDepartment() { return department; } + } + """); + + Compilation compilation = + compileSources( + createJakartaValidAnnotation(), createJakartaNotNullAnnotation(), department, employee); + String generatedCode = loadGeneratedSource(compilation, "EmployeeBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "EmployeeBuilder", generatedCode); + + ProcessorAsserts.assertingResult( + generatedCode, + contains("name(@NotNull String name)"), + contains("department(@NotNull Department department)")); + + ProcessorAsserts.assertNotContaining(generatedCode, "@Valid"); + } + + @Test + void annotations_validAnnotation_mixedWithConstraints_onlyValidFiltered() { + String packageName = "test.mixed"; + + JavaFileObject metadata = + JavaFileObjects.forSourceString( + packageName + ".Metadata", + """ + package test.mixed; + + public class Metadata { + private String key; + private String value; + + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + } + """); + + JavaFileObject document = + JavaFileObjects.forSourceString( + packageName + ".Document", + """ + package test.mixed; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import jakarta.validation.Valid; + import jakarta.validation.constraints.NotNull; + import jakarta.validation.constraints.Size; + + @SimpleBuilder + public class Document { + private String title; + private Metadata metadata; + + public String getTitle() { return title; } + public void setTitle(@NotNull @Size(min = 1, max = 100) String title) { + this.title = title; + } + + public Metadata getMetadata() { return metadata; } + public void setMetadata(@Valid @NotNull Metadata metadata) { + this.metadata = metadata; + } + } + """); + + Compilation compilation = + compileSources( + createJakartaValidAnnotation(), + createJakartaNotNullAnnotation(), + createMockAnnotation( + "jakarta.validation.constraints", + "Size", + "ElementType.FIELD, ElementType.PARAMETER", + "int min() default 0;\n int max() default Integer.MAX_VALUE;"), + metadata, + document); + String generatedCode = loadGeneratedSource(compilation, "DocumentBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "DocumentBuilder", generatedCode); + + ProcessorAsserts.assertingResult( + generatedCode, + contains("title(@NotNull @Size(min = 1, max = 100) String title)"), + contains("metadata(@NotNull Metadata metadata)")); + + ProcessorAsserts.assertNotContaining(generatedCode, "@Valid"); + } + + /** + * Creates a mock jakarta.validation.Valid annotation for testing. + * + *

This helper is used multiple times across different test methods. + * + * @return a JavaFileObject representing the mocked Valid annotation + */ + private JavaFileObject createJakartaValidAnnotation() { + return createMockAnnotation( + "jakarta.validation", + "Valid", + "ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD"); + } + + /** + * Creates a mock jakarta.validation.constraints.NotNull annotation for testing. + * + *

This helper is used multiple times across different test methods. + * + * @return a JavaFileObject representing the mocked NotNull annotation + */ + private JavaFileObject createJakartaNotNullAnnotation() { + return createMockAnnotation( + "jakarta.validation.constraints", "NotNull", "ElementType.FIELD, ElementType.PARAMETER"); + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java index ab10e6e0..d7a70427 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java @@ -242,4 +242,71 @@ private static String[] buildSourceLines( System.arraycopy(footer, 0, lines, header.length + body.length, footer.length); return lines; } + + /** + * Creates a mock annotation for testing without any members. + * + *

Mock annotations are used instead of real dependencies because: + * + *

+ * + *

The processor only cares about the qualified name, not the actual implementation. + * + * @param packageName the package name of the annotation (e.g., "jakarta.validation") + * @param annotationName the simple name of the annotation (e.g., "Valid") + * @param targets the annotation targets (e.g., "ElementType.FIELD, ElementType.PARAMETER") + * @return a JavaFileObject representing the mocked annotation + */ + public static JavaFileObject createMockAnnotation( + String packageName, String annotationName, String targets) { + return createMockAnnotation(packageName, annotationName, targets, ""); + } + + /** + * Creates a mock annotation for testing with optional members. + * + *

Mock annotations are used instead of real dependencies because: + * + *

+ * + *

The processor only cares about the qualified name, not the actual implementation. + * + * @param packageName the package name of the annotation (e.g., "jakarta.validation") + * @param annotationName the simple name of the annotation (e.g., "Valid") + * @param targets the annotation targets (e.g., "ElementType.FIELD, ElementType.PARAMETER") + * @param members the annotation members/attributes (e.g., "int min() default 0;") + * @return a JavaFileObject representing the mocked annotation + */ + public static JavaFileObject createMockAnnotation( + String packageName, String annotationName, String targets, String members) { + String qualifiedName = packageName + "." + annotationName; + String membersSection = members.isEmpty() ? "" : "\n " + members + "\n"; + + return JavaFileObjects.forSourceString( + qualifiedName, + """ + package %s; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({%s}) + public @interface %s {%s} + """ + .formatted(packageName, targets, annotationName, membersSection)); + } }