From 00d2eadcd784d5804edd364b4498bce76222d0e0 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 26 Oct 2025 17:37:35 +0100 Subject: [PATCH 01/24] First implementation of supporting with-interface --- .../processor/dtos/BuilderDefinitionDto.java | 24 +++ .../builders/processor/dtos/MethodDto.java | 42 +++++ .../processor/dtos/NestedTypeDto.java | 96 ++++++++++++ .../util/BuilderDefinitionCreator.java | 103 ++++++++++++ .../processor/util/JavaCodeGenerator.java | 134 ++++++++++++++++ .../builders/processor/WithInterfaceTest.java | 147 ++++++++++++++++++ 6 files changed, 546 insertions(+) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java index 0a7a4e1d..4b375cc0 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java @@ -51,6 +51,12 @@ public class BuilderDefinitionDto { */ private final List fields = new LinkedList<>(); + /** + * Nested types (interfaces or classes) to be generated inside the builder, such as the "With" + * interface. + */ + private final List nestedTypes = new LinkedList<>(); + /** * Getting type of builder. * @@ -173,4 +179,22 @@ public void addGeneric(GenericParameterDto generic) { public List getGenerics() { return generics; } + + /** + * Returns the list of nested types (interfaces or classes) to be generated inside the builder. + * + * @return the list of nested types + */ + public List getNestedTypes() { + return nestedTypes; + } + + /** + * Adds a nested type definition to the builder. + * + * @param nestedType the nested type to add + */ + public void addNestedType(NestedTypeDto nestedType) { + this.nestedTypes.add(nestedType); + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java index 0d352648..787f108b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java @@ -46,6 +46,12 @@ public class MethodDto { /** Name of method. */ private String methodName; + /** Return type of method. */ + private TypeName returnType; + + /** Javadoc comment for the method. */ + private String javadoc; + /** List of parameters of Method. */ private final LinkedList parameters = new LinkedList<>(); @@ -178,4 +184,40 @@ public Optional getModifier() { public void setModifier(Modifier modifier) { this.modifier = Optional.ofNullable(modifier); } + + /** + * Gets the return type of the method. + * + * @return the return type as TypeName + */ + public TypeName getReturnType() { + return returnType; + } + + /** + * Sets the return type of the method. + * + * @param returnType the return type as TypeName + */ + public void setReturnType(TypeName returnType) { + this.returnType = returnType; + } + + /** + * Gets the Javadoc comment for the method. + * + * @return the Javadoc comment + */ + public String getJavadoc() { + return javadoc; + } + + /** + * Sets the Javadoc comment for the method. + * + * @param javadoc the Javadoc comment + */ + public void setJavadoc(String javadoc) { + this.javadoc = javadoc; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java new file mode 100644 index 00000000..857049f6 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java @@ -0,0 +1,96 @@ +/* + * 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.LinkedList; +import java.util.List; + +/** + * Represents a nested type (interface or class) to be generated inside the builder. + * + *

For example, the "With" interface that allows DTOs to implement fluent modification methods. + */ +public class NestedTypeDto { + + /** The simple name of the nested type (e.g., "With"). */ + private String typeName; + + /** The kind of nested type (INTERFACE or CLASS). */ + private NestedTypeKind kind; + + /** Whether this nested type should be public. */ + private boolean isPublic = true; + + /** Methods to be generated in this nested type. */ + private final List methods = new LinkedList<>(); + + /** Javadoc comment for this nested type. */ + private String javadoc; + + public enum NestedTypeKind { + INTERFACE, + CLASS + } + + public String getTypeName() { + return typeName; + } + + public void setTypeName(String typeName) { + this.typeName = typeName; + } + + public NestedTypeKind getKind() { + return kind; + } + + public void setKind(NestedTypeKind kind) { + this.kind = kind; + } + + public boolean isPublic() { + return isPublic; + } + + public void setPublic(boolean isPublic) { + this.isPublic = isPublic; + } + + public List getMethods() { + return methods; + } + + public void addMethod(MethodDto method) { + this.methods.add(method); + } + + public String getJavadoc() { + return javadoc; + } + + public void setJavadoc(String javadoc) { + this.javadoc = javadoc; + } +} 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 51d26f8a..507b6088 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 @@ -99,6 +99,10 @@ public static BuilderDefinitionDto extractFromElement( List setterFields = extractSetterFields(annotatedType, result, context); result.addAllFields(setterFields); + // Create the With interface + NestedTypeDto withInterface = createWithInterface(result, context); + result.addNestedType(withInterface); + return result; } @@ -898,4 +902,103 @@ private static TypeMirror extractFirstTypeArgument(TypeMirror typeMirror) { } return null; } + + /** + * Creates the "With" interface definition that allows the DTO to implement fluent modification + * methods. + * + * @param builderDef the builder definition containing type information + * @param context the processing context + * @return the nested type definition for the With interface + */ + private static NestedTypeDto createWithInterface( + BuilderDefinitionDto builderDef, ProcessingContext context) { + context.debug( + "Creating With interface for: %s", builderDef.getBuilderTypeName().getClassName()); + + NestedTypeDto withInterface = new NestedTypeDto(); + withInterface.setTypeName("With"); + withInterface.setKind(NestedTypeDto.NestedTypeKind.INTERFACE); + withInterface.setPublic(true); + withInterface.setJavadoc( + "Interface that can be implemented by the DTO to provide fluent modification methods."); + + // Create the first method: DtoType with(Consumer b) + MethodDto withConsumerMethod = createWithConsumerMethod(builderDef); + withInterface.addMethod(withConsumerMethod); + + // Create the second method: BuilderType with() + MethodDto withBuilderMethod = createWithBuilderMethod(builderDef); + withInterface.addMethod(withBuilderMethod); + + return withInterface; + } + + /** + * Creates the `DtoType with(Consumer b)` method definition. + * + * @param builderDef the builder definition + * @return the method definition + */ + private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDef) { + MethodDto method = new MethodDto(); + method.setMethodName("with"); + + // Return type is the DTO type + TypeName dtoType = builderDef.getBuildingTargetTypeName(); + method.setReturnType(dtoType); + + // Parameter: Consumer b + MethodParameterDto parameter = new MethodParameterDto(); + parameter.setParameterName("b"); + // For interface methods, we store the full type as a string + TypeName consumerType = + new TypeName( + "java.util.function", + "Consumer<" + builderDef.getBuilderTypeName().getClassName() + ">"); + parameter.setParameterTypeName(consumerType); + method.addParameter(parameter); + + // Add implementation (cast this to the DTO type) + method.setCode( + """ + $builderType:T builder = new $builderType:T(($dtoType:T) this); + b.accept(builder); + return builder.build(); + """); + method.addArgument("builderType", builderDef.getBuilderTypeName()); + method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); + + method.setJavadoc( + "Applies modifications to a builder initialized from this instance and returns the built object.\n\n" + + "@param b the consumer to apply modifications\n" + + "@return the modified instance"); + + return method; + } + + /** + * Creates the `BuilderType with()` method definition. + * + * @param builderDef the builder definition + * @return the method definition + */ + private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef) { + MethodDto method = new MethodDto(); + method.setMethodName("with"); + + // Return type is the Builder type + method.setReturnType(builderDef.getBuilderTypeName()); + + // Add implementation (cast this to the DTO type) + method.setCode("return new $builderType:T(($dtoType:T) this);\n"); + method.addArgument("builderType", builderDef.getBuilderTypeName()); + method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); + + method.setJavadoc( + "Creates a builder initialized from this instance.\n\n" + + "@return a builder initialized with this instance's values"); + + return method; + } } 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 017d972a..20695627 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 @@ -149,6 +149,13 @@ public void generateBuilder(BuilderDefinitionDto builderDef) throws BuilderExcep classBuilder.addMethod(createMethodConditional(builderTypeName)); classBuilder.addMethod(createMethodConditionalPositiveOnly(builderTypeName)); + // Adding nested types (e.g., With interface) + for (NestedTypeDto nestedType : builderDef.getNestedTypes()) { + TypeSpec nestedTypeSpec = createNestedType(nestedType, dtoBaseClass, builderBaseClass); + classBuilder.addType(nestedTypeSpec); + logger.debug(" Generated nested type: %s", nestedType.getTypeName()); + } + // Adding annotations classBuilder.addAnnotation(createAnnotationGenerated()); classBuilder.addAnnotation(createAnnotationBuilderImplementation(dtoBaseClass)); @@ -430,6 +437,133 @@ private MethodSpec createMethodConditionalPositiveOnly( .build(); } + /** + * Creates a nested type (interface or class) from the DTO definition. + * + * @param nestedType the nested type definition + * @param dtoClass the DTO class name + * @param builderClass the builder class name + * @return the TypeSpec for the nested type + */ + private TypeSpec createNestedType( + NestedTypeDto nestedType, ClassName dtoClass, ClassName builderClass) { + TypeSpec.Builder typeBuilder; + + if (nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE) { + typeBuilder = TypeSpec.interfaceBuilder(nestedType.getTypeName()); + } else { + typeBuilder = TypeSpec.classBuilder(nestedType.getTypeName()); + } + + if (nestedType.isPublic()) { + typeBuilder.addModifiers(PUBLIC); + } + + if (nestedType.getJavadoc() != null) { + typeBuilder.addJavadoc(nestedType.getJavadoc()); + } + + // Add methods to the nested type + for (MethodDto method : nestedType.getMethods()) { + MethodSpec methodSpec = createNestedTypeMethod(method, dtoClass, builderClass); + typeBuilder.addMethod(methodSpec); + } + + return typeBuilder.build(); + } + + /** + * Creates a method for a nested type (default interface method with body). + * + * @param method the method DTO + * @param dtoClass the DTO class name + * @param builderClass the builder class name + * @return the MethodSpec + */ + private MethodSpec createNestedTypeMethod( + MethodDto method, ClassName dtoClass, ClassName builderClass) { + MethodSpec.Builder methodBuilder = + MethodSpec.methodBuilder(method.getMethodName()).addModifiers(PUBLIC); + + // Set return type + if (method.getReturnType() != null) { + TypeName returnTypeName = method.getReturnType(); + if (returnTypeName.getClassName().equals(dtoClass.simpleName())) { + methodBuilder.returns(dtoClass); + } else if (returnTypeName.getClassName().equals(builderClass.simpleName())) { + methodBuilder.returns(builderClass); + } else { + // Use the return type as-is (construct ClassName from TypeName) + if (returnTypeName.getPackageName() != null && !returnTypeName.getPackageName().isEmpty()) { + methodBuilder.returns( + ClassName.get(returnTypeName.getPackageName(), returnTypeName.getClassName())); + } else { + methodBuilder.returns(ClassName.bestGuess(returnTypeName.getClassName())); + } + } + } + + // Add parameters + for (MethodParameterDto param : method.getParameters()) { + com.palantir.javapoet.TypeName paramType; + String typeStr = param.getParameterType().getClassName(); + + // Handle Consumer + if (typeStr.startsWith("Consumer<")) { + paramType = + ParameterizedTypeName.get( + ClassName.get(java.util.function.Consumer.class), builderClass); + } else { + paramType = ClassName.bestGuess(typeStr); + } + + methodBuilder.addParameter(paramType, param.getParameterName()); + } + + // Add Javadoc + if (method.getJavadoc() != null) { + methodBuilder.addJavadoc(method.getJavadoc()); + } + + // Add default modifier and method body for interface methods + methodBuilder.addModifiers(javax.lang.model.element.Modifier.DEFAULT); + + // Add method body from the MethodCodeDto + MethodCodeDto codeDto = method.getMethodCodeDto(); + if (codeDto.getCodeFormat() != null && !codeDto.getCodeFormat().isEmpty()) { + // Build code with type arguments + String code = codeDto.getCodeFormat(); + java.util.List args = new java.util.ArrayList<>(); + + // Replace placeholders with appropriate classes (count how many times each placeholder + // appears) + for (MethodCodePlaceholder placeholder : codeDto.getCodeArguments()) { + if (placeholder instanceof MethodCodeTypePlaceholder) { + String placeholderStr = "$" + placeholder.getLabel() + ":T"; + // Count occurrences + int count = 0; + int index = 0; + while ((index = code.indexOf(placeholderStr, index)) != -1) { + count++; + index += placeholderStr.length(); + } + // Replace all occurrences with $T + code = code.replace(placeholderStr, "$T"); + // Determine which class to use based on placeholder label + ClassName classToUse = placeholder.getLabel().equals("dtoType") ? dtoClass : builderClass; + // Add class to args for each occurrence + for (int i = 0; i < count; i++) { + args.add(classToUse); + } + } + } + + methodBuilder.addCode(code, args.toArray()); + } + + return methodBuilder.build(); + } + private List createFieldMethods( FieldDto fieldDto, com.palantir.javapoet.TypeName builderTypeName) { return fieldDto.getMethods().stream() diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java new file mode 100644 index 00000000..9b94c81c --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java @@ -0,0 +1,147 @@ +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; + +/** Tests for With interface generation in builders. */ +class WithInterfaceTest { + + private Compilation compileSources(JavaFileObject... sources) { + return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources); + } + + @Test + void withInterface_generatedInBuilder() { + String packageName = "test.withinterface"; + + JavaFileObject project = + JavaFileObjects.forSourceString( + packageName + ".Project", + """ + package test.withinterface; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Project { + private String name; + private String description; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } + """); + + Compilation compilation = compileSources(project); + String generatedCode = loadGeneratedSource(compilation, "ProjectBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ProjectBuilder", generatedCode); + + // Verify With interface is declared + ProcessorAsserts.assertingResult( + generatedCode, + contains("public interface With {"), + contains( + "Interface that can be implemented by the DTO to provide fluent modification methods")); + + // Verify first method with default implementation + ProcessorAsserts.assertingResult( + generatedCode, + contains("default Project with(Consumer b)"), + contains("Applies modifications to a builder initialized from this instance"), + contains("@param b the consumer to apply modifications"), + contains("@return the modified instance"), + contains("ProjectBuilder builder = new ProjectBuilder((Project) this)"), + contains("b.accept(builder)"), + contains("return builder.build()")); + + // Verify second method with default implementation + ProcessorAsserts.assertingResult( + generatedCode, + contains("default ProjectBuilder with()"), + contains("Creates a builder initialized from this instance"), + contains("@return a builder initialized with this instance's values"), + contains("return new ProjectBuilder((Project) this)")); + } + + @Test + void withInterface_worksWithConstructorFields() { + String packageName = "test.withinterface.constructor"; + + JavaFileObject user = + JavaFileObjects.forSourceString( + packageName + ".User", + """ + package test.withinterface.constructor; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class User { + private final String username; + private String email; + + public User(String username) { + this.username = username; + } + + public String getUsername() { return username; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + } + """); + + Compilation compilation = compileSources(user); + String generatedCode = loadGeneratedSource(compilation, "UserBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "UserBuilder", generatedCode); + + // Verify With interface exists with default implementations + ProcessorAsserts.assertingResult( + generatedCode, + contains("public interface With {"), + contains("default User with(Consumer b)"), + contains("default UserBuilder with()"), + contains("return new UserBuilder((User) this)")); + } + + @Test + void withInterface_correctTypeNames() { + String packageName = "test.withinterface.types"; + + JavaFileObject config = + JavaFileObjects.forSourceString( + packageName + ".Config", + """ + package test.withinterface.types; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Config { + private int timeout; + private boolean enabled; + + public int getTimeout() { return timeout; } + public void setTimeout(int timeout) { this.timeout = timeout; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + } + """); + + Compilation compilation = compileSources(config); + String generatedCode = loadGeneratedSource(compilation, "ConfigBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ConfigBuilder", generatedCode); + + // Verify return types match the correct classes with default implementations + ProcessorAsserts.assertingResult( + generatedCode, + contains("default Config with(Consumer b)"), + contains("default ConfigBuilder with()"), + contains("return new ConfigBuilder((Config) this)")); + } +} From de1474aede60dcbb5403d02ec52b06545ccc7bf8 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 26 Oct 2025 17:45:28 +0100 Subject: [PATCH 02/24] Improving readability of asserts in WithInterfaceTest --- .../builders/processor/WithInterfaceTest.java | 131 +++++++++++++----- 1 file changed, 93 insertions(+), 38 deletions(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java index 9b94c81c..8416b46c 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java @@ -44,31 +44,37 @@ public class Project { String generatedCode = loadGeneratedSource(compilation, "ProjectBuilder"); ProcessorAsserts.assertGenerationSucceeded(compilation, "ProjectBuilder", generatedCode); - // Verify With interface is declared - ProcessorAsserts.assertingResult( - generatedCode, - contains("public interface With {"), - contains( - "Interface that can be implemented by the DTO to provide fluent modification methods")); - - // Verify first method with default implementation - ProcessorAsserts.assertingResult( - generatedCode, - contains("default Project with(Consumer b)"), - contains("Applies modifications to a builder initialized from this instance"), - contains("@param b the consumer to apply modifications"), - contains("@return the modified instance"), - contains("ProjectBuilder builder = new ProjectBuilder((Project) this)"), - contains("b.accept(builder)"), - contains("return builder.build()")); - - // Verify second method with default implementation - ProcessorAsserts.assertingResult( - generatedCode, - contains("default ProjectBuilder with()"), - contains("Creates a builder initialized from this instance"), - contains("@return a builder initialized with this instance's values"), - contains("return new ProjectBuilder((Project) this)")); + // Verify complete With interface is generated with default implementations + String expectedWithInterface = + """ + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Applies modifications to a builder initialized from this instance and returns the built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default Project with(Consumer b) { + ProjectBuilder builder = new ProjectBuilder((Project) this); + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default ProjectBuilder with() { + return new ProjectBuilder((Project) this); + } + } + """; + + ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface)); } @Test @@ -101,13 +107,37 @@ public User(String username) { String generatedCode = loadGeneratedSource(compilation, "UserBuilder"); ProcessorAsserts.assertGenerationSucceeded(compilation, "UserBuilder", generatedCode); - // Verify With interface exists with default implementations - ProcessorAsserts.assertingResult( - generatedCode, - contains("public interface With {"), - contains("default User with(Consumer b)"), - contains("default UserBuilder with()"), - contains("return new UserBuilder((User) this)")); + // Verify complete With interface with default implementations for constructor fields + String expectedWithInterface = + """ + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Applies modifications to a builder initialized from this instance and returns the built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default User with(Consumer b) { + UserBuilder builder = new UserBuilder((User) this); + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default UserBuilder with() { + return new UserBuilder((User) this); + } + } + """; + + ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface)); } @Test @@ -137,11 +167,36 @@ public class Config { String generatedCode = loadGeneratedSource(compilation, "ConfigBuilder"); ProcessorAsserts.assertGenerationSucceeded(compilation, "ConfigBuilder", generatedCode); - // Verify return types match the correct classes with default implementations - ProcessorAsserts.assertingResult( - generatedCode, - contains("default Config with(Consumer b)"), - contains("default ConfigBuilder with()"), - contains("return new ConfigBuilder((Config) this)")); + // Verify complete With interface with correct type names (primitives) + String expectedWithInterface = + """ + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Applies modifications to a builder initialized from this instance and returns the built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default Config with(Consumer b) { + ConfigBuilder builder = new ConfigBuilder((Config) this); + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default ConfigBuilder with() { + return new ConfigBuilder((Config) this); + } + } + """; + + ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface)); } } From c67bce5b97cb830ba35bf9d8defda582350a0572 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 26 Oct 2025 19:44:49 +0100 Subject: [PATCH 03/24] Refactoring code to always set returnType on methodDto and generate code based on that --- .../util/BuilderDefinitionCreator.java | 194 ++++++++++++------ .../processor/util/JavaCodeGenerator.java | 25 +-- 2 files changed, 137 insertions(+), 82 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 507b6088..30b68aaa 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 @@ -93,7 +93,7 @@ public static BuilderDefinitionDto extractFromElement( BuilderDefinitionDto result = initializeBuilderDefinition(annotatedType, context); - List constructorFields = extractConstructorFields(annotatedType, context); + List constructorFields = extractConstructorFields(annotatedType, result, context); result.addAllFieldsInConstructor(constructorFields); List setterFields = extractSetterFields(annotatedType, result, context); @@ -130,7 +130,7 @@ private static BuilderDefinitionDto initializeBuilderDefinition( * @return list of fields extracted from constructor parameters */ private static List extractConstructorFields( - TypeElement annotatedType, ProcessingContext context) { + TypeElement annotatedType, BuilderDefinitionDto builderDef, ProcessingContext context) { List constructorFields = new LinkedList<>(); Optional constructorOpt = findConstructorForBuilder(annotatedType, context); if (constructorOpt.isPresent()) { @@ -140,7 +140,8 @@ private static List extractConstructorFields( ctor.getSimpleName(), ctor.getParameters().size()); for (VariableElement param : ctor.getParameters()) { Optional fieldFromCtor = - createFieldFromConstructor(annotatedType, param, context); + createFieldFromConstructor( + annotatedType, param, builderDef.getBuilderTypeName(), context); if (fieldFromCtor.isPresent()) { FieldDto field = fieldFromCtor.get(); logFieldAddition(field, context); @@ -178,7 +179,8 @@ private static List extractSetterFields( mth.getSimpleName(), mth.getParameters().size()); if (isMethodRelevantForBuilder(mth, context)) { - Optional maybeField = createFieldFromSetter(mth, context); + Optional maybeField = + createFieldFromSetter(mth, result.getBuilderTypeName(), context); if (maybeField.isPresent()) { processedCount++; FieldDto field = maybeField.get(); @@ -238,12 +240,16 @@ private static boolean isMethodRelevantForBuilder( } private static void addAdditionalHelperMethodsForField( - FieldDto result, String fieldName, TypeName fieldType, List annotations) { + FieldDto result, + String fieldName, + TypeName fieldType, + List annotations, + TypeName builderType) { // Check for String type (not array) and add format method if (isString(fieldType) && !(fieldType instanceof TypeNameArray)) { result.addMethod( createStringFormatMethodWithTransform( - fieldName, "String.format(format, args)", annotations)); + fieldName, "String.format(format, args)", annotations, builderType)); } // Only process generic types (List, Set, Map, Optional, etc.) @@ -257,29 +263,31 @@ private static void addAdditionalHelperMethodsForField( if (isList(fieldType) && innerTypesCnt == 1) { result.addMethod( createFieldSetterWithTransform( - fieldName, "List.of(%s)", new TypeNameArray(innerTypes.get(0), false))); + fieldName, "List.of(%s)", new TypeNameArray(innerTypes.get(0), false), builderType)); } else if (isSet(fieldType) && innerTypesCnt == 1) { result.addMethod( createFieldSetterWithTransform( - fieldName, "Set.of(%s)", new TypeNameArray(innerTypes.get(0), true))); + fieldName, "Set.of(%s)", new TypeNameArray(innerTypes.get(0), true), builderType)); } else if (isMap(fieldType) && innerTypesCnt == 2) { TypeName mapEntryType = new TypeNameArray( new TypeNameGeneric("java.util", "Map.Entry", innerTypes.get(0), innerTypes.get(1)), false); result.addMethod( - createFieldSetterWithTransform(fieldName, "Map.ofEntries(%s)", mapEntryType)); + createFieldSetterWithTransform( + fieldName, "Map.ofEntries(%s)", mapEntryType, builderType)); } else if (isOptional(fieldType) && innerTypesCnt == 1) { // Add setter that accepts the inner type T and wraps it in Optional.ofNullable() result.addMethod( - createFieldSetterWithTransform(fieldName, "Optional.ofNullable(%s)", innerTypes.get(0))); + createFieldSetterWithTransform( + fieldName, "Optional.ofNullable(%s)", innerTypes.get(0), builderType)); // If Optional, add format method TypeName innerType = innerTypes.get(0); if (isString(innerType)) { result.addMethod( createStringFormatMethodWithTransform( - fieldName, "Optional.of(String.format(format, args))", List.of())); + fieldName, "Optional.of(String.format(format, args))", List.of(), builderType)); } } } @@ -290,6 +298,7 @@ private static void addConsumerMethodsForField( TypeName fieldType, VariableElement fieldParameter, TypeElement fieldTypeElement, + TypeName builderType, ProcessingContext context) { // Do not generate supplier methods for generic type variables (e.g., T) if (fieldType instanceof TypeNameVariable) { @@ -300,12 +309,13 @@ private static void addConsumerMethodsForField( return; } - if (!tryAddBuilderConsumer(result, fieldName, fieldParameter, context) - && !tryAddFieldConsumer(result, fieldName, fieldType, fieldTypeElement, context) - && !tryAddListConsumer(result, fieldName, fieldType, fieldParameter, context) - && !tryAddMapConsumer(result, fieldName, fieldType) - && !tryAddSetConsumer(result, fieldName, fieldType, fieldParameter, context)) { - tryAddStringBuilderConsumer(result, fieldName, fieldType); + if (!tryAddBuilderConsumer(result, fieldName, fieldParameter, builderType, context) + && !tryAddFieldConsumer( + result, fieldName, fieldType, fieldTypeElement, builderType, context) + && !tryAddListConsumer(result, fieldName, fieldType, fieldParameter, builderType, context) + && !tryAddMapConsumer(result, fieldName, fieldType, builderType) + && !tryAddSetConsumer(result, fieldName, fieldType, fieldParameter, builderType, context)) { + tryAddStringBuilderConsumer(result, fieldName, fieldType, builderType); } } @@ -314,12 +324,14 @@ private static boolean tryAddBuilderConsumer( FieldDto result, String fieldName, VariableElement fieldParameter, + TypeName builderType, ProcessingContext context) { - Optional builderTypeOpt = resolveBuilderType(fieldParameter, context); - if (builderTypeOpt.isPresent()) { - TypeName builderType = builderTypeOpt.get(); + Optional fieldBuilderOpt = resolveBuilderType(fieldParameter, context); + if (fieldBuilderOpt.isPresent()) { + TypeName fieldBuilderType = fieldBuilderOpt.get(); result.addMethod( - BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderType)); + BuilderDefinitionCreator.createFieldConsumerWithBuilder( + fieldName, fieldBuilderType, builderType)); return true; } return false; @@ -331,6 +343,7 @@ private static boolean tryAddFieldConsumer( String fieldName, TypeName fieldType, TypeElement fieldTypeElement, + TypeName builderType, ProcessingContext context) { if (!isJavaClass(fieldType) && fieldTypeElement != null @@ -338,7 +351,7 @@ private static boolean tryAddFieldConsumer( && !fieldTypeElement.getModifiers().contains(Modifier.ABSTRACT) && hasEmptyConstructor(fieldTypeElement, context)) { // Only generate a Consumer for concrete classes with an accessible empty constructor - result.addMethod(createFieldConsumer(fieldName, fieldType)); + result.addMethod(createFieldConsumer(fieldName, fieldType, builderType)); return true; } return false; @@ -346,11 +359,11 @@ && hasEmptyConstructor(fieldTypeElement, context)) { /** Tries to add StringBuilder-based consumer for String and Optional. */ private static boolean tryAddStringBuilderConsumer( - FieldDto result, String fieldName, TypeName fieldType) { + FieldDto result, String fieldName, TypeName fieldType, TypeName builderType) { if (shouldGenerateStringBuilderConsumer(fieldType)) { String transform = isOptionalString(fieldType) ? "Optional.of(builder.toString())" : "builder.toString()"; - result.addMethod(createStringBuilderConsumer(fieldName, transform)); + result.addMethod(createStringBuilderConsumer(fieldName, transform, builderType)); return true; } return false; @@ -362,6 +375,7 @@ private static boolean tryAddListConsumer( String fieldName, TypeName fieldType, VariableElement fieldParameter, + TypeName builderType, ProcessingContext context) { if (!(isList(fieldType) && fieldType instanceof TypeNameGeneric fieldTypeGeneric @@ -380,23 +394,27 @@ private static boolean tryAddListConsumer( if (elementBuilderType.isPresent()) { // Element type has a builder - use ArrayListBuilderWithElementBuilders - TypeName builderType = + TypeName collectionBuilderType = new TypeNameGeneric( map2TypeName(ArrayListBuilderWithElementBuilders.class), elementType, elementBuilderType.get()); result.addMethod( - createFieldConsumerWithElementBuilders(fieldName, builderType, elementBuilderType.get())); + createFieldConsumerWithElementBuilders( + fieldName, collectionBuilderType, elementBuilderType.get(), builderType)); } else { // Regular ArrayListBuilder - TypeName builderType = map2TypeName(ArrayListBuilder.class); - result.addMethod(createFieldConsumerWithBuilder(fieldName, builderType, elementType)); + TypeName collectionBuilderType = map2TypeName(ArrayListBuilder.class); + result.addMethod( + createFieldConsumerWithBuilder( + fieldName, collectionBuilderType, elementType, builderType)); } return true; } /** Tries to add Map-specific consumer methods. Returns true if handled. */ - private static boolean tryAddMapConsumer(FieldDto result, String fieldName, TypeName fieldType) { + private static boolean tryAddMapConsumer( + FieldDto result, String fieldName, TypeName fieldType, TypeName builderType) { if (!(isMap(fieldType) && fieldType instanceof TypeNameGeneric fieldTypeGeneric && fieldTypeGeneric.getInnerTypeArguments().size() == 2)) { @@ -409,7 +427,8 @@ private static boolean tryAddMapConsumer(FieldDto result, String fieldName, Type fieldTypeGeneric.getInnerTypeArguments().get(0), fieldTypeGeneric.getInnerTypeArguments().get(1)); MethodDto mapConsumerWithBuilder = - BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderTargetTypeName); + BuilderDefinitionCreator.createFieldConsumerWithBuilder( + fieldName, builderTargetTypeName, builderType); result.addMethod(mapConsumerWithBuilder); return true; } @@ -420,6 +439,7 @@ private static boolean tryAddSetConsumer( String fieldName, TypeName fieldType, VariableElement fieldParameter, + TypeName builderType, ProcessingContext context) { if (!(isSet(fieldType) && fieldType instanceof TypeNameGeneric fieldTypeGeneric @@ -438,34 +458,40 @@ private static boolean tryAddSetConsumer( if (elementBuilderType.isPresent()) { // Element type has a builder - use HashSetBuilderWithElementBuilders - TypeName builderType = + TypeName collectionBuilderType = new TypeNameGeneric( map2TypeName(HashSetBuilderWithElementBuilders.class), elementType, elementBuilderType.get()); result.addMethod( - createFieldConsumerWithElementBuilders(fieldName, builderType, elementBuilderType.get())); + createFieldConsumerWithElementBuilders( + fieldName, collectionBuilderType, elementBuilderType.get(), builderType)); } else { // Regular HashSetBuilder - TypeName builderType = map2TypeName(HashSetBuilder.class); - result.addMethod(createFieldConsumerWithBuilder(fieldName, builderType, elementType)); + TypeName collectionBuilderType = map2TypeName(HashSetBuilder.class); + result.addMethod( + createFieldConsumerWithBuilder( + fieldName, collectionBuilderType, elementType, builderType)); } return true; } private static void addSupplierMethodsForField( - FieldDto result, String fieldName, TypeName fieldType, TypeElement fieldTypeElement) { + FieldDto result, + String fieldName, + TypeName fieldType, + TypeElement fieldTypeElement, + TypeName builderType) { // Skip supplier generation for functional interfaces if (isFunctionalInterface(fieldTypeElement)) { return; } - // For all fields including Optional, use the real field type for suppliers - result.addMethod(createFieldSupplier(fieldName, fieldType)); + result.addMethod(createFieldSupplier(fieldName, fieldType, builderType)); } private static Optional createFieldFromSetter( - ExecutableElement mth, ProcessingContext context) { + ExecutableElement mth, TypeName builderType, ProcessingContext context) { String methodName = mth.getSimpleName().toString(); String fieldName = StringUtils.uncapitalize(Strings.CI.removeStart(methodName, "set")); @@ -498,7 +524,7 @@ private static Optional createFieldFromSetter( javaDoc = fieldName; } - return createFieldDto(fieldName, javaDoc, fieldParameter, dtoType, context); + return createFieldDto(fieldName, javaDoc, fieldParameter, dtoType, builderType, context); } /** @@ -506,14 +532,18 @@ private static Optional createFieldFromSetter( * the constructor argument. */ private static Optional createFieldFromConstructor( - TypeElement dtoType, VariableElement param, ProcessingContext context) { + TypeElement annotatedType, + VariableElement param, + TypeName builderType, + ProcessingContext context) { String fieldName = param.getSimpleName().toString(); // Set javadoc (default to field name if no javadoc found) - String javaDoc = JavaLangAnalyser.extractParamJavaDoc(context.getDocComment(dtoType), param); + String javaDoc = + JavaLangAnalyser.extractParamJavaDoc(context.getDocComment(annotatedType), param); if (javaDoc == null) { javaDoc = fieldName; } - return createFieldDto(fieldName, javaDoc, param, dtoType, context); + return createFieldDto(fieldName, javaDoc, param, annotatedType, builderType, context); } /** @@ -532,6 +562,7 @@ private static Optional createFieldDto( String javaDoc, VariableElement param, TypeElement dtoType, + TypeName builderType, ProcessingContext context) { MethodParameterDto paramDto = map2MethodParameter(param, context); if (paramDto == null) { @@ -562,12 +593,14 @@ private static Optional createFieldDto( } // Add basic setter method with annotations - field.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType, annotations)); + field.addMethod( + createFieldSetterWithTransform(fieldName, null, fieldType, annotations, builderType)); // Add consumer/supplier/helper methods - addConsumerMethodsForField(field, fieldName, fieldType, param, fieldTypeElement, context); - addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement); - addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations); + addConsumerMethodsForField( + field, fieldName, fieldType, param, fieldTypeElement, builderType, context); + addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement, builderType); + addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations, builderType); return Optional.of(field); } @@ -581,8 +614,8 @@ private static Optional createFieldDto( * @return the method DTO for the setter */ private static MethodDto createFieldSetterWithTransform( - String fieldName, String transform, TypeName fieldType) { - return createFieldSetterWithTransform(fieldName, transform, fieldType, List.of()); + String fieldName, String transform, TypeName fieldType, TypeName builderType) { + return createFieldSetterWithTransform(fieldName, transform, fieldType, List.of(), builderType); } /** @@ -595,7 +628,11 @@ private static MethodDto createFieldSetterWithTransform( * @return the method DTO for the setter */ private static MethodDto createFieldSetterWithTransform( - String fieldName, String transform, TypeName fieldType, List annotations) { + String fieldName, + String transform, + TypeName fieldType, + List annotations, + TypeName builderType) { MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName); parameter.setParameterTypeName(fieldType); @@ -603,6 +640,7 @@ private static MethodDto createFieldSetterWithTransform( annotations.forEach(parameter::addAnnotation); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.PROXY); @@ -623,13 +661,15 @@ private static MethodDto createFieldSetterWithTransform( return methodDto; } - private static MethodDto createFieldConsumer(String fieldName, TypeName fieldType) { + private static MethodDto createFieldConsumer( + String fieldName, TypeName fieldType, TypeName builderType) { TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), fieldType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName + SUFFIX_CONSUMER); parameter.setParameterTypeName(consumerType); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.CONSUMER); @@ -647,7 +687,8 @@ private static MethodDto createFieldConsumer(String fieldName, TypeName fieldTyp return methodDto; } - private static MethodDto createStringBuilderConsumer(String fieldName, String transform) { + private static MethodDto createStringBuilderConsumer( + String fieldName, String transform, TypeName builderType) { TypeName stringBuilderType = new TypeName("java.lang", "StringBuilder"); TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), stringBuilderType); @@ -674,14 +715,25 @@ private static MethodDto createStringBuilderConsumer(String fieldName, String tr } private static MethodDto createFieldConsumerWithBuilder( - String fieldName, TypeName builderType, TypeName builderTargetType) { - TypeNameGeneric builderTypeGeneric = new TypeNameGeneric(builderType, builderTargetType); - return BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderTypeGeneric); + String fieldName, + TypeName consumerBuilderType, + TypeName builderTargetType, + TypeName returnBuilderType) { + TypeNameGeneric builderTypeGeneric = + new TypeNameGeneric(consumerBuilderType, builderTargetType); + return BuilderDefinitionCreator.createFieldConsumerWithBuilder( + fieldName, builderTypeGeneric, returnBuilderType); } - private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeName builderType) { + private static MethodDto createFieldConsumerWithBuilder( + String fieldName, TypeName consumerBuilderType, TypeName returnBuilderType) { return createFieldConsumerWithBuilder( - fieldName, builderType, "this.$fieldName:N.value()", "", Map.of()); + fieldName, + consumerBuilderType, + "this.$fieldName:N.value()", + "", + Map.of(), + returnBuilderType); } /** @@ -689,13 +741,17 @@ private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeNa * ArrayListBuilderWithElementBuilders and HashSetBuilderWithElementBuilders. */ private static MethodDto createFieldConsumerWithElementBuilders( - String fieldName, TypeName collectionBuilderType, TypeName elementBuilderType) { + String fieldName, + TypeName collectionBuilderType, + TypeName elementBuilderType, + TypeName returnBuilderType) { return createFieldConsumerWithBuilder( fieldName, collectionBuilderType, "this.$fieldName:N.value(), $elementBuilderType:T::create", "$elementBuilderType:T::create", - Map.of("elementBuilderType", elementBuilderType)); + Map.of("elementBuilderType", elementBuilderType), + returnBuilderType); } /** @@ -711,16 +767,19 @@ private static MethodDto createFieldConsumerWithElementBuilders( */ private static MethodDto createFieldConsumerWithBuilder( String fieldName, - TypeName builderType, + TypeName consumerBuilderType, String constructorArgsWithValue, - String constructorArgsEmpty, - Map additionalArguments) { - TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), builderType); + String additionalConstructorArgs, + Map additionalArguments, + TypeName returnBuilderType) { + TypeNameGeneric consumerType = + new TypeNameGeneric(map2TypeName(Consumer.class), consumerBuilderType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName + BUILDER_SUFFIX + SUFFIX_CONSUMER); parameter.setParameterTypeName(consumerType); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(returnBuilderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.CONSUMER_BY_BUILDER); @@ -731,22 +790,24 @@ private static MethodDto createFieldConsumerWithBuilder( this.$fieldName:N = $builderFieldWrapper:T.changedValue(builder.build()); return this; """ - .formatted(constructorArgsWithValue, constructorArgsEmpty)); + .formatted(constructorArgsWithValue, additionalConstructorArgs)); methodDto.addArgument(ARG_FIELD_NAME, fieldName); methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); - methodDto.addArgument(ARG_HELPER_TYPE, builderType); + methodDto.addArgument(ARG_HELPER_TYPE, consumerBuilderType); additionalArguments.forEach(methodDto::addArgument); methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); return methodDto; } - private static MethodDto createFieldSupplier(String fieldName, TypeName fieldType) { + private static MethodDto createFieldSupplier( + String fieldName, TypeName fieldType, TypeName builderType) { TypeNameGeneric supplierType = new TypeNameGeneric(map2TypeName(Supplier.class), fieldType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName + SUFFIX_SUPPLIER); parameter.setParameterTypeName(supplierType); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.SUPPLIER); @@ -762,7 +823,7 @@ private static MethodDto createFieldSupplier(String fieldName, TypeName fieldTyp } private static MethodDto createStringFormatMethodWithTransform( - String fieldName, String transform, List annotations) { + String fieldName, String transform, List annotations, TypeName builderType) { TypeName stringType = new TypeName("java.lang", "String"); MethodParameterDto formatParam = new MethodParameterDto(); @@ -777,6 +838,7 @@ private static MethodDto createStringFormatMethodWithTransform( MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(formatParam); methodDto.addParameter(argsParam); methodDto.setModifier(Modifier.PUBLIC); 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 20695627..578a80d8 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 @@ -485,22 +485,15 @@ private MethodSpec createNestedTypeMethod( MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(method.getMethodName()).addModifiers(PUBLIC); - // Set return type - if (method.getReturnType() != null) { - TypeName returnTypeName = method.getReturnType(); - if (returnTypeName.getClassName().equals(dtoClass.simpleName())) { - methodBuilder.returns(dtoClass); - } else if (returnTypeName.getClassName().equals(builderClass.simpleName())) { - methodBuilder.returns(builderClass); - } else { - // Use the return type as-is (construct ClassName from TypeName) - if (returnTypeName.getPackageName() != null && !returnTypeName.getPackageName().isEmpty()) { - methodBuilder.returns( - ClassName.get(returnTypeName.getPackageName(), returnTypeName.getClassName())); - } else { - methodBuilder.returns(ClassName.bestGuess(returnTypeName.getClassName())); - } - } + // Set return type (returnType is mandatory in MethodDto) + TypeName returnTypeName = method.getReturnType(); + if (returnTypeName.getClassName().equals(dtoClass.simpleName())) { + methodBuilder.returns(dtoClass); + } else if (returnTypeName.getClassName().equals(builderClass.simpleName())) { + methodBuilder.returns(builderClass); + } else { + // Use the mapper for other types + methodBuilder.returns(JavapoetMapper.map2ClassName(returnTypeName)); } // Add parameters From 25a00443827fd249a855b20b795c6628739001b9 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 26 Oct 2025 19:46:49 +0100 Subject: [PATCH 04/24] Improving mapping from dto to JavaPoet classname --- .../simple/builders/processor/util/JavapoetMapper.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 58ebf185..20001890 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 @@ -35,6 +35,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import org.javahelpers.simple.builders.processor.dtos.*; /** Helper functions to create JavaPoet types from DTOs of simple builder. */ @@ -106,7 +107,11 @@ public static TypeName map2ParameterType( */ public static ClassName map2ClassName( org.javahelpers.simple.builders.processor.dtos.TypeName typeName) { - return ClassName.get(typeName.getPackageName(), typeName.getClassName()); + if (StringUtils.isNoneEmpty(typeName.getPackageName())) { + return ClassName.get(typeName.getPackageName(), typeName.getClassName()); + } else { + return ClassName.bestGuess(typeName.getClassName()); + } } /** From fc377aa0f20041b00f055443c40b85db678f7d75 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 26 Oct 2025 20:17:55 +0100 Subject: [PATCH 05/24] Fixing the issue of casting With-Interfaces to Dtos/Records --- .../processor/util/BuilderDefinitionCreator.java | 4 ++-- .../simple/builders/processor/WithInterfaceTest.java | 12 ++++++------ 2 files changed, 8 insertions(+), 8 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 30b68aaa..bf395f0b 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 @@ -1024,7 +1024,7 @@ private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDe // Add implementation (cast this to the DTO type) method.setCode( """ - $builderType:T builder = new $builderType:T(($dtoType:T) this); + $builderType:T builder = new $builderType:T($dtoType:T.class.cast(this)); b.accept(builder); return builder.build(); """); @@ -1053,7 +1053,7 @@ private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef method.setReturnType(builderDef.getBuilderTypeName()); // Add implementation (cast this to the DTO type) - method.setCode("return new $builderType:T(($dtoType:T) this);\n"); + method.setCode("return new $builderType:T($dtoType:T.class.cast(this));\n"); method.addArgument("builderType", builderDef.getBuilderTypeName()); method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java index 8416b46c..92a4ab00 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java @@ -58,7 +58,7 @@ public interface With { * @return the modified instance */ default Project with(Consumer b) { - ProjectBuilder builder = new ProjectBuilder((Project) this); + ProjectBuilder builder = new ProjectBuilder(Project.class.cast(this)); b.accept(builder); return builder.build(); } @@ -69,7 +69,7 @@ default Project with(Consumer b) { * @return a builder initialized with this instance's values */ default ProjectBuilder with() { - return new ProjectBuilder((Project) this); + return new ProjectBuilder(Project.class.cast(this)); } } """; @@ -121,7 +121,7 @@ public interface With { * @return the modified instance */ default User with(Consumer b) { - UserBuilder builder = new UserBuilder((User) this); + UserBuilder builder = new UserBuilder(User.class.cast(this)); b.accept(builder); return builder.build(); } @@ -132,7 +132,7 @@ default User with(Consumer b) { * @return a builder initialized with this instance's values */ default UserBuilder with() { - return new UserBuilder((User) this); + return new UserBuilder(User.class.cast(this)); } } """; @@ -181,7 +181,7 @@ public interface With { * @return the modified instance */ default Config with(Consumer b) { - ConfigBuilder builder = new ConfigBuilder((Config) this); + ConfigBuilder builder = new ConfigBuilder(Config.class.cast(this)); b.accept(builder); return builder.build(); } @@ -192,7 +192,7 @@ default Config with(Consumer b) { * @return a builder initialized with this instance's values */ default ConfigBuilder with() { - return new ConfigBuilder((Config) this); + return new ConfigBuilder(Config.class.cast(this)); } } """; From 91807e85d0bf0f9403e5908f2343d22a7c7bb7bb Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 26 Oct 2025 20:18:29 +0100 Subject: [PATCH 06/24] Adding an example of with-interface --- .../builders/example/ProductRecord.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 example/src/main/java/org/javahelpers/simple/builders/example/ProductRecord.java diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/ProductRecord.java b/example/src/main/java/org/javahelpers/simple/builders/example/ProductRecord.java new file mode 100644 index 00000000..8698a4e3 --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/ProductRecord.java @@ -0,0 +1,68 @@ +/* + * 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.example; + +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + +/** + * Example showing how Records work with Simple Builders and the With interface. + * + *

Records are immutable and final. The builder pattern works great with Records + * for creating new instances. The With interface is also generated, allowing fluent + * modification methods. + * + *

Since annotation processors cannot modify Records to add 'implements' clauses, + * you need to manually add {@code implements ProductRecordBuilder.With} to your Record + * declaration to use the fluent with() methods. + * + *

Example usage with the With interface: + *

{@code
+ * ProductRecord product = ProductRecordBuilder.create()
+ *     .name("Laptop")
+ *     .price(1500.0)
+ *     .category("Electronics")
+ *     .build();
+ * 
+ * // Create modified copy using with()
+ * ProductRecord discounted = product.with(b -> b.price(1200.0));
+ * }
+ */ +@SimpleBuilder +public record ProductRecord( + String name, + double price, + String category +) implements ProductRecordBuilder.With { + + /** + * Custom method showing fluent modification using the With interface. + * Since Records are immutable, the with() methods create new instances. + */ + public ProductRecord withDiscountedPrice(double discountPercentage) { + double discountedPrice = price * (1 - discountPercentage / 100); + // Use the with() method from the With interface for fluent modification + return with(builder -> builder.price(discountedPrice)); + } +} From 96d134b08c8f7bc11ffdd1842d37dfe461c63445 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 12:56:48 +0100 Subject: [PATCH 07/24] Getter-recognition needs to match getters in records too --- .../simple/builders/processor/util/JavaLangAnalyser.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java index bc33c47a..1230f537 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java @@ -363,14 +363,13 @@ public static Optional findGetterForField( if (dtoType == null || fieldName == null || fieldTypeMirror == null) { return Optional.empty(); } - String cap = StringUtils.capitalize(fieldName); - String getterCandidate = "get" + cap; - String booleanGetterCandidate = "is" + cap; List classMethods = ElementFilter.methodsIn(context.getAllMembers(dtoType)); - // Prefer boolean-style getter if present + + // Check for accessor methods: + // Record-style (fieldName), boolean-style (isXxx), or standard (getXxx) for (ExecutableElement candidate : classMethods) { String name = candidate.getSimpleName().toString(); - if ((name.equals(booleanGetterCandidate) || name.equals(getterCandidate)) + if (Strings.CI.equalsAny(name, fieldName, "is" + fieldName, "get" + fieldName) && candidate.getParameters().isEmpty() && context.isSameType(candidate.getReturnType(), fieldTypeMirror)) { return Optional.of(candidate); From 260e567a4518b6c3394a1dc7dc7637f498c2f239 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 12:57:17 +0100 Subject: [PATCH 08/24] Adding at test to demonstrate the usage of with-interfaces in ProductRecord --- .../builders/example/ProductRecordTest.java | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 example/src/test/java/org/javahelpers/simple/builders/example/ProductRecordTest.java diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/ProductRecordTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/ProductRecordTest.java new file mode 100644 index 00000000..1cf054e2 --- /dev/null +++ b/example/src/test/java/org/javahelpers/simple/builders/example/ProductRecordTest.java @@ -0,0 +1,201 @@ +/* + * 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.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +/** + * Tests demonstrating how Records work with Simple Builders and the With interface. + * + *

This showcases: + *

    + *
  • Builder pattern for Records
  • + *
  • With interface for fluent modifications
  • + *
  • Immutability guarantees of Records
  • + *
  • Using Class.cast() for type-safe runtime casts
  • + *
+ */ +class ProductRecordTest { + + @Test + void testBuilder_createsRecordSuccessfully() { + // Create a product using the builder + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + assertNotNull(laptop); + assertEquals("Gaming Laptop", laptop.name()); + assertEquals(1500.00, laptop.price()); + assertEquals("Electronics", laptop.category()); + } + + @Test + void testBuilder_copiesFromRecordInstance() { + // Given: an existing Record instance + ProductRecord original = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: creating a builder from the Record instance using copy constructor + ProductRecordBuilder builder = new ProductRecordBuilder(original); + ProductRecord copy = builder.build(); + + // Then: all fields should be copied from the original + assertNotNull(copy); + assertEquals("Gaming Laptop", copy.name(), "Name should be copied from original Record"); + assertEquals(1500.00, copy.price(), "Price should be copied from original Record"); + assertEquals("Electronics", copy.category(), "Category should be copied from original Record"); + } + + @Test + void testWithInterface_createsModifiedCopy() { + // Given: an original product + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: using the With interface to create a modified copy + ProductRecord discountedLaptop = laptop.with(builder -> + builder.price(1200.00) + ); + + // Then: a new instance is created with modified values + assertNotNull(discountedLaptop); + assertEquals("Gaming Laptop", discountedLaptop.name()); + assertEquals(1200.00, discountedLaptop.price()); + assertEquals("Electronics", discountedLaptop.category()); + + // And: the original is unchanged (immutability) + assertEquals(1500.00, laptop.price()); + } + + @Test + void testWithInterface_multipleModifications() { + // Given: an original product + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: applying multiple modifications using with() + ProductRecord rebranded = laptop.with(builder -> + builder + .name("Professional Laptop") + .category("Business") + .price(1800.00) + ); + + // Then: all modifications are applied + assertNotNull(rebranded); + assertEquals("Professional Laptop", rebranded.name()); + assertEquals(1800.00, rebranded.price()); + assertEquals("Business", rebranded.category()); + + // And: the original is unchanged + assertEquals("Gaming Laptop", laptop.name()); + assertEquals(1500.00, laptop.price()); + assertEquals("Electronics", laptop.category()); + } + + @Test + void testWithInterface_returnsBuilder() { + // Given: an original product + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: getting a builder from the existing instance + ProductRecordBuilder builder = laptop.with(); + ProductRecord modified = builder + .price(1400.00) + .build(); + + // Then: the builder is initialized from the original and modifications applied + assertNotNull(modified); + assertEquals("Gaming Laptop", modified.name()); + assertEquals(1400.00, modified.price()); + assertEquals("Electronics", modified.category()); + } + + @Test + void testCustomWithMethod_calculatesDiscount() { + // Given: a product with a price + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: applying a 20% discount using the custom method + ProductRecord saleProduct = laptop.withDiscountedPrice(20); + + // Then: the price is correctly discounted + assertNotNull(saleProduct); + assertEquals("Gaming Laptop", saleProduct.name()); + assertEquals(1200.00, saleProduct.price(), 0.01); // 1500 * 0.8 = 1200 + assertEquals("Electronics", saleProduct.category()); + + // And: the original is unchanged + assertEquals(1500.00, laptop.price()); + } + + @Test + void testImmutability_originalUnchanged() { + // Given: an original product + ProductRecord original = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: creating multiple modified versions + ProductRecord modified1 = original.with(builder -> builder.price(1200.00)); + ProductRecord modified2 = original.with(builder -> builder.name("Business Laptop")); + ProductRecord modified3 = original.withDiscountedPrice(10); + + // Then: all modified versions are different instances + assertNotNull(modified1); + assertNotNull(modified2); + assertNotNull(modified3); + + // And: the original remains completely unchanged + assertEquals("Gaming Laptop", original.name()); + assertEquals(1500.00, original.price()); + assertEquals("Electronics", original.category()); + } +} From 74554d784695cac6d64111818e82965e5427ccad Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 13:37:25 +0100 Subject: [PATCH 09/24] Fixing code generation in complex code, where the ordering of types might be different and types not used just once. --- .../processor/util/JavaCodeGenerator.java | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) 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 578a80d8..4eaf54bd 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 @@ -526,32 +526,26 @@ private MethodSpec createNestedTypeMethod( if (codeDto.getCodeFormat() != null && !codeDto.getCodeFormat().isEmpty()) { // Build code with type arguments String code = codeDto.getCodeFormat(); - java.util.List args = new java.util.ArrayList<>(); + java.util.Map args = new java.util.HashMap<>(); // Replace placeholders with appropriate classes (count how many times each placeholder // appears) for (MethodCodePlaceholder placeholder : codeDto.getCodeArguments()) { if (placeholder instanceof MethodCodeTypePlaceholder) { - String placeholderStr = "$" + placeholder.getLabel() + ":T"; - // Count occurrences - int count = 0; - int index = 0; - while ((index = code.indexOf(placeholderStr, index)) != -1) { - count++; - index += placeholderStr.length(); - } - // Replace all occurrences with $T - code = code.replace(placeholderStr, "$T"); // Determine which class to use based on placeholder label - ClassName classToUse = placeholder.getLabel().equals("dtoType") ? dtoClass : builderClass; - // Add class to args for each occurrence - for (int i = 0; i < count; i++) { - args.add(classToUse); + if (placeholder.getLabel().equals("dtoType")) { + args.put(placeholder.getLabel(), dtoClass); + } else if (placeholder.getLabel().equals("builderType")) { + args.put(placeholder.getLabel(), builderClass); + } else if (placeholder.getValue() instanceof String className) { + args.put(placeholder.getLabel(), className); + } else { + throw new IllegalArgumentException("Unknown placeholder type: " + placeholder); } } } - methodBuilder.addCode(code, args.toArray()); + methodBuilder.addNamedCode(code, args); } return methodBuilder.build(); From 7f2474df195e2e1955038c52bbb1dca126e93e9c Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 13:43:13 +0100 Subject: [PATCH 10/24] Adding specific exception handling, if the Builder.with-interface is used on wrong class --- .../util/BuilderDefinitionCreator.java | 26 +++++++++++-- .../processor/util/JavaCodeGenerator.java | 7 +++- .../builders/processor/WithInterfaceTest.java | 39 ++++++++++++++++--- 3 files changed, 60 insertions(+), 12 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 bf395f0b..c73a7312 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 @@ -1021,15 +1021,23 @@ private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDe parameter.setParameterTypeName(consumerType); method.addParameter(parameter); - // Add implementation (cast this to the DTO type) + // Add implementation with validation to catch wrong implementations method.setCode( """ - $builderType:T builder = new $builderType:T($dtoType:T.class.cast(this)); + $builderType:T builder; + try { + builder = new $builderType:T($dtoType:T.class.cast(this)); + } catch ($classcastexception:T ex) { + throw new $illegalargumentexception:T("The interface '$builderType:T.With' should only be implemented by classes, which could be casted to '$dtoType:T'", ex); + } b.accept(builder); return builder.build(); """); method.addArgument("builderType", builderDef.getBuilderTypeName()); method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); + method.addArgument("classcastexception", new TypeName("java.lang", "ClassCastException")); + method.addArgument( + "illegalargumentexception", new TypeName("java.lang", "IllegalArgumentException")); method.setJavadoc( "Applies modifications to a builder initialized from this instance and returns the built object.\n\n" @@ -1052,10 +1060,20 @@ private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef // Return type is the Builder type method.setReturnType(builderDef.getBuilderTypeName()); - // Add implementation (cast this to the DTO type) - method.setCode("return new $builderType:T($dtoType:T.class.cast(this));\n"); + // Add implementation with validation to catch wrong implementations + method.setCode( + """ + try { + return new $builderType:T($dtoType:T.class.cast(this)); + } catch ($classcastexception:T ex) { + throw new $illegalargumentexception:T("The interface '$builderType:T.With' should only be implemented by classes, which could be casted to '$dtoType:T'", ex); + } + """); method.addArgument("builderType", builderDef.getBuilderTypeName()); method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); + method.addArgument("classcastexception", new TypeName("java.lang", "ClassCastException")); + method.addArgument( + "illegalargumentexception", new TypeName("java.lang", "IllegalArgumentException")); method.setJavadoc( "Creates a builder initialized from this instance.\n\n" 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 4eaf54bd..a5e36208 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 @@ -528,8 +528,7 @@ private MethodSpec createNestedTypeMethod( String code = codeDto.getCodeFormat(); java.util.Map args = new java.util.HashMap<>(); - // Replace placeholders with appropriate classes (count how many times each placeholder - // appears) + // Replace placeholders with appropriate classes for (MethodCodePlaceholder placeholder : codeDto.getCodeArguments()) { if (placeholder instanceof MethodCodeTypePlaceholder) { // Determine which class to use based on placeholder label @@ -537,6 +536,10 @@ private MethodSpec createNestedTypeMethod( args.put(placeholder.getLabel(), dtoClass); } else if (placeholder.getLabel().equals("builderType")) { args.put(placeholder.getLabel(), builderClass); + } else if (placeholder.getValue() + instanceof org.javahelpers.simple.builders.processor.dtos.TypeName typeName) { + // Map TypeName to ClassName for proper import handling + args.put(placeholder.getLabel(), JavapoetMapper.map2ClassName(typeName)); } else if (placeholder.getValue() instanceof String className) { args.put(placeholder.getLabel(), className); } else { diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java index 92a4ab00..273aec0f 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java @@ -58,7 +58,12 @@ public interface With { * @return the modified instance */ default Project with(Consumer b) { - ProjectBuilder builder = new ProjectBuilder(Project.class.cast(this)); + ProjectBuilder builder; + try { + builder = new ProjectBuilder(Project.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ProjectBuilder.With' should only be implemented by classes, which could be casted to 'Project'", ex); + } b.accept(builder); return builder.build(); } @@ -69,7 +74,11 @@ default Project with(Consumer b) { * @return a builder initialized with this instance's values */ default ProjectBuilder with() { - return new ProjectBuilder(Project.class.cast(this)); + try { + return new ProjectBuilder(Project.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ProjectBuilder.With' should only be implemented by classes, which could be casted to 'Project'", ex); + } } } """; @@ -121,7 +130,12 @@ public interface With { * @return the modified instance */ default User with(Consumer b) { - UserBuilder builder = new UserBuilder(User.class.cast(this)); + UserBuilder builder; + try { + builder = new UserBuilder(User.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'UserBuilder.With' should only be implemented by classes, which could be casted to 'User'", ex); + } b.accept(builder); return builder.build(); } @@ -132,7 +146,11 @@ default User with(Consumer b) { * @return a builder initialized with this instance's values */ default UserBuilder with() { - return new UserBuilder(User.class.cast(this)); + try { + return new UserBuilder(User.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'UserBuilder.With' should only be implemented by classes, which could be casted to 'User'", ex); + } } } """; @@ -181,7 +199,12 @@ public interface With { * @return the modified instance */ default Config with(Consumer b) { - ConfigBuilder builder = new ConfigBuilder(Config.class.cast(this)); + ConfigBuilder builder; + try { + builder = new ConfigBuilder(Config.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ConfigBuilder.With' should only be implemented by classes, which could be casted to 'Config'", ex); + } b.accept(builder); return builder.build(); } @@ -192,7 +215,11 @@ default Config with(Consumer b) { * @return a builder initialized with this instance's values */ default ConfigBuilder with() { - return new ConfigBuilder(Config.class.cast(this)); + try { + return new ConfigBuilder(Config.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ConfigBuilder.With' should only be implemented by classes, which could be casted to 'Config'", ex); + } } } """; From e5277fe5ac0e4d323a61a69774bf4863d6c23182 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 13:58:06 +0100 Subject: [PATCH 11/24] Adding a contribution documentation --- CONTRIBUTING.md | 281 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 23 ++-- 2 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..0171449d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,281 @@ +# Contributing to Simple Builders + +Thank you for your interest in contributing to Simple Builders! This document provides guidelines and instructions for developing and testing the project. + +## Table of Contents +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Building and Testing](#building-and-testing) +- [Code Style](#code-style) +- [Submitting Changes](#submitting-changes) + +## Development Setup + +### Prerequisites + +- **Java 17+**: Required for development +- **Maven 3.8+**: Build tool +- **Git**: Version control + +### Clone and Build + +```bash +git clone https://github.com/java-helpers/simple-builders.git +cd simple-builders +mvn clean install +``` + +## Project Structure + +The project is organized as a multi-module Maven project: + +``` +simple-builders/ +├── core/ # Core annotations and runtime utilities +├── processor/ # Annotation processor (compile-time code generation) +├── example/ # Example usage and integration tests +└── pom.xml # Parent POM +``` + +### Module Dependencies + +**Important**: The `example` module depends on the `processor` module being installed in your local Maven repository. This is because: +1. The annotation processor must be available at compile-time +2. The example uses `@SimpleBuilder` annotations that trigger code generation +3. Tests in example validate the generated builders + +## Building and Testing + +### Understanding Module Dependencies + +Due to the annotation processor architecture, you must follow specific test strategies based on what you're modifying: + +### Test Strategies + +#### 1. When Modifying Processor Code + +If you're changing code in the `processor` module: + +```bash +# Test only the processor +mvn test -pl processor + +# With annotation processor verbose output (requires pom.xml configuration) +mvn test -pl processor -Dsimplebuilder.verbose=true +``` + +#### 2. When Modifying Example Code + +If you're changing code in the `example` module, you **must** install the processor first: + +```bash +# Install processor (skip its tests for speed) +mvn install -pl processor -DskipTests + +# Then test example +mvn test -pl example +``` + +#### 3. When Modifying Both Modules + +For changes affecting both processor and example: + +```bash +# Option A: Use reactor with -am (also-make) flag +mvn test -pl processor,example -am + +# Option B: Full clean install (safest) +mvn clean install +``` + +#### 4. Full Validation Before Committing + +Always run a full build with all tests before committing: + +```bash +# Clean build with all tests +mvn clean test + +# Or full install +mvn clean install +``` + +### Common Maven Commands + +```bash +# Clean everything +mvn clean + +# Compile without tests +mvn compile -DskipTests + +# Install to local repository without tests +mvn install -DskipTests + +# Run tests for specific modules +mvn test -pl processor,example + +# Run a specific test class +mvn test -Dtest=BuilderProcessorTest -pl processor + +# Run with Maven debug output (shows Maven internals, not processor details) +mvn test -X -pl processor +``` + +### Debug Logging for Annotation Processor + +The annotation processor has its own verbose logging that shows detailed information about field discovery, method analysis, and code generation. This is **different** from Maven's `-X` debug flag. + +The `example` module is already configured to support verbose output. Enable it with: + +```bash +# Compile/test example with processor verbose output +mvn compile -pl example -Dsimplebuilder.verbose=true +mvn test -pl example -Dsimplebuilder.verbose=true +``` + +**Note**: The `processor` module's tests already use verbose output internally via `.withOptions("-Averbose=true")` in the test code itself. No Maven property is needed for processor tests. + +For complete documentation on debug logging and configuration, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). + +### Troubleshooting Build Issues + +#### Maven Compilation Cache Issues + +If you encounter strange compilation errors in the `example` module: + +1. **Clean the affected module**: + ```bash + mvn clean -pl example + ``` + +2. **Reinstall dependencies**: + ```bash + mvn clean install -pl processor -DskipTests + mvn compile -pl example + ``` + +3. **Full clean (nuclear option)**: + ```bash + mvn clean + mvn install + ``` + +#### Test Compilation Failures + +If test classes can't find generated builders: + +```bash +# Ensure processor is installed +mvn install -pl processor -DskipTests + +# Clean and rebuild example +mvn clean compile -pl example +mvn test -pl example +``` + +## Code Style + +### Formatting + +The project uses [google-java-format](https://github.com/google/google-java-format) for consistent code formatting. + +**Automatic formatting** is applied during build via the `fmt-maven-plugin`: + +```bash +# Format code automatically +mvn fmt:format + +# Check formatting without modifying files +mvn fmt:check +``` + +### Code Quality + +- **SonarLint**: We use SonarQube rules. Install the SonarLint IDE plugin for real-time feedback. +- **Test Coverage**: Aim for high test coverage for new features. +- **JavaDoc**: Public APIs should have comprehensive JavaDoc comments. + +### Naming Conventions + +- **Classes**: `PascalCase` (e.g., `BuilderProcessor`) +- **Methods**: `camelCase` (e.g., `generateBuilder`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_TIMEOUT`) +- **Packages**: lowercase (e.g., `org.javahelpers.simple.builders`) + +## Submitting Changes + +### Before Submitting + +1. **Run all tests**: + ```bash + mvn clean test + ``` + +2. **Check code formatting**: + ```bash + mvn fmt:check + ``` + +3. **Update documentation** if needed + +4. **Write tests** for new features + +### Pull Request Process + +1. **Fork** the repository +2. **Create a feature branch** from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` + +3. **Make your changes** following the code style guidelines + +4. **Commit** with clear, descriptive messages: + ```bash + git commit -m "Add feature: description of feature" + ``` + +5. **Push** to your fork: + ```bash + git push origin feature/your-feature-name + ``` + +6. **Open a Pull Request** against the `main` branch + +### Pull Request Guidelines + +- **Clear description**: Explain what your PR does and why +- **Reference issues**: Link related issues (e.g., "Fixes #123") +- **Keep it focused**: One feature or fix per PR +- **Include tests**: Add tests for new functionality +- **Update docs**: Update README.md or other docs if needed + +## Testing Philosophy + +### Test Preferences + +1. **Explicit assertions**: Prefer explicit expected string literals over building assertion strings from variables +2. **Readable tests**: Tests should be self-documenting and easy to understand +3. **Comprehensive coverage**: Test edge cases, error conditions, and happy paths + +### Running Specific Tests + +```bash +# Run a single test method +mvn test -Dtest=BuilderProcessorTest#shouldGenerateBasicBuilder -pl processor + +# Run all tests in a class +mvn test -Dtest=BuilderProcessorTest -pl processor + +# Run tests matching a pattern +mvn test -Dtest=*ProcessorTest -pl processor +``` + +## Questions? + +If you have questions or need help: +- Open an [issue](https://github.com/java-helpers/simple-builders/issues) +- Check existing [discussions](https://github.com/java-helpers/simple-builders/discussions) + +Thank you for contributing to Simple Builders! 🎉 diff --git a/README.md b/README.md index f8d954ea..aca6cbe7 100644 --- a/README.md +++ b/README.md @@ -260,15 +260,24 @@ For complete documentation on enabling and using debug logging, see [DEBUG_LOGGI ## Contributing -Contributions are welcome! Please follow these steps: +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on: -1. Fork the repository -2. Create a feature branch -3. Commit your changes -4. Push to the branch -5. Create a new Pull Request +- Development setup and project structure +- Building and testing strategies (important for annotation processor modules) +- Code style and formatting +- Pull request process -Please ensure your code follows the project's code style and includes appropriate tests. +**Quick Start for Contributors:** + +```bash +# Clone and build +git clone https://github.com/java-helpers/simple-builders.git +cd simple-builders +mvn clean install + +# Run tests (requires processor to be installed first) +mvn test -pl processor,example -am +``` ### Releasing From 5ae910df84025a960bcf2381f5f02949620b83db Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 14:21:41 +0100 Subject: [PATCH 12/24] Adding a functionality to have verbose logging in tests --- CONTRIBUTING.md | 31 ++++++-- .../processor/AnnotationCopyTest.java | 9 ++- .../processor/ConditionalExecutionTest.java | 7 +- .../processor/NullConstraintTest.java | 7 +- .../builders/processor/ReadmeExampleTest.java | 9 ++- .../builders/processor/WithInterfaceTest.java | 7 +- .../processor/testing/ProcessorTestUtils.java | 74 +++++++++++++++++++ 7 files changed, 125 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0171449d..c88d247a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ If you're changing code in the `processor` module: # Test only the processor mvn test -pl processor -# With annotation processor verbose output (requires pom.xml configuration) +# With annotation processor verbose output (shows detailed field/method analysis) mvn test -pl processor -Dsimplebuilder.verbose=true ``` @@ -126,15 +126,36 @@ mvn test -X -pl processor The annotation processor has its own verbose logging that shows detailed information about field discovery, method analysis, and code generation. This is **different** from Maven's `-X` debug flag. -The `example` module is already configured to support verbose output. Enable it with: +Both `processor` and `example` modules now support the `simplebuilder.verbose` property: ```bash -# Compile/test example with processor verbose output +# Enable verbose output for processor tests +mvn test -pl processor -Dsimplebuilder.verbose=true + +# Enable verbose output for example compilation mvn compile -pl example -Dsimplebuilder.verbose=true -mvn test -pl example -Dsimplebuilder.verbose=true + +# Enable for all modules +mvn test -Dsimplebuilder.verbose=true ``` -**Note**: The `processor` module's tests already use verbose output internally via `.withOptions("-Averbose=true")` in the test code itself. No Maven property is needed for processor tests. +**How it works**: +1. The `ProcessorTestUtils.createCompiler()` utility automatically checks for the `simplebuilder.verbose` system property and applies `-Averbose=true` to all test compilations when enabled. +2. The `printDiagnosticsOnVerbose()` helper prints the processor's detailed debug output to the console, making it visible in test output and CI logs. +3. This is especially useful when debugging failing tests - you'll see exactly what the processor is doing. + +**Example verbose output**: +``` +========== Compilation Diagnostics ========== +--- NOTES --- +[DEBUG] simple-builders: Processing element: Project +[DEBUG] Extracting builder definition from: test.Project +[DEBUG] Analyzing method: setName with 1 parameter(s) +[DEBUG] -> Adding field: name (type: java.lang.String) +[DEBUG] Generated 4 methods for field: name +[DEBUG] Successfully generated builder: ProjectBuilder +============================================= +``` For complete documentation on debug logging and configuration, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). 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 606b5404..b795d1bc 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 @@ -1,10 +1,11 @@ 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.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; 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; @@ -14,9 +15,9 @@ class AnnotationCopyTest { private Compilation compileSources(JavaFileObject... sources) { - BuilderProcessor processor = new BuilderProcessor(); - Compiler compiler = Compiler.javac().withProcessors(processor); - return compiler.compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java index 7ed759e4..c6e917a0 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java @@ -1,10 +1,11 @@ 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.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; 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; @@ -14,7 +15,9 @@ class ConditionalExecutionTest { private Compilation compileSources(JavaFileObject... sources) { - return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java index 41a1e3ea..022b5618 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java @@ -25,10 +25,11 @@ 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.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; 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; @@ -38,7 +39,9 @@ class NullConstraintTest { private Compilation compileSources(JavaFileObject... sources) { - return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java index dcc6002b..24dc31e1 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java @@ -1,10 +1,11 @@ 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.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; 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; @@ -13,9 +14,9 @@ class ReadmeExampleTest { private Compilation compileSources(JavaFileObject... sources) { - BuilderProcessor processor = new BuilderProcessor(); - Compiler compiler = Compiler.javac().withProcessors(processor); - return compiler.compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java index 273aec0f..3dd32698 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java @@ -1,10 +1,11 @@ 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.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; 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; @@ -14,7 +15,9 @@ class WithInterfaceTest { private Compilation compileSources(JavaFileObject... sources) { - return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); // Print diagnostics when verbose mode is enabled + return compilation; } @Test 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 589ee87b..a64fb0b1 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 @@ -1,11 +1,14 @@ package org.javahelpers.simple.builders.processor.testing; import com.google.testing.compile.Compilation; +import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.tools.JavaFileObject; +import org.apache.commons.lang3.Strings; +import org.javahelpers.simple.builders.processor.BuilderProcessor; /** * Utilities to simplify annotation-processor tests by reducing boilerplate for building sources, @@ -15,6 +18,77 @@ public final class ProcessorTestUtils { private ProcessorTestUtils() {} + /** + * Creates a configured {@link Compiler} instance with the BuilderProcessor. + * + *

This method checks for the system property {@code simplebuilder.verbose} (or {@code + * Averbose}) and automatically adds {@code -Averbose=true} to the compiler options if either is + * set to "true". + * + *

This allows developers to enable verbose processor output for all tests by running: {@code + * mvn test -Dsimplebuilder.verbose=true} + * + * @return a Compiler instance configured with BuilderProcessor and optional verbose output + */ + public static Compiler createCompiler() { + Compiler compiler = Compiler.javac().withProcessors(new BuilderProcessor()); + + // Check for verbose flag from Maven property + if (isVerboseEnabled()) { + compiler = compiler.withOptions("-Averbose=true"); + } + + return compiler; + } + + /** + * Checks if verbose mode is enabled via system properties. + * + * @return true if simplebuilder.verbose or Averbose is set to "true" + */ + public static boolean isVerboseEnabled() { + String verboseProperty = System.getProperty("simplebuilder.verbose"); + String averboseProperty = System.getProperty("Averbose"); + return Strings.CI.equalsAny("true", verboseProperty, averboseProperty); + } + + /** + * Prints compilation diagnostics (notes, warnings, errors) to System.out if verbose mode is + * enabled. + * + *

This is useful for debugging test failures, as it makes the processor's debug output visible + * in the test console output and CI logs. + * + * @param compilation the compilation result to print diagnostics from + */ + public static void printDiagnosticsOnVerbose(Compilation compilation) { + if (!isVerboseEnabled()) { + return; + } + + System.out.println("\n========== Compilation Diagnostics =========="); + + // Print notes (includes debug messages) + if (!compilation.notes().isEmpty()) { + System.out.println("--- NOTES ---"); + compilation.notes().forEach(diag -> System.out.println(diag.getMessage(null))); + } + + // Print warnings + if (!compilation.warnings().isEmpty()) { + System.out.println("\n--- WARNINGS ---"); + compilation.warnings().forEach(diag -> System.out.println(diag.getMessage(null))); + } + + // Print errors + if (!compilation.errors().isEmpty()) { + System.out.println("\n--- ERRORS ---"); + compilation.errors().forEach(diag -> System.out.println(diag.getMessage(null))); + } + + System.out.println("=============================================\n"); + } + /** * Creates a {@link JavaFileObject} for a simple class annotated with @SimpleBuilder. You pass the * inner body lines (fields/methods); imports and annotation are handled for you. From dad5a92143abc4da7f6ecf07b071274bdbd08197 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 15:52:16 +0100 Subject: [PATCH 13/24] Improving CONTRIBUTION.md and removing duplicated expainations from README.md --- CONTRIBUTING.md | 153 ++++++++++++++++++++++++++++-------------------- README.md | 46 +-------------- 2 files changed, 91 insertions(+), 108 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c88d247a..10ce57e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,9 @@ Thank you for your interest in contributing to Simple Builders! This document pr ## Table of Contents - [Development Setup](#development-setup) - [Project Structure](#project-structure) +- [Development Guidelines](#development-guidelines) - [Building and Testing](#building-and-testing) +- [Debugging](#debugging) - [Code Style](#code-style) - [Submitting Changes](#submitting-changes) @@ -44,11 +46,41 @@ simple-builders/ 2. The example uses `@SimpleBuilder` annotations that trigger code generation 3. Tests in example validate the generated builders -## Building and Testing +## Development Guidelines + +### Working with Annotation Processors + +This project uses annotation processing for code generation. Understanding this architecture is crucial: + +- The `processor` module generates code at **compile-time** +- The `example` module depends on the processor being installed in your local Maven repository +- Tests use Google's compile-testing library, which makes compilation happen inside test code + +### Code Changes Workflow + +After making code changes: +1. **Always run tests** +2. **Use appropriate scope**: + - Processor changes: `mvn test -pl processor` + - Changes affecting generation: `mvn test -pl processor,example -am` +3. **Full validation before committing**: `mvn clean test` -### Understanding Module Dependencies +### Test Assertions Best Practices -Due to the annotation processor architecture, you must follow specific test strategies based on what you're modifying: +- **Use explicit string literals** for expected values, not variables +- This improves readability and makes failures easier to diagnose +- ✅ Good: Use complete method bodies in assertions + ```java + assertContains(code, """ + public PersonBuilder name(String name) { + this.name = name; + return this; + } + """); + ``` +- ❌ Avoid: Building assertion strings dynamically from variables + +## Building and Testing ### Test Strategies @@ -59,9 +91,6 @@ If you're changing code in the `processor` module: ```bash # Test only the processor mvn test -pl processor - -# With annotation processor verbose output (shows detailed field/method analysis) -mvn test -pl processor -Dsimplebuilder.verbose=true ``` #### 2. When Modifying Example Code @@ -118,47 +147,13 @@ mvn test -pl processor,example # Run a specific test class mvn test -Dtest=BuilderProcessorTest -pl processor -# Run with Maven debug output (shows Maven internals, not processor details) -mvn test -X -pl processor -``` - -### Debug Logging for Annotation Processor - -The annotation processor has its own verbose logging that shows detailed information about field discovery, method analysis, and code generation. This is **different** from Maven's `-X` debug flag. - -Both `processor` and `example` modules now support the `simplebuilder.verbose` property: - -```bash -# Enable verbose output for processor tests -mvn test -pl processor -Dsimplebuilder.verbose=true - -# Enable verbose output for example compilation -mvn compile -pl example -Dsimplebuilder.verbose=true - -# Enable for all modules -mvn test -Dsimplebuilder.verbose=true -``` - -**How it works**: -1. The `ProcessorTestUtils.createCompiler()` utility automatically checks for the `simplebuilder.verbose` system property and applies `-Averbose=true` to all test compilations when enabled. -2. The `printDiagnosticsOnVerbose()` helper prints the processor's detailed debug output to the console, making it visible in test output and CI logs. -3. This is especially useful when debugging failing tests - you'll see exactly what the processor is doing. +# Run a single test method +mvn test -Dtest=BuilderProcessorTest#shouldGenerateBasicBuilder -pl processor -**Example verbose output**: -``` -========== Compilation Diagnostics ========== ---- NOTES --- -[DEBUG] simple-builders: Processing element: Project -[DEBUG] Extracting builder definition from: test.Project -[DEBUG] Analyzing method: setName with 1 parameter(s) -[DEBUG] -> Adding field: name (type: java.lang.String) -[DEBUG] Generated 4 methods for field: name -[DEBUG] Successfully generated builder: ProjectBuilder -============================================= +# Run tests matching a pattern +mvn test -Dtest=*ProcessorTest -pl processor ``` -For complete documentation on debug logging and configuration, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). - ### Troubleshooting Build Issues #### Maven Compilation Cache Issues @@ -195,6 +190,55 @@ mvn clean compile -pl example mvn test -pl example ``` +## Debugging + +### When Tests Fail + +**Always run failing tests with verbose mode first** to see what the annotation processor is doing: + +```bash +# Debug a specific failing test +mvn test -pl processor -Dtest=YourFailingTest -Dsimplebuilder.verbose=true +``` + +### Verbose Output + +The annotation processor has its own verbose logging (different from Maven's `-X` flag): + +```bash +# Enable for processor tests +mvn test -pl processor -Dsimplebuilder.verbose=true + +# Enable for example compilation +mvn compile -pl example -Dsimplebuilder.verbose=true + +# Enable for all tests +mvn test -Dsimplebuilder.verbose=true +``` + +**What verbose output shows:** +- Field discovery and type analysis +- Method parameter extraction +- Annotation processing steps +- Code generation details +- Exact error locations + +**Example output:** +``` +========== Compilation Diagnostics ========== +--- NOTES --- +[DEBUG] simple-builders: Processing element: Project +[DEBUG] Extracting builder definition from: test.Project +[DEBUG] Analyzing method: setName with 1 parameter(s) +[DEBUG] -> Adding field: name (type: java.lang.String) +[DEBUG] Generated 4 methods for field: name +============================================= +``` + +This is **critical** for debugging because annotation processing happens inside Google's compile-testing framework, making it otherwise invisible. + +For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). + ## Code Style ### Formatting @@ -272,27 +316,6 @@ mvn fmt:check - **Include tests**: Add tests for new functionality - **Update docs**: Update README.md or other docs if needed -## Testing Philosophy - -### Test Preferences - -1. **Explicit assertions**: Prefer explicit expected string literals over building assertion strings from variables -2. **Readable tests**: Tests should be self-documenting and easy to understand -3. **Comprehensive coverage**: Test edge cases, error conditions, and happy paths - -### Running Specific Tests - -```bash -# Run a single test method -mvn test -Dtest=BuilderProcessorTest#shouldGenerateBasicBuilder -pl processor - -# Run all tests in a class -mvn test -Dtest=BuilderProcessorTest -pl processor - -# Run tests matching a pattern -mvn test -Dtest=*ProcessorTest -pl processor -``` - ## Questions? If you have questions or need help: diff --git a/README.md b/README.md index aca6cbe7..65295563 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,6 @@ - [Validation Annotations](#validation-annotations) - [Conditional Builder Logic](#conditional-builder-logic) - [Collections and Nested Objects](#collections-and-nested-objects) - - [Debug Logging](#debug-logging) -- [Building from Source](#building-from-source) - [Contributing](#contributing) - [License](#license) - [Links](#links) @@ -234,55 +232,17 @@ Project project = ProjectBuilder.create() .build(); ``` -### Debug Logging - -Simple Builders supports detailed debug logging to trace the builder generation process. Enable it with the `-Averbose=true` compiler argument for detailed insights into field discovery, method analysis, and code generation. - -For complete documentation on enabling and using debug logging, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). - -## Building from Source - -1. Clone the repository: - ```bash - git clone https://github.com/java-helpers/simple-builders.git - cd simple-builders - ``` - -2. Build the project: - ```bash - mvn clean install - ``` - -3. Run tests: - ```bash - mvn test - ``` - ## Contributing -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on: +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for: - Development setup and project structure - Building and testing strategies (important for annotation processor modules) +- Debugging with verbose output - Code style and formatting - Pull request process -**Quick Start for Contributors:** - -```bash -# Clone and build -git clone https://github.com/java-helpers/simple-builders.git -cd simple-builders -mvn clean install - -# Run tests (requires processor to be installed first) -mvn test -pl processor,example -am -``` - -### Releasing - -For maintainers: -- **Release process:** See [RELEASE.md](RELEASE.md) for releasing new versions +For maintainers, see [RELEASE.md](RELEASE.md) for the release process. ## License From 20be5c7eb01bbc03c10cbbdbd23df300c04d56bd Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 16:07:20 +0100 Subject: [PATCH 14/24] Adding documentation in README.md for with-interface --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 65295563..992141e9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [Validation Annotations](#validation-annotations) - [Conditional Builder Logic](#conditional-builder-logic) - [Collections and Nested Objects](#collections-and-nested-objects) + - [With Interface Pattern](#with-interface-pattern) - [Contributing](#contributing) - [License](#license) - [Links](#links) @@ -32,8 +33,8 @@ Simple Builders is a Java [annotation processor](https://docs.oracle.com/en/java - **Type-Safe Builders**: Compile-time type checking for all builder methods - **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 +- **With Interface Pattern**: Type-safe object modifications using generated With interfaces ## Requirements @@ -232,6 +233,29 @@ Project project = ProjectBuilder.create() .build(); ``` + +### With Interface Pattern + +Simple Builders generates a nested `With` interface for each builder field, enabling a clean, type-safe way to create modified copies of objects. This pattern is particularly useful for creating variations of an object: + +```java +Person person = PersonBuilder.create() + .name("John Doe") + .age(30) + .build(); + +// Create a modified copy using the With interface +Person olderPerson = PersonBuilder.create() + .with(person) + .age(31) // Only change the age + .build(); + +// By implementing the With interface, you can create modified copies of objects in a type-safe way +Person youngerPerson = person.with(p -> p.age(29)); +``` + +The `With` interface provides type-safe setter methods that mirror the builder's API, making it easy to create object variations without manually copying all fields. + ## Contributing Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for: From b368b4f862af3e17d0ec39e1d91db17e7fceab57 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 16:08:29 +0100 Subject: [PATCH 15/24] Adding acknoledgements to README --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 992141e9..89897e93 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - [With Interface Pattern](#with-interface-pattern) - [Contributing](#contributing) - [License](#license) +- [Acknowledgements](#acknowledgements) - [Links](#links) ## What is Simple Builders? @@ -272,6 +273,29 @@ For maintainers, see [RELEASE.md](RELEASE.md) for the release process. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +## Acknowledgements + +This project was made possible thanks to the following: + +### Inspiration and Patterns + +- **[Benji Weber](https://benjiweber.co.uk/blog/2020/09/19/fun-with-java-records/)** - The With interface pattern is inspired by Benji's innovative work on functional builders and extending Java Records. +- **[RecordBuilder](https://github.com/Randgalt/record-builder)** by Randall Hauch - A state-of-the-art builder solution for Java records. If your project uses records exclusively, RecordBuilder is an excellent choice. Simple Builders extends these concepts to traditional Java classes. + +### Tools and Libraries + +- **[JavaPoet](https://github.com/palantir/javapoet)** - An excellent library for generating Java source code. Originally created by Square, now maintained by Palantir. JavaPoet made it straightforward to generate clean, readable builder code. +- **[Google Compile Testing](https://github.com/google/compile-testing)** - Essential for testing annotation processors with comprehensive compilation diagnostics. + +### Learning Resources + +The following resources were invaluable for understanding annotation processing: + +- **[Baeldung: Java Annotation Processing and Creating a Builder](https://www.baeldung.com/java-annotation-processing-builder)** - Comprehensive guide to annotation processing fundamentals +- **[SkyRo Tech: Code Generation with JavaPoet in Practice](https://medium.com/skyro-tech/code-generation-with-javapoet-on-practice-bfbe8ca56a61)** - Practical examples of using JavaPoet +- **[Annotation Processing Demo](https://github.com/ledungcobra/annotation-processing-demo)** by Le Dung - Hands-on examples of annotation processor implementation + +Thank you to all contributors and the Java community for making this project possible! ## Links From 20c9c6c0d49c3cedd43aac20aa2e4ac0cb325e28 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 16:22:11 +0100 Subject: [PATCH 16/24] Adding functionality to print the content of generated source code --- CONTRIBUTING.md | 26 ++++++++++- .../processor/testing/ProcessorTestUtils.java | 46 +++++++++++++++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10ce57e0..45cc9122 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -222,6 +222,9 @@ mvn test -Dsimplebuilder.verbose=true - Annotation processing steps - Code generation details - Exact error locations +- Complete generated source code (printed before assertions run) + +For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). **Example output:** ``` @@ -233,11 +236,30 @@ mvn test -Dsimplebuilder.verbose=true [DEBUG] -> Adding field: name (type: java.lang.String) [DEBUG] Generated 4 methods for field: name ============================================= + +========== Generated Source Files ========== + +--- ProjectBuilder.java --- +package test; + +public class ProjectBuilder { + private String name; + + public ProjectBuilder name(String name) { + this.name = name; + return this; + } + ... +} +--- End of ProjectBuilder.java --- +============================================= ``` -This is **critical** for debugging because annotation processing happens inside Google's compile-testing framework, making it otherwise invisible. +This makes it easy to compare expected vs actual generated code without needing a debugger. -For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). +**Why is this important?** + +This is **critical** for debugging because annotation processing happens inside Google's compile-testing framework, making it otherwise invisible. ## Code Style 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 a64fb0b1..b1810942 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 @@ -53,11 +53,11 @@ public static boolean isVerboseEnabled() { } /** - * Prints compilation diagnostics (notes, warnings, errors) to System.out if verbose mode is - * enabled. + * Prints compilation diagnostics (notes, warnings, errors) and generated source files to + * System.out if verbose mode is enabled. * - *

This is useful for debugging test failures, as it makes the processor's debug output visible - * in the test console output and CI logs. + *

This is useful for debugging test failures, as it makes the processor's debug output and + * generated code visible in the test console output and CI logs. * * @param compilation the compilation result to print diagnostics from */ @@ -87,6 +87,44 @@ public static void printDiagnosticsOnVerbose(Compilation compilation) { } System.out.println("=============================================\n"); + + // Print generated source files + printGeneratedSourcesOnVerbose(compilation); + } + + /** + * Prints all generated source files to System.out if verbose mode is enabled. + * + *

This displays the actual generated code before assertions run, making it easy to compare + * expected vs actual output without debugging. + * + * @param compilation the compilation result containing generated files + */ + public static void printGeneratedSourcesOnVerbose(Compilation compilation) { + if (!isVerboseEnabled()) { + return; + } + + var generatedFiles = compilation.generatedSourceFiles(); + if (generatedFiles.isEmpty()) { + System.out.println("========== No Source Files Generated ==========\n"); + return; + } + + System.out.println("========== Generated Source Files =========="); + generatedFiles.forEach( + file -> { + try { + String fileName = file.getName(); + String content = file.getCharContent(false).toString(); + System.out.println("\n--- " + fileName + " ---"); + System.out.println(content); + System.out.println("--- End of " + fileName + " ---"); + } catch (Exception e) { + System.err.println("Failed to read generated file: " + e.getMessage()); + } + }); + System.out.println("=============================================\n"); } /** From 42b1ff11a4929f9ec337878fdff3c2a9e019f784 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 17:35:12 +0100 Subject: [PATCH 17/24] Fixing type of generated consumer --- .../builders/processor/util/BuilderDefinitionCreator.java | 6 ++---- 1 file changed, 2 insertions(+), 4 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 c73a7312..f71d539a 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 @@ -1014,10 +1014,8 @@ private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDe MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName("b"); // For interface methods, we store the full type as a string - TypeName consumerType = - new TypeName( - "java.util.function", - "Consumer<" + builderDef.getBuilderTypeName().getClassName() + ">"); + TypeNameGeneric consumerType = + new TypeNameGeneric(map2TypeName(Consumer.class), builderDef.getBuilderTypeName()); parameter.setParameterTypeName(consumerType); method.addParameter(parameter); From 2fa56f4181057d3feab92b29538caaf791b3fa2d Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 17:35:49 +0100 Subject: [PATCH 18/24] Refactoring JavaCodeGenerator to reuse existing mapping-functions and using code with less complexity --- .../processor/util/JavaCodeGenerator.java | 88 +++++-------------- 1 file changed, 24 insertions(+), 64 deletions(-) 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 a5e36208..fe1edc3a 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 @@ -464,8 +464,9 @@ private TypeSpec createNestedType( } // Add methods to the nested type + boolean isInterface = nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE; for (MethodDto method : nestedType.getMethods()) { - MethodSpec methodSpec = createNestedTypeMethod(method, dtoClass, builderClass); + MethodSpec methodSpec = createNestedTypeMethod(method, isInterface); typeBuilder.addMethod(methodSpec); } @@ -473,82 +474,41 @@ private TypeSpec createNestedType( } /** - * Creates a method for a nested type (default interface method with body). + * Creates a MethodSpec for a method of a nested type (e.g., With interface). * - * @param method the method DTO - * @param dtoClass the DTO class name - * @param builderClass the builder class name + * @param methodDto the method to create + * @param isInterface whether the nested type is an interface * @return the MethodSpec */ - private MethodSpec createNestedTypeMethod( - MethodDto method, ClassName dtoClass, ClassName builderClass) { + private MethodSpec createNestedTypeMethod(MethodDto methodDto, boolean isInterface) { MethodSpec.Builder methodBuilder = - MethodSpec.methodBuilder(method.getMethodName()).addModifiers(PUBLIC); - - // Set return type (returnType is mandatory in MethodDto) - TypeName returnTypeName = method.getReturnType(); - if (returnTypeName.getClassName().equals(dtoClass.simpleName())) { - methodBuilder.returns(dtoClass); - } else if (returnTypeName.getClassName().equals(builderClass.simpleName())) { - methodBuilder.returns(builderClass); - } else { - // Use the mapper for other types - methodBuilder.returns(JavapoetMapper.map2ClassName(returnTypeName)); - } - - // Add parameters - for (MethodParameterDto param : method.getParameters()) { - com.palantir.javapoet.TypeName paramType; - String typeStr = param.getParameterType().getClassName(); + MethodSpec.methodBuilder(methodDto.getMethodName()).addModifiers(PUBLIC); - // Handle Consumer - if (typeStr.startsWith("Consumer<")) { - paramType = - ParameterizedTypeName.get( - ClassName.get(java.util.function.Consumer.class), builderClass); - } else { - paramType = ClassName.bestGuess(typeStr); - } + // Set return type using mapper + methodBuilder.returns(JavapoetMapper.map2ParameterType(methodDto.getReturnType())); - methodBuilder.addParameter(paramType, param.getParameterName()); + // Add parameters using mapper + for (MethodParameterDto paramDto : methodDto.getParameters()) { + methodBuilder.addParameter(createParameter(paramDto)); } + // Add modifiers if defined + methodDto.getModifier().ifPresent(methodBuilder::addModifiers); + // Add Javadoc - if (method.getJavadoc() != null) { - methodBuilder.addJavadoc(method.getJavadoc()); + if (methodDto.getJavadoc() != null) { + methodBuilder.addJavadoc(methodDto.getJavadoc()); } - // Add default modifier and method body for interface methods - methodBuilder.addModifiers(javax.lang.model.element.Modifier.DEFAULT); - - // Add method body from the MethodCodeDto - MethodCodeDto codeDto = method.getMethodCodeDto(); - if (codeDto.getCodeFormat() != null && !codeDto.getCodeFormat().isEmpty()) { - // Build code with type arguments - String code = codeDto.getCodeFormat(); - java.util.Map args = new java.util.HashMap<>(); - - // Replace placeholders with appropriate classes - for (MethodCodePlaceholder placeholder : codeDto.getCodeArguments()) { - if (placeholder instanceof MethodCodeTypePlaceholder) { - // Determine which class to use based on placeholder label - if (placeholder.getLabel().equals("dtoType")) { - args.put(placeholder.getLabel(), dtoClass); - } else if (placeholder.getLabel().equals("builderType")) { - args.put(placeholder.getLabel(), builderClass); - } else if (placeholder.getValue() - instanceof org.javahelpers.simple.builders.processor.dtos.TypeName typeName) { - // Map TypeName to ClassName for proper import handling - args.put(placeholder.getLabel(), JavapoetMapper.map2ClassName(typeName)); - } else if (placeholder.getValue() instanceof String className) { - args.put(placeholder.getLabel(), className); - } else { - throw new IllegalArgumentException("Unknown placeholder type: " + placeholder); - } - } + // Add method body if present + MethodCodeDto codeDto = methodDto.getMethodCodeDto(); + if (codeDto != null) { + // Add default modifier for interface methods with implementation + if (isInterface) { + methodBuilder.addModifiers(javax.lang.model.element.Modifier.DEFAULT); } - methodBuilder.addNamedCode(code, args); + methodBuilder.addCode(map2CodeBlock(codeDto)); } return methodBuilder.build(); From 1acd6ce88f77ef8fd28a226e29b5f5ce03031460 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 17:44:29 +0100 Subject: [PATCH 19/24] Fixing sonarQube findings --- .../processor/util/JavaCodeGenerator.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) 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 fe1edc3a..c823eb33 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 @@ -51,6 +51,9 @@ /** JavaCodeGenerator generates with BuilderDefinitionDto JavaCode for the builder. */ public class JavaCodeGenerator { /** Util class for source code generation of type {@code javax.annotation.processing.Filer}. */ + private static final String CREATE_METHOD = "create"; + + private static final String THROW_EXCEPTION_FORMAT = "throw new $T($S)"; private final Filer filer; /** Logger for debug output during code generation. */ @@ -151,7 +154,7 @@ public void generateBuilder(BuilderDefinitionDto builderDef) throws BuilderExcep // Adding nested types (e.g., With interface) for (NestedTypeDto nestedType : builderDef.getNestedTypes()) { - TypeSpec nestedTypeSpec = createNestedType(nestedType, dtoBaseClass, builderBaseClass); + TypeSpec nestedTypeSpec = createNestedType(nestedType); classBuilder.addType(nestedTypeSpec); logger.debug(" Generated nested type: %s", nestedType.getTypeName()); } @@ -253,7 +256,7 @@ private void addFieldInitializationWithValidation( if (field.isNonNullable()) { cb.beginControlFlow("if (this.$N.value() == null)", field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalArgumentException.class, "Cannot initialize builder from instance: field '" + field.getFieldName() @@ -299,13 +302,13 @@ private MethodSpec createMethodBuild( if (field.isNonNullable()) { mb.beginControlFlow("if (!this.$N.isSet())", field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalStateException.class, "Required field '" + field.getFieldName() + "' must be set before calling build()") .endControlFlow(); mb.beginControlFlow("if (this.$N.value() == null)", field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalStateException.class, "Field '" + field.getFieldName() @@ -323,7 +326,7 @@ private MethodSpec createMethodBuild( field.getFieldName(), field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalStateException.class, "Field '" + field.getFieldName() @@ -360,7 +363,7 @@ private MethodSpec createMethodStaticCreate( com.palantir.javapoet.ClassName dtoBaseClass, List generics) { MethodSpec.Builder methodBuilder = - MethodSpec.methodBuilder("create") + MethodSpec.methodBuilder(CREATE_METHOD) .addModifiers(STATIC, PUBLIC) .addJavadoc( """ @@ -438,15 +441,12 @@ private MethodSpec createMethodConditionalPositiveOnly( } /** - * Creates a nested type (interface or class) from the DTO definition. + * Creates a TypeSpec for a nested type (e.g., With interface). * * @param nestedType the nested type definition - * @param dtoClass the DTO class name - * @param builderClass the builder class name * @return the TypeSpec for the nested type */ - private TypeSpec createNestedType( - NestedTypeDto nestedType, ClassName dtoClass, ClassName builderClass) { + private TypeSpec createNestedType(NestedTypeDto nestedType) { TypeSpec.Builder typeBuilder; if (nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE) { From 14a67873e66ba9c39d2e7b1c1912cc88f01cb62c Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 22:15:13 +0100 Subject: [PATCH 20/24] Removing duplications of string literals --- .../processor/util/BuilderDefinitionCreator.java | 12 ++++++------ .../builders/processor/util/JavaCodeGenerator.java | 4 ++-- 2 files changed, 8 insertions(+), 8 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 f71d539a..cc769ac8 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 @@ -689,7 +689,7 @@ private static MethodDto createFieldConsumer( private static MethodDto createStringBuilderConsumer( String fieldName, String transform, TypeName builderType) { - TypeName stringBuilderType = new TypeName("java.lang", "StringBuilder"); + TypeName stringBuilderType = map2TypeName(StringBuilder.class); TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), stringBuilderType); MethodParameterDto parameter = new MethodParameterDto(); @@ -824,7 +824,7 @@ private static MethodDto createFieldSupplier( private static MethodDto createStringFormatMethodWithTransform( String fieldName, String transform, List annotations, TypeName builderType) { - TypeName stringType = new TypeName("java.lang", "String"); + TypeName stringType = map2TypeName(String.class); MethodParameterDto formatParam = new MethodParameterDto(); formatParam.setParameterName("format"); @@ -1033,9 +1033,9 @@ private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDe """); method.addArgument("builderType", builderDef.getBuilderTypeName()); method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); - method.addArgument("classcastexception", new TypeName("java.lang", "ClassCastException")); + method.addArgument("classcastexception", map2TypeName(ClassCastException.class)); method.addArgument( - "illegalargumentexception", new TypeName("java.lang", "IllegalArgumentException")); + "illegalargumentexception", map2TypeName(IllegalArgumentException.class)); method.setJavadoc( "Applies modifications to a builder initialized from this instance and returns the built object.\n\n" @@ -1069,9 +1069,9 @@ private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef """); method.addArgument("builderType", builderDef.getBuilderTypeName()); method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); - method.addArgument("classcastexception", new TypeName("java.lang", "ClassCastException")); + method.addArgument("classcastexception", map2TypeName(ClassCastException.class)); method.addArgument( - "illegalargumentexception", new TypeName("java.lang", "IllegalArgumentException")); + "illegalargumentexception", map2TypeName(IllegalArgumentException.class)); method.setJavadoc( "Creates a builder initialized from this instance.\n\n" 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 c823eb33..09fbac65 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 @@ -51,7 +51,7 @@ /** JavaCodeGenerator generates with BuilderDefinitionDto JavaCode for the builder. */ public class JavaCodeGenerator { /** Util class for source code generation of type {@code javax.annotation.processing.Filer}. */ - private static final String CREATE_METHOD = "create"; + private static final String METHOD_NAME_CREATE = "create"; private static final String THROW_EXCEPTION_FORMAT = "throw new $T($S)"; private final Filer filer; @@ -363,7 +363,7 @@ private MethodSpec createMethodStaticCreate( com.palantir.javapoet.ClassName dtoBaseClass, List generics) { MethodSpec.Builder methodBuilder = - MethodSpec.methodBuilder(CREATE_METHOD) + MethodSpec.methodBuilder(METHOD_NAME_CREATE) .addModifiers(STATIC, PUBLIC) .addJavadoc( """ From ff0f798dd60f85d9e8f1191c8a9bf66da6d4b674 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 22:15:31 +0100 Subject: [PATCH 21/24] Replacing string-concatenation by text-block --- .../processor/util/BuilderDefinitionCreator.java | 16 +++++++++++----- 1 file changed, 11 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 cc769ac8..37d7befe 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 @@ -1038,9 +1038,12 @@ private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDe "illegalargumentexception", map2TypeName(IllegalArgumentException.class)); method.setJavadoc( - "Applies modifications to a builder initialized from this instance and returns the built object.\n\n" - + "@param b the consumer to apply modifications\n" - + "@return the modified instance"); + """ + Applies modifications to a builder initialized from this instance and returns the built object. + + @param b the consumer to apply modifications + @return the modified instance + """); return method; } @@ -1074,8 +1077,11 @@ private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef "illegalargumentexception", map2TypeName(IllegalArgumentException.class)); method.setJavadoc( - "Creates a builder initialized from this instance.\n\n" - + "@return a builder initialized with this instance's values"); + """ + Creates a builder initialized from this instance. + + @return a builder initialized with this instance's values + """); return method; } From 3ed50b8d38d1a50ce84cae567b0738e0a5cbabe4 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 22:19:09 +0100 Subject: [PATCH 22/24] Fixing code-format --- .../processor/util/BuilderDefinitionCreator.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 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 37d7befe..bca1c378 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 @@ -1034,11 +1034,10 @@ private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDe method.addArgument("builderType", builderDef.getBuilderTypeName()); method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); method.addArgument("classcastexception", map2TypeName(ClassCastException.class)); - method.addArgument( - "illegalargumentexception", map2TypeName(IllegalArgumentException.class)); + method.addArgument("illegalargumentexception", map2TypeName(IllegalArgumentException.class)); method.setJavadoc( - """ + """ Applies modifications to a builder initialized from this instance and returns the built object. @param b the consumer to apply modifications @@ -1073,11 +1072,10 @@ private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef method.addArgument("builderType", builderDef.getBuilderTypeName()); method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); method.addArgument("classcastexception", map2TypeName(ClassCastException.class)); - method.addArgument( - "illegalargumentexception", map2TypeName(IllegalArgumentException.class)); + method.addArgument("illegalargumentexception", map2TypeName(IllegalArgumentException.class)); method.setJavadoc( - """ + """ Creates a builder initialized from this instance. @return a builder initialized with this instance's values From 67e004a8a241076febd828904817383a1b0fb379 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 22:19:21 +0100 Subject: [PATCH 23/24] Adding missing return type --- .../simple/builders/processor/util/BuilderDefinitionCreator.java | 1 + 1 file changed, 1 insertion(+) 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 bca1c378..a7514d66 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 @@ -711,6 +711,7 @@ private static MethodDto createStringBuilderConsumer( methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); methodDto.addArgument("transform", transform); methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); + methodDto.setReturnType(builderType); return methodDto; } From 4f1f86cdb019508cf6b5f9892d2ed69a973a0125 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Mon, 27 Oct 2025 22:42:33 +0100 Subject: [PATCH 24/24] Improving code --- .../simple/builders/processor/util/JavaCodeGenerator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 09fbac65..4654de9a 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 @@ -449,7 +449,8 @@ private MethodSpec createMethodConditionalPositiveOnly( private TypeSpec createNestedType(NestedTypeDto nestedType) { TypeSpec.Builder typeBuilder; - if (nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE) { + boolean isInterface = nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE; + if (isInterface) { typeBuilder = TypeSpec.interfaceBuilder(nestedType.getTypeName()); } else { typeBuilder = TypeSpec.classBuilder(nestedType.getTypeName()); @@ -464,7 +465,6 @@ private TypeSpec createNestedType(NestedTypeDto nestedType) { } // Add methods to the nested type - boolean isInterface = nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE; for (MethodDto method : nestedType.getMethods()) { MethodSpec methodSpec = createNestedTypeMethod(method, isInterface); typeBuilder.addMethod(methodSpec);