diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Ignore4BuilderGeneration.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Ignore4BuilderGeneration.java new file mode 100644 index 00000000..5e70c7b0 --- /dev/null +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Ignore4BuilderGeneration.java @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2026 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.core.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Placed on a class/record to exclude it from builder generation, even if {@code @SimpleBuilder} or + * a {@code @SimpleBuilder.Template} annotation is inherited from a parent type. + * + *

A type marked with this annotation is treated as having no builder available: other builders + * never reference a builder for it and fall back to plain setters. + * + *

This annotation is intentionally not {@code @Inherited}. It only suppresses builder + * generation for the exact type it is placed on and does not cascade to further subclasses, which + * may therefore still get a builder from an inherited {@code @SimpleBuilder} or template + * annotation. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.CLASS) +public @interface Ignore4BuilderGeneration {} diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java index 09111033..193ebd45 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java @@ -54,8 +54,19 @@ * *

Use {@link Template} to create reusable configuration presets. * + *

Related annotations: + * + *

+ * * @see Options * @see Template + * @see IgnoreInBuilder + * @see Ignore4BuilderGeneration */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7534ad7b..ee10d325 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -7,6 +7,7 @@ Simple-builders supports fine-grained configuration through the `@SimpleBuilder. - [Overview](#overview) - [Annotation Configuration](#annotation-configuration) - [Template Annotations](#template-annotations) +- [Excluding Types from Builder Generation](#excluding-types-from-builder-generation) - [Compiler Options](#compiler-options) - [Maven Configuration](#maven-configuration) - [Gradle Configuration](#gradle-configuration) @@ -117,6 +118,22 @@ public class PersonDto { } ``` +## Excluding Types from Builder Generation + +You can opt a whole DTO out of builder generation with `@Ignore4BuilderGeneration`. This is useful when a class inherits `@SimpleBuilder` or an `@SimpleBuilder.Template` annotation from a parent and you do not want a builder for that specific subclass. + +```java +import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; + +@Ignore4BuilderGeneration +public class IgnoredDto extends ParentDto { + // No builder will be generated for IgnoredDto, even if ParentDto carries + // @SimpleBuilder or an @Inherited template annotation. +} +``` + +A type marked with `@Ignore4BuilderGeneration` is treated as having **no builder available**. Other builders that reference it will fall back to plain setters instead of emitting nested-builder consumers. The annotation is intentionally **not** `@Inherited`, so it only suppresses the exact type it is placed on and does not cascade to further subclasses. + ## Compiler Options Set project-wide defaults via compiler options. These apply to all builders unless overridden by annotations. diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index 1c5786d0..5efaf2bd 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -32,6 +32,7 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.ProcessingEnvironment; @@ -39,10 +40,13 @@ import javax.annotation.processing.RoundEnvironment; import javax.annotation.processing.SupportedAnnotationTypes; import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; import javax.lang.model.element.TypeElement; +import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template; +import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.generators.integration.JacksonModuleGenerator; @@ -150,6 +154,23 @@ public boolean process(Set annotations, RoundEnvironment elementsToProcess.addAll(roundEnv.getElementsAnnotatedWith(annotation)); } + // Filter out elements explicitly opted out via @Ignore4BuilderGeneration. + // Only directly-declared annotations are checked: the type itself must carry + // the opt-out; a parent carrying it does not cascade, matching the fact that + // @Ignore4BuilderGeneration is intentionally NOT @Inherited. + elementsToProcess.removeIf( + element -> { + Optional ignoreAnnotation = + JavaLangAnalyser.findAnnotation(element, Ignore4BuilderGeneration.class); + if (ignoreAnnotation.isPresent()) { + context.debug( + "Skipping element '%s' due to @Ignore4BuilderGeneration opt-out.", + element.getSimpleName()); + return true; + } + return false; + }); + context.info("simple-builders: PROCESSING ROUND START"); context.debug( "simple-builders: Processing round started. Found %d annotated elements.", diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java index fbe566b0..d7610c4e 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java @@ -43,6 +43,7 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.type.TypeVariable; import javax.lang.model.util.SimpleTypeVisitor14; +import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.core.enums.AccessModifier; import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.method.MethodParameterDto; @@ -214,6 +215,11 @@ private static void setBuilderAndConstructorInfo( */ private static void setBuilderTypeIfAnnotated( TypeName typeName, TypeElement typeElement, ProcessingContext context) { + // Types explicitly opted out must never be referenced as builders by other DTOs. + if (JavaLangAnalyser.findAnnotation(typeElement, Ignore4BuilderGeneration.class).isPresent()) { + return; + } + Optional foundBuilderAnnotation = JavaLangAnalyser.findAnnotation( typeElement, org.javahelpers.simple.builders.core.annotations.SimpleBuilder.class); @@ -270,6 +276,12 @@ private static void setElementBuilderTypeForGenericCollections( return; } + // Opted-out element types must never be referenced as element builders. + if (JavaLangAnalyser.findAnnotation(elementTypeElement, Ignore4BuilderGeneration.class) + .isPresent()) { + return; + } + // Element type must have @SimpleBuilder annotation if (!hasSimpleBuilderAnnotation(elementTypeElement)) { return; diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/Ignore4BuilderGenerationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/Ignore4BuilderGenerationTest.java new file mode 100644 index 00000000..58b9b9ff --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/Ignore4BuilderGenerationTest.java @@ -0,0 +1,219 @@ +/* + * MIT License + * + * Copyright (c) 2026 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; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.assertContaining; +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.assertGenerationSucceeded; +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.notContains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; + +import com.google.testing.compile.Compilation; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests for the {@code @Ignore4BuilderGeneration} opt-out annotation. */ +class Ignore4BuilderGenerationTest { + + private Compilation compile(JavaFileObject... sourceFiles) { + return ProcessorTestUtils.createCompiler().compile(sourceFiles); + } + + /** + * (a) A subclass that inherits a template annotation from its parent and is annotated with + * {@code @Ignore4BuilderGeneration} must NOT get a builder, while the parent's builder is still + * generated. + */ + @Test + void subclassWithInheritedTemplateAndOptOutGeneratesParentButNotChild() { + JavaFileObject templateAnnotation = + ProcessorTestUtils.forSource( + """ + package test; + + import java.lang.annotation.ElementType; + import java.lang.annotation.Inherited; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder.Template(options = @SimpleBuilder.Options()) + @Inherited + @Retention(RetentionPolicy.CLASS) + @Target(ElementType.TYPE) + public @interface InheritedTemplate {} + """); + + JavaFileObject parentSource = + ProcessorTestUtils.forSource( + """ + package test; + + @InheritedTemplate + public class ParentDto { + private String name; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """); + + JavaFileObject childSource = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; + + @Ignore4BuilderGeneration + public class ChildDto extends ParentDto { } + """); + + Compilation compilation = compile(templateAnnotation, parentSource, childSource); + + assertThat(compilation).succeededWithoutWarnings(); + + String parentBuilder = loadGeneratedSource(compilation, "ParentDtoBuilder"); + assertGenerationSucceeded(compilation, "ParentDtoBuilder", parentBuilder); + assertContaining(parentBuilder, "public ParentDtoBuilder name(String name)"); + + Assertions.assertTrue( + compilation.generatedSourceFiles().stream() + .noneMatch(f -> f.getName().endsWith("ChildDtoBuilder.java")), + "ChildDto builder should not have been generated due to @Ignore4BuilderGeneration"); + } + + /** + * (b) A DTO field whose type is annotated with {@code @Ignore4BuilderGeneration} must not trigger + * nested-builder consumer generation in the referencing DTO. The referencing builder falls back + * to a plain setter and must not reference a non-existent builder class. + */ + @Test + void referencedOptedOutDtoFallsBackToPlainSetter() { + JavaFileObject ignoredDto = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; + + @SimpleBuilder + @Ignore4BuilderGeneration + public class IgnoredDto { + private String value; + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + } + """); + + JavaFileObject containerDto = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ContainerDto { + private IgnoredDto ignored; + + public IgnoredDto getIgnored() { return ignored; } + public void setIgnored(IgnoredDto ignored) { this.ignored = ignored; } + } + """); + + Compilation compilation = compile(ignoredDto, containerDto); + + assertThat(compilation).succeededWithoutWarnings(); + + Assertions.assertTrue( + compilation.generatedSourceFiles().stream() + .noneMatch(f -> f.getName().endsWith("IgnoredDtoBuilder.java")), + "IgnoredDto builder should not have been generated"); + + String containerBuilder = loadGeneratedSource(compilation, "ContainerDtoBuilder"); + assertGenerationSucceeded(compilation, "ContainerDtoBuilder", containerBuilder); + + ProcessorAsserts.assertingResult( + containerBuilder, + contains("public ContainerDtoBuilder ignored(IgnoredDto ignored)"), + notContains("IgnoredDtoBuilder"), + notContains("Consumer"), + notContains("Consumer")); + } + + /** + * (c) Regression: a normally annotated DTO without the opt-out still generates its builder and is + * still referenced by other DTOs. + */ + @Test + void normalDtoStillGeneratesBuilderAndIsReferencedByOthers() { + JavaFileObject normalDto = + ProcessorTestUtils.simpleBuilderClass( + "test", + "NormalDto", + """ + private String value; + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + """); + + JavaFileObject containerDto = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ReferenceDto { + private NormalDto normal; + + public NormalDto getNormal() { return normal; } + public void setNormal(NormalDto normal) { this.normal = normal; } + } + """); + + Compilation compilation = compile(normalDto, containerDto); + + assertThat(compilation).succeededWithoutWarnings(); + + String normalBuilder = loadGeneratedSource(compilation, "NormalDtoBuilder"); + assertGenerationSucceeded(compilation, "NormalDtoBuilder", normalBuilder); + + String referenceBuilder = loadGeneratedSource(compilation, "ReferenceDtoBuilder"); + assertGenerationSucceeded(compilation, "ReferenceDtoBuilder", referenceBuilder); + assertContaining( + referenceBuilder, "public ReferenceDtoBuilder normal(Consumer"); + } +}