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 d6b78afa..0cbf7220 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 @@ -63,6 +63,9 @@ public class BuilderDefinitionCreator { private static final String ARG_BUILDER_FIELD_WRAPPER = "builderFieldWrapper"; private static final String ARG_HELPER_TYPE = "helperType"; + // Type constants + private static final TypeName TRACKED_VALUE_TYPE = TypeName.of(TrackedValue.class); + // Parameter name suffixes private static final String SUFFIX_CONSUMER = "Consumer"; private static final String SUFFIX_SUPPLIER = "Supplier"; @@ -230,7 +233,13 @@ private static boolean isMethodRelevantForBuilder( private static void addAdditionalHelperMethodsForField( FieldDto result, String fieldName, TypeName fieldType) { - // Only process generic types (List, Set, Map, etc.) + // Check for String type (not array) and add format method + if (isString(fieldType) && !(fieldType instanceof TypeNameArray)) { + result.addMethod( + createStringFormatMethodWithTransform(fieldName, "String.format(format, args)")); + } + + // Only process generic types (List, Set, Map, Optional, etc.) if (!(fieldType instanceof TypeNameGeneric fieldTypeGeneric)) { return; } @@ -253,6 +262,18 @@ private static void addAdditionalHelperMethodsForField( false); result.addMethod( createFieldSetterWithTransform(fieldName, "Map.ofEntries(%s)", mapEntryType)); + } else if (isOptional(fieldType) && innerTypesCnt == 1) { + // Add setter that accepts the inner type T and wraps it in Optional.of() + result.addMethod( + createFieldSetterWithTransform(fieldName, "Optional.of(%s)", innerTypes.get(0))); + + // If Optional, add format method + TypeName innerType = innerTypes.get(0); + if (isString(innerType)) { + result.addMethod( + createStringFormatMethodWithTransform( + fieldName, "Optional.of(String.format(format, args))")); + } } } @@ -271,6 +292,7 @@ private static void addConsumerMethodsForField( if (isFunctionalInterface(fieldTypeElement)) { return; } + Optional builderTypeOpt = findBuilderType(fieldParameter, context); if (builderTypeOpt.isPresent()) { TypeName builderType = builderTypeOpt.get(); @@ -310,6 +332,11 @@ && hasEmptyConstructor(fieldTypeElement, context)) { fieldName, map2TypeName(HashSetBuilder.class), fieldTypeGeneric.getInnerTypeArguments().get(0))); + } else if (shouldGenerateStringBuilderConsumer(fieldType)) { + // Generate StringBuilder consumer for String or Optional + String transform = + isOptionalString(fieldType) ? "Optional.of(builder.toString())" : "builder.toString()"; + result.addMethod(createStringBuilderConsumer(fieldName, transform)); } } @@ -319,6 +346,8 @@ private static void addSupplierMethodsForField( if (isFunctionalInterface(fieldTypeElement)) { return; } + + // For all fields including Optional, use the real field type for suppliers result.addMethod(createFieldSupplier(fieldName, fieldType)); } @@ -444,7 +473,7 @@ private static MethodDto createFieldSetterWithTransform( """); methodDto.addArgument(ARG_FIELD_NAME, fieldName); methodDto.addArgument(ARG_DTO_METHOD_PARAMS, params); - methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TypeName.of(TrackedValue.class)); + methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); return methodDto; } @@ -468,7 +497,33 @@ private static MethodDto createFieldConsumer(String fieldName, TypeName fieldTyp methodDto.addArgument(ARG_FIELD_NAME, fieldName); methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); methodDto.addArgument(ARG_HELPER_TYPE, fieldType); - methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TypeName.of(TrackedValue.class)); + methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); + return methodDto; + } + + private static MethodDto createStringBuilderConsumer(String fieldName, String transform) { + TypeName stringBuilderType = new TypeName("java.lang", "StringBuilder"); + TypeNameGeneric consumerType = + new TypeNameGeneric(map2TypeName(Consumer.class), stringBuilderType); + MethodParameterDto parameter = new MethodParameterDto(); + parameter.setParameterName(fieldName + "StringBuilderConsumer"); + parameter.setParameterTypeName(consumerType); + MethodDto methodDto = new MethodDto(); + methodDto.setMethodName(fieldName); + methodDto.addParameter(parameter); + methodDto.setModifier(Modifier.PUBLIC); + methodDto.setMethodType(MethodTypes.CONSUMER); + methodDto.setCode( + """ + StringBuilder builder = new StringBuilder(); + $dtoMethodParam:N.accept(builder); + this.$fieldName:N = $builderFieldWrapper:T.changedValue($transform:N); + return this; + """); + methodDto.addArgument(ARG_FIELD_NAME, fieldName); + methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); + methodDto.addArgument("transform", transform); + methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); return methodDto; } @@ -498,7 +553,7 @@ private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeNa methodDto.addArgument(ARG_FIELD_NAME, fieldName); methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); methodDto.addArgument(ARG_HELPER_TYPE, builderType); - methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TypeName.of(TrackedValue.class)); + methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); return methodDto; } @@ -519,10 +574,56 @@ private static MethodDto createFieldSupplier(String fieldName, TypeName fieldTyp """); methodDto.addArgument(ARG_FIELD_NAME, fieldName); methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); - methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TypeName.of(TrackedValue.class)); + methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); return methodDto; } + private static MethodDto createStringFormatMethodWithTransform( + String fieldName, String transform) { + TypeName stringType = new TypeName("java.lang", "String"); + + MethodParameterDto formatParam = new MethodParameterDto(); + formatParam.setParameterName("format"); + formatParam.setParameterTypeName(stringType); + + MethodParameterDto argsParam = new MethodParameterDto(); + argsParam.setParameterName("args"); + argsParam.setParameterTypeName(new TypeNameArray(TypeName.of(Object.class), false)); + + MethodDto methodDto = new MethodDto(); + methodDto.setMethodName(fieldName); + methodDto.addParameter(formatParam); + methodDto.addParameter(argsParam); + methodDto.setModifier(Modifier.PUBLIC); + methodDto.setMethodType(MethodTypes.PROXY); + methodDto.setCode( + """ + this.$fieldName:N = $builderFieldWrapper:T.changedValue($transform:N); + return this; + """); + methodDto.addArgument(ARG_FIELD_NAME, fieldName); + methodDto.addArgument("transform", transform); + methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); + return methodDto; + } + + /** + * Checks if a StringBuilder consumer should be generated for the given field type. This applies + * to plain String fields and Optional<String> fields. + * + * @param fieldType the type of the field + * @return true if StringBuilder consumer should be generated, false otherwise + */ + private static boolean shouldGenerateStringBuilderConsumer(TypeName fieldType) { + // Check for plain String (not array) + if (isString(fieldType) && !(fieldType instanceof TypeNameArray)) { + return true; + } + + // Check for Optional + return isOptionalString(fieldType); + } + private static Optional findBuilderType( VariableElement param, ProcessingContext context) { TypeMirror typeOfParameter = param.asType(); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/TypeNameAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/TypeNameAnalyser.java index 7f3b9391..3d86108c 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/TypeNameAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/TypeNameAnalyser.java @@ -25,6 +25,7 @@ import org.apache.commons.lang3.Strings; import org.javahelpers.simple.builders.processor.dtos.TypeName; +import org.javahelpers.simple.builders.processor.dtos.TypeNameGeneric; /** Helperclass to extract insights from TypeName. */ public class TypeNameAnalyser { @@ -76,4 +77,41 @@ public static boolean isList(TypeName typeName) { return Strings.CI.equals(typeName.getPackageName(), JAVA_UTIL_PACKAGE) && Strings.CI.equals(typeName.getClassName(), "List"); } + + /** + * Helper to check if the type is a {@code java.util.Optional}. + * + * @param typeName Type to be validated + * @return {@code true}, if it is a {@code java.util.Optional} + */ + public static boolean isOptional(TypeName typeName) { + return Strings.CI.equals(typeName.getPackageName(), JAVA_UTIL_PACKAGE) + && Strings.CI.equals(typeName.getClassName(), "Optional"); + } + + /** + * Helper to check if the type is a {@code java.lang.String}. + * + * @param typeName Type to be validated + * @return {@code true}, if it is a {@code java.lang.String} + */ + public static boolean isString(TypeName typeName) { + return Strings.CI.equals(typeName.getPackageName(), "java.lang") + && Strings.CI.equals(typeName.getClassName(), "String"); + } + + /** + * Checks if the field type is Optional<String>. + * + * @param fieldType the type of the field + * @return true if the type is Optional<String>, false otherwise + */ + public static boolean isOptionalString(TypeName fieldType) { + if (fieldType instanceof TypeNameGeneric fieldTypeGeneric + && isOptional(fieldType) + && fieldTypeGeneric.getInnerTypeArguments().size() == 1) { + return isString(fieldTypeGeneric.getInnerTypeArguments().get(0)); + } + return false; + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index ec4673b2..6db98c43 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -24,24 +24,24 @@ void shouldGenerateBasicBuilder() { String className = "SimpleDto"; String builderClassName = className + "Builder"; + // When JavaFileObject sourceFile = ProcessorTestUtils.simpleBuilderClass( "test", className, """ - private String value; - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } + private Integer value; + public Integer getValue() { return value; } + public void setValue(Integer value) { this.value = value; } """); - // When Compilation compilation = compile(sourceFile); // Then: Builder should be generated successfully String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); ProcessorAsserts.assertContaining( - generatedCode, "public SimpleDtoBuilder value(String value)", "public SimpleDto build()"); + generatedCode, "public SimpleDtoBuilder value(Integer value)", "public SimpleDto build()"); } @Test @@ -391,12 +391,14 @@ public RequiredFieldViaConstructor(String name, int age) { generatedCode, // Basic setters for constructor fields "public RequiredFieldViaConstructorBuilder name(String name)", + "public RequiredFieldViaConstructorBuilder name(String format, Object... args)", "public RequiredFieldViaConstructorBuilder age(int age)", // Supplier methods for constructor fields (the bug we fixed!) "public RequiredFieldViaConstructorBuilder name(Supplier nameSupplier)", "public RequiredFieldViaConstructorBuilder age(Supplier ageSupplier)", // Setter field should also have supplier "public RequiredFieldViaConstructorBuilder description(String description)", + "public RequiredFieldViaConstructorBuilder description(String format, Object... args)", "public RequiredFieldViaConstructorBuilder description(Supplier descriptionSupplier)"); } @@ -582,6 +584,214 @@ void shouldGenerateCollectionSettersAndProviders() { "private TrackedValue> map = unsetValue();"); } + @Test + void shouldGenerateOptionalSettersAndProviders() { + // Given + String packageName = "test"; + String className = "WithOptionals"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private java.util.Optional description; + private java.util.Optional age; + + public java.util.Optional getDescription() { return description; } + public void setDescription(java.util.Optional description) { this.description = description; } + + public java.util.Optional getAge() { return age; } + public void setAge(java.util.Optional age) { this.age = age; } + """); + + // When + Compilation compilation = compile(sourceFile); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // Direct setters with Optional and unwrapped T convenience + ProcessorAsserts.assertContaining( + generatedCode, + "public WithOptionalsBuilder description(Optional description)", + "public WithOptionalsBuilder description(Supplier> descriptionSupplier)", + "public WithOptionalsBuilder description(String description)", + "public WithOptionalsBuilder description(String format, Object... args)", + "this.description = changedValue(Optional.of(description));", + "private TrackedValue> description = unsetValue();", + "public WithOptionalsBuilder age(Optional age)", + "public WithOptionalsBuilder age(Supplier> ageSupplier)", + "public WithOptionalsBuilder age(Integer age)", + "this.age = changedValue(Optional.of(age));", + "private TrackedValue> age = unsetValue();"); + } + + @Test + void shouldGenerateStringFormatMethodForStringField() { + // Given: String field to verify format method generation + String packageName = "test"; + String className = "WithString"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private String message; + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + """); + + // When + Compilation compilation = compile(sourceFile); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // Verify both basic String setter and format method are generated + ProcessorAsserts.assertContaining( + generatedCode, + "private TrackedValue message = unsetValue();", + """ + public WithStringBuilder message(String message) { + this.message = changedValue(message); + return this; + } + """, + """ + public WithStringBuilder message(String format, Object... args) { + this.message = changedValue(String.format(format, args)); + return this; + } + """); + } + + @Test + void shouldGenerateStringFormatMethodForOptionalString() { + // Given: Optional field to verify format method generation + String packageName = "test"; + String className = "WithOptionalString"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private java.util.Optional message; + + public java.util.Optional getMessage() { return message; } + public void setMessage(java.util.Optional message) { this.message = message; } + """); + + // When + Compilation compilation = compile(sourceFile); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // Verify both unwrapped String setter and format method are generated + ProcessorAsserts.assertContaining( + generatedCode, + "private TrackedValue> message = unsetValue();", + """ + public WithOptionalStringBuilder message(String message) { + this.message = changedValue(Optional.of(message)); + return this; + } + """, + """ + public WithOptionalStringBuilder message(String format, Object... args) { + this.message = changedValue(Optional.of(String.format(format, args))); + return this; + } + """); + } + + @Test + void shouldGenerateStringBuilderConsumerForStringField() { + // Given: String field to verify StringBuilder consumer generation + String packageName = "test"; + String className = "WithStringBuilder"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private String message; + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + """); + + // When + Compilation compilation = compile(sourceFile); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // Verify StringBuilder consumer method is generated + ProcessorAsserts.assertContaining( + generatedCode, + "private TrackedValue message = unsetValue();", + """ + public WithStringBuilderBuilder message(Consumer messageStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + messageStringBuilderConsumer.accept(builder); + this.message = changedValue(builder.toString()); + return this; + } + """); + } + + @Test + void shouldGenerateStringBuilderConsumerForOptionalString() { + // Given: Optional field to verify StringBuilder consumer generation + String packageName = "test"; + String className = "WithOptString"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private java.util.Optional message; + + public java.util.Optional getMessage() { return message; } + public void setMessage(java.util.Optional message) { this.message = message; } + """); + + // When + Compilation compilation = compile(sourceFile); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // Verify StringBuilder consumer method is generated with Optional wrapping + ProcessorAsserts.assertContaining( + generatedCode, + """ + public WithOptStringBuilder message(Consumer messageStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + messageStringBuilderConsumer.accept(builder); + this.message = changedValue(Optional.of(builder.toString())); + return this; + } + """); + } + @Test void shouldDeclarePerFieldBackingField() { // Given @@ -1068,12 +1278,16 @@ void shouldGenerateSupplierMethodForField() { String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - // Expect supplier-based setter usage + // Expect supplier-based setter and backing field ProcessorAsserts.assertContaining( generatedCode, - "nameSupplier", "private TrackedValue name = unsetValue();", - "this.name = changedValue(nameSupplier.get());"); + """ + public HasSupplierBuilder name(Supplier nameSupplier) { + this.name = changedValue(nameSupplier.get()); + return this; + } + """); } protected Compilation compile(JavaFileObject... sourceFiles) { @@ -1205,7 +1419,7 @@ public class DtoWithWrappedTypes { "public DtoWithWrappedTypes build()"); // Verify no additional helper methods are generated for these generic types - // (since they don't match List/Set/Map/Optional patterns) + // (since they don't match List/Set/Map/Optional patterns with recognized inner types) ProcessorAsserts.assertNotContaining( optionalBuilder, "nameBuilderConsumer",