diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java index 7f0fd9ac..d4e951ae 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java @@ -29,6 +29,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import javax.lang.model.SourceVersion; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; @@ -384,6 +385,9 @@ private static Optional getValueMember(AnnotationMirror mirror) { *
  • {@code String} — wrapped in double quotes, e.g. {@code "GENERAL"} *
  • {@code char} — wrapped in single quotes, e.g. {@code 'A'} *
  • numeric/boolean primitives — used as-is, e.g. {@code 0.0}, {@code true} + *
  • enum types — a simple constant name is qualified with the enum class name, e.g. {@code + * GOOD} becomes {@code ItemCondition.GOOD}; already-qualified names or complex expressions + * are left as-is *
  • complex types (List, custom objects) — used as a raw Java expression, e.g. {@code * List.of()} * @@ -399,6 +403,24 @@ public static String formatDefaultExpression(String rawValue, TypeName fieldType if (fieldType.equals(TypeNamePrimitive.CHAR)) { return "'%s'".formatted(rawValue); } + if (fieldType.isEnumType() && isSimpleIdentifier(rawValue)) { + return fieldType.getClassName() + "." + rawValue; + } return rawValue; } + + /** + * Checks whether the given raw value is a simple Java identifier (e.g. an enum constant name like + * {@code GOOD}). + * + *

    Values containing dots (e.g. {@code ItemCondition.GOOD}), parentheses (e.g. {@code new + * IntegerDto(12)}), spaces, or other non-identifier characters are not simple identifiers and + * will not be auto-qualified. + * + * @param rawValue the raw string to check + * @return {@code true} if the value is a simple Java identifier + */ + private static boolean isSimpleIdentifier(String rawValue) { + return SourceVersion.isIdentifier(rawValue); + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java index d7610c4e..9ac9edde 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java @@ -33,6 +33,7 @@ import java.util.Optional; import java.util.Set; import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; import javax.lang.model.element.Modifier; import javax.lang.model.element.TypeElement; import javax.lang.model.element.TypeParameterElement; @@ -649,6 +650,9 @@ public TypeName visitDeclared(DeclaredType t, Void p) { String simpleClassName = elementOfParameter.getSimpleName().toString(); String packageName = context.getPackageName(elementOfParameter); TypeName rawType = new TypeName(packageName, simpleClassName); + if (elementOfParameter.getKind() == ElementKind.ENUM) { + rawType.setEnumType(true); + } TypeMirror enclosingType = t.getEnclosingType(); TypeName enclosing = (enclosingType.getKind() != NONE) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java index ec8719e8..bd2a1069 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java @@ -51,6 +51,9 @@ public class TypeName { /** Whether this type has an empty constructor. */ private boolean hasEmptyConstructor = false; + /** Whether this type represents an enum. */ + private boolean enumType = false; + /** * Constructor for TypeName. * @@ -157,6 +160,24 @@ public void setHasEmptyConstructor(boolean hasEmptyConstructor) { this.hasEmptyConstructor = hasEmptyConstructor; } + /** + * Returns whether this type represents an enum. + * + * @return true if this type is an enum + */ + public boolean isEnumType() { + return enumType; + } + + /** + * Sets whether this type represents an enum. + * + * @param enumType true if this type is an enum + */ + public void setEnumType(boolean enumType) { + this.enumType = enumType; + } + /** * Helper function to hold a specific inner type.Is empty if this is a class without generic parts * or a class has multiple generics. diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java index 2fa5c7d6..b5094798 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java @@ -30,6 +30,9 @@ import com.google.testing.compile.Compilation; import java.util.stream.Stream; import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.analysis.FieldAnnotationExtractor; +import org.javahelpers.simple.builders.processor.model.type.TypeName; +import org.javahelpers.simple.builders.processor.model.type.TypeNamePrimitive; import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; import org.junit.jupiter.api.Test; @@ -129,6 +132,48 @@ public PlainRecord build() { PlainRecord result = new PlainRecord(this.name.value(), this.age.value()); return result; } + """), + Arguments.of( + "EnumDefaultRecord", + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record EnumDefaultRecord( + String name, + @Default("ACTIVE") Status status) {} + + enum Status { ACTIVE, INACTIVE, PENDING } + """, + """ + public EnumDefaultRecord build() { + EnumDefaultRecord result = new EnumDefaultRecord(this.name.value(), this.status.valueOr(Status.ACTIVE)); + return result; + } + """), + Arguments.of( + "EnumQualifiedDefaultRecord", + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record EnumQualifiedDefaultRecord( + String name, + @Default("Priority.HIGH") Priority priority) {} + + enum Priority { LOW, MEDIUM, HIGH } + """, + """ + public EnumQualifiedDefaultRecord build() { + EnumQualifiedDefaultRecord result = new EnumQualifiedDefaultRecord(this.name.value(), this.priority.valueOr(Priority.HIGH)); + return result; + } """)); } @@ -197,6 +242,124 @@ public OrderDto build() { generatedCode, "public OrderDtoBuilder status(String status)"); } + /** + * Verifies that a {@code @Default} annotation on an enum-typed setter field qualifies the raw + * constant name with the enum class name, producing e.g. {@code .orElse(ItemCondition.GOOD)} + * instead of the unqualified {@code .orElse(GOOD)} that would fail to compile. + * + * @see Issue #260 + */ + @Test + void defaultAppliedWhenUnset_setterField_enumQualified() { + String className = "ChoralScore"; + String builderClassName = className + "Builder"; + + JavaFileObject enumSource = + ProcessorTestUtils.forSource( + """ + package test; + + public enum ItemCondition { GOOD, FAIR, POOR } + """); + + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ChoralScore { + private String title; + @Default("GOOD") + private ItemCondition condition; + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public ItemCondition getCondition() { return condition; } + public void setCondition(ItemCondition condition) { this.condition = condition; } + } + """); + + Compilation compilation = compile(enumSource, sourceFile); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // build() must use ifSet().orElse() with the qualified enum constant + ProcessorAsserts.assertContaining( + generatedCode, + """ + public ChoralScore build() { + ChoralScore result = new ChoralScore(); + this.condition.ifSet(result::setCondition).orElse(ItemCondition.GOOD); + this.title.ifSet(result::setTitle); + return result; + } + """); + } + + /** + * Verifies that a {@code @Default} annotation with a complex expression (e.g. {@code new + * ScoreValue(12)}) on a non-enum field is used as-is without any qualification, supporting + * constructor calls and other arbitrary Java expressions. + */ + @Test + void defaultAppliedWhenUnset_setterField_complexExpressionUsedAsIs() { + String className = "ScoreDto"; + String builderClassName = className + "Builder"; + + JavaFileObject scoreValueSource = + ProcessorTestUtils.forSource( + """ + package test; + + public class ScoreValue { + private final int value; + public ScoreValue(int value) { this.value = value; } + public int getValue() { return value; } + } + """); + + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.Default; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ScoreDto { + private String name; + @Default("new ScoreValue(12)") + private ScoreValue score; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public ScoreValue getScore() { return score; } + public void setScore(ScoreValue score) { this.score = score; } + } + """); + + Compilation compilation = compile(scoreValueSource, sourceFile); + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + + // build() must use the complex expression as-is (no qualification attempted) + ProcessorAsserts.assertContaining( + generatedCode, + """ + public ScoreDto build() { + ScoreDto result = new ScoreDto(); + this.name.ifSet(result::setName); + this.score.ifSet(result::setScore).orElse(new ScoreValue(12)); + return result; + } + """); + } + /** * Verifies that a setter-based class field without {@code @Default} generates plain * {@code ifSet(result::setStatus);} with no {@code .orElse()} call. This is a regression guard to @@ -356,4 +519,30 @@ public JakartaRecord build() { } """); } + + // === formatDefaultExpression unit tests === + + private static Stream formatDefaultExpressionCases() { + TypeName enumType = new TypeName("test", "Status"); + enumType.setEnumType(true); + TypeName nonEnumType = new TypeName("test", "Other"); + return Stream.of( + Arguments.of(enumType, "ACTIVE", "Status.ACTIVE"), + Arguments.of(enumType, "", ""), + Arguments.of(enumType, "1BAD", "1BAD"), + Arguments.of(enumType, "GO OD", "GO OD"), + Arguments.of(enumType, "Status.ACTIVE", "Status.ACTIVE"), + Arguments.of(enumType, "new Status()", "new Status()"), + Arguments.of(nonEnumType, "ACTIVE", "ACTIVE"), + Arguments.of(TypeName.of(String.class), "hello", "\"hello\""), + Arguments.of(TypeNamePrimitive.CHAR, "X", "'X'")); + } + + @ParameterizedTest + @MethodSource("formatDefaultExpressionCases") + void formatDefaultExpression_producesExpectedOutput( + TypeName fieldType, String rawValue, String expected) { + org.junit.jupiter.api.Assertions.assertEquals( + expected, FieldAnnotationExtractor.formatDefaultExpression(rawValue, fieldType)); + } }